8 Commits

Author SHA1 Message Date
peptiderepo
85956da7c8 PR Vision v0.2.3: fix PHPCS escape-output violations in key manager renderer
WordPress.Security.EscapeOutput.OutputNotEscaped (x3):
- Lines 110+148: inline esc_url( admin_url() ) directly in echo; drop pre-assigned $form_url
- Line 128: replace $disabled variable with literal-conditional
  ( $is_const ? ' disabled aria-disabled="true"' : '' ) — no dynamic value

PEAR.Functions.FunctionCallSignature.ContentAfterOpenBracket (x1):
- Line 116: move esc_html( opening to its own line so arg starts on next line

Full file audit: no other unescaped echo found; $nonce data-attr uses esc_attr(),
$ajax_url uses esc_url(), inline <script> has existing phpcs:ignore.
No behavior change; password input still renders empty.

CHANGELOG [0.2.3]: one-line note — output-escaping hardening in key manager renderer.

Agent-Session: geomonitor / 2026-06-15
2026-06-15 03:41:09 +00:00
peptiderepo
54e3687acc Merge origin/main into claude/pr-vision-api-key-ui — CHANGELOG only (v0.2.3 + v0.2.2) 2026-06-15 03:31:22 +00:00
peptiderepo
67499506ba PR Vision v0.2.3: UI-editable provider API key, encrypted + write-only
Add PRV_Key_Store (resolver), PRV_Crypto_Helper (libsodium/OpenSSL),
PRV_Key_Manager_Renderer (write-only Settings card), PRV_Key_Test_Ajax.

Key resolver: PRV_OPENROUTER_API_KEY constant > prv_provider_key_enc
option > none. All probe + test paths read key through PRV_Key_Store::get_key().

Security:
- Ciphertext only in prv_provider_key_enc (hex nonce:hex ciphertext).
- Enc key from SHA-256(AUTH_KEY+SECURE_AUTH_KEY) — never stored.
- Plaintext never in options, logs, exceptions, page source, REST, or JS.
- manage_options + nonce on set/remove/test. wp-config precedence enforced.
- write-only UI: password input always renders empty; status text only.
- uninstall.php prv_ wildcard DELETE covers prv_provider_key_enc.

Files: 4 new (all ≤300 lines), 14 modified. Tests: test-key-store.php
(encrypt/decrypt round-trip, wrong-key fails silently, resolver precedence,
write-only render, Remove, constant wins). ARCHITECTURE/CONVENTIONS/CONTEXT/
CHANGELOG all updated in this commit.

Agent-Session: 2026-06-15-pr-vision-v0.2.3-api-key-ui
2026-06-15 03:08:54 +00:00
peptiderepo
648b72efad PR Vision v0.2.2: dark theme bleeds edge-to-edge in wp-admin (kill white gutters) (#5)
CSS-only: dark page bg over #wpcontent/#wpbody-content + header + proxy-note on the PR Vision screens (scoped via the pr-vision hook guard; no leak to other admin pages).

QA: APPROVE — qa-reviews/pr-vision/2026-06-14-28e3ce65761bd6dd16ae5725b6b54f2910c381db.md

Agent-Session: pr-vision v0.2.2 merge
2026-06-14 21:07:25 +07:00
Engineer (geomonitor)
28e3ce6576 v0.2.2: kill white WP admin gutters (CSS only)
- #wpcontent / #wpbody / #wpbody-content set to dark page bg (#14181C)
  on both PR Vision screens; WP default left-padding on #wpcontent removed
- .wrap>h1 text colour set to light token (#EEF2F5) so the dashboard H1
  renders on dark, not white
- .notice.prv-proxy-note overridden to dark surface with teal left border
- Scoped strictly: entire block delivered via wp_add_inline_style('wp-admin',...)
  inside PRV_Admin_Page::enqueue_assets() which guards on strpos($hook, 'pr-vision')
  — fires only on the two PR Vision screens; does not touch WP admin bar or menu
- No logic change; files <300 lines; CHANGELOG + version bump included
2026-06-14 13:59:25 +00:00
peptiderepo
43bcb8b17d PR Vision v0.2.1: dashboard config-marker + KPI health pill + auto-deploy pipeline (#4)
Wire the deferred dashboard visuals (trendline config-change marker from config_version data; KPI per-model health pill) + add .github/workflows/deploy.yml (CI-gated rsync to the prod plugin dir + LiteSpeed purge + health check on push to main) replacing manual rsync.

QA: APPROVE — qa-reviews/pr-vision/2026-06-14-f5cfa39f60db22ac9f223ca1d95ea4954b43d71f.md

Agent-Session: pr-vision v0.2.1 merge
2026-06-14 20:20:44 +07:00
peptiderepo
2fcef7a323 PR Vision v0.2.0: admin/settings interface + correctness fixes (#3)
Model manager (UI-swappable models) + versioned/idempotent prv_models migration; metric comparability (config-version stamping); run-lock; projected cost + truncation state; per-model run-health; cadence reschedule; API-key 3-state; design/a11y fixes (dark v2).

QA: APPROVE-WITH-P2-NOTES — qa-reviews/pr-vision/2026-06-14-ee4371d9163f992f60e09c7e4ac6cf8cd1d3c5eb.md

Agent-Session: pr-vision admin v0.2.0 merge
2026-06-14 14:48:25 +07:00
peptiderepo
401077fe45 PR Vision v0.1.1: rename from peptide-geo-monitor + QA P2 fixes (#2)
Rename to PR Vision (PRV_/pr-vision); fix www-strip char-mask in competitor display (str_starts_with); make PHPCS a CI gate; remove dead in-run cache.

QA: APPROVE — qa-reviews/pr-vision/2026-06-13-c633fdb9e1541386a713a6de12d2ee2ae4239640.md

Agent-Session: pr-vision rename v0.1.1 merge
2026-06-13 17:17:52 +07:00
71 changed files with 6801 additions and 2082 deletions

View File

@@ -47,13 +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
continue-on-error: true
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
View 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

View File

@@ -1,51 +1,66 @@
# Architecture — Peptide GEO Monitor
# Architecture — PR Vision
**Version:** 0.1.0 | **Last updated:** 2026-06-13
**Version:** 0.2.1 | **Last updated:** 2026-06-14
---
## 1. Overview
`peptide-geo-monitor` 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.
---
## 2. File tree
```
peptide-geo-monitor/
├── peptide-geo-monitor.php # Plugin boot: constants, autoloader, hooks
├── uninstall.php # Full data teardown: DROP TABLE + DELETE pgm_* options
├── composer.json # Dev deps (PHPCS only)
├── phpcs.xml.dist # PHPCS config (WordPress standard)
pr-vision/
├── pr-vision.php # Plugin boot: constants, autoloader, hooks
├── 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-pgm-autoloader.php # SPL autoloader: PGM_* → includes/**
│ │ ├── class-pgm-plugin.php # Orchestrator: boots sub-systems
│ │ ├── class-pgm-activator.php # Activation: table + defaults + cron
│ │ ├── class-pgm-deactivator.php # Deactivation: clear cron
│ │ ├── class-pgm-table-manager.php # dbDelta create/drop + get_table_name()
│ │ ├── class-pgm-config.php # Typed getters + seed_defaults()
│ │ ├── class-pgm-cron.php # Weekly WP-Cron schedule/clear
│ │ ├── class-pgm-cost-ledger.php # MTD cost + hard monthly cap enforcement
│ │ ├── class-pgm-probe-result.php # Immutable value object from a probe call
│ │ ├── class-pgm-probe-runner.php # Orchestrates peptide×intent×model run
│ │ ├── class-pgm-collector-registry.php # Singleton registry for collectors + panels
│ │ └── class-pgm-admin-page.php # Admin page: menu, Run now, rendering
│ │ ├── 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 + 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 run: lock + peptide*intent*model loop
│ │ ├── class-prv-collector-registry.php # Singleton registry for collectors + panels
│ │ ├── 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
│ │ ├── class-prv-key-store.php # [v0.2.3] API key resolver: constant → enc-option → none
│ │ ├── class-prv-crypto-helper.php # [v0.2.3] libsodium/OpenSSL encrypt+decrypt
│ │ ├── class-prv-key-manager-renderer.php # [v0.2.3] Write-only key card renderer
│ │ └── class-prv-key-test-ajax.php # [v0.2.3] AJAX handler for Test key button
│ ├── providers/
│ │ ├── interface-pgm-probe-provider.php # probe(query): PGM_Probe_Result contract
│ │ ├── class-pgm-gateway-client.php # Cloudflare AI Gateway HTTP + retry
│ │ ├── class-pgm-citation-detector.php # Domain extraction + cite detection
│ │ ├── class-pgm-perplexity-provider.php # Perplexity sonar via OpenRouter/gateway
│ │ └── class-pgm-openrouter-provider.php # Generic OpenRouter (GPT-search, Gemini)
│ │ ├── interface-prv-probe-provider.php # probe(query): PRV_Probe_Result contract
│ │ ├── class-prv-gateway-client.php # Cloudflare AI Gateway HTTP + retry
│ │ ├── class-prv-citation-detector.php # Domain extraction + cite detection
│ │ ├── class-prv-perplexity-provider.php # Perplexity sonar via OpenRouter/gateway
│ │ └── class-prv-openrouter-provider.php # Generic OpenRouter (GPT-search, Gemini)
│ ├── collector/
│ │ ├── interface-pgm-data-collector.php # collect(): array seam
│ │ └── class-pgm-ai-visibility-collector.php # AI-visibility data from DB
│ │ ├── interface-prv-data-collector.php # collect(): array seam
│ │ └── class-prv-ai-visibility-collector.php # AI-visibility data from DB
│ └── panel/
│ ├── interface-pgm-dashboard-panel.php # render(data): void seam
│ └── class-pgm-ai-visibility-panel.php # Renders trendline + standings
│ ├── interface-prv-dashboard-panel.php # render(data): void seam
│ ├── 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 +69,15 @@ peptide-geo-monitor/
│ ├── 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,49 +85,52 @@ peptide-geo-monitor/
## 3. Data flow
```
WP-Cron (weekly) ──────────────────────────────────────┐
Admin "Run now" (POST + nonce) ─────────────────────────┤
↓
PGM_Probe_Runner::run()
│
┌───────────────────────────────────┤
│ for each peptide × intent × model│
│ │
│ PGM_Cost_Ledger::can_afford() │
│ └─ ABORT gracefully if at cap │
│ │
│ PGM_Probe_Provider::probe(query) │
│ └─ PGM_Gateway_Client │
│ └─ Cloudflare AI Gateway │
│ └─ OpenRouter │
│ └─ LLM │
│ │
│ PGM_Citation_Detector │
│ └─ extract domains │
│ └─ detect peptiderepo.com │
│ │
│ $wpdb→insert(pgm_ai_visibility) │
│ PGM_Cost_Ledger::update_row_cost()│
└───────────────────────────────────┘
│
Admin page load ────────────────────────────────────────┤
↓
PGM_Admin_Page::render_page()
│
PGM_Collector_Registry
│
PGM_Ai_Visibility_Collector::collect()
│ (DB reads)
PGM_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)
```
---
## 4. Database
**Table:** `{prefix}pgm_ai_visibility`
**Table:** `{prefix}prv_ai_visibility`
| Column | Type | Notes |
|--------|------|-------|
@@ -122,58 +146,74 @@ 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 `pgm_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_provider_key_enc | string | [v0.2.3] Encrypted API key ciphertext (hex(nonce):hex(ciphertext)); PRV_Key_Store |
| 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/{PGM_CF_ACCOUNT_ID}/{PGM_CF_GATEWAY_ID}/openrouter
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 |
|----------|-------|-----------------|
| PGM_Perplexity_Provider | `perplexity/sonar` | `citations[]` array (primary, real-web retrieval) |
| PGM_OpenRouter_Provider | `openai/gpt-4o-search-preview` | annotations or inline URL regex |
| PGM_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
```
PGM_Data_Collector (interface) PGM_Dashboard_Panel (interface)
│ │
PGM_Ai_Visibility_Collector PGM_Ai_Visibility_Panel
│ │
└──────── PGM_Collector_Registry ──────────┘
(key: "ai_visibility")
```
To add a future SEO collector (keyword rankings, schema coverage…):
1. Implement `PGM_Data_Collector` + `PGM_Dashboard_Panel`.
2. Register both in `PGM_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.
- **API key resolver (v0.2.3):** `PRV_Key_Store::get_key()` is the single access point for
the API key. Precedence: `PRV_OPENROUTER_API_KEY` constant → encrypted option `prv_provider_key_enc` → none.
All probe + test paths route through this resolver; direct constant reads are removed.
- **Encrypted at rest (v0.2.3):** admin-entered key stored as ciphertext via `PRV_Crypto_Helper`
(libsodium preferred, OpenSSL AES-256-GCM fallback). Encryption key derived from WP salts via SHA-256.
Plaintext never stored in any option, transient, log, or error message.
- **Write-only UI (v0.2.3):** Settings shows source status only; password input always renders empty.
Key value never in page source, JS, REST responses, or AJAX responses.
- **wp-config precedence:** constant takes priority; admin option is only used when constant is absent.
Input is disabled with a note when constant is defined.
- Secrets read from `wp-config.php` constants at runtime (pre-v0.2.3 primary path).
- 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.
- AJAX `prv_test_key`: capability + nonce + 30s rate-limit transient; key never in response.
---
@@ -181,8 +221,13 @@ 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). |
| wp-config constant beats admin option | Most-secure path (constant) stays the default and is untouched by the admin UI. Admin option is a convenience fallback, never a replacement. |
| libsodium preferred, OpenSSL fallback | Sodium is bundled in PHP 7.2+ (available on all supported hosts); OpenSSL AES-256-GCM is the universal fallback. Algorithm inferred from nonce length at decrypt time, no stored metadata needed. |
| Key resolver in PRV_Key_Store, not in providers | Single source of truth — any future provider added to the plugin automatically gets the right key without repeating the precedence logic. |
| PRV_Crypto_Helper split from PRV_Key_Store | Keeps each file under the 300-line AI-readability limit while preserving a deep-module interface: Key_Store owns the WP-layer (options, salts, source detection); Crypto_Helper owns the crypto primitives. |

View File

@@ -1,4 +1,4 @@
# Changelog — Peptide GEO Monitor
# Changelog — PR Vision
All notable changes to this project will be documented in this file.
@@ -7,29 +7,195 @@ Versioning: [Semantic Versioning](https://semver.org/)
---
## [0.2.3] — 2026-06-15
UI-editable provider API key: encrypted at rest, write-only, wp-config precedence.
Output-escaping hardening in key manager renderer (`esc_url`/`esc_attr`/literal-conditional on all echoed values).
### Added
- **`PRV_Key_Store`** (`includes/core/class-prv-key-store.php`): single key resolver with
precedence chain — `PRV_OPENROUTER_API_KEY` constant → encrypted admin option `prv_provider_key_enc` → none.
`get_key()` is the sole key-access point for all probe and test paths.
- **`PRV_Crypto_Helper`** (`includes/core/class-prv-crypto-helper.php`): low-level
symmetric encryption/decryption. Prefers libsodium `sodium_crypto_secretbox`
(XSalsa20-Poly1305); falls back to OpenSSL AES-256-GCM. Encryption key is derived
from WP salts (`AUTH_KEY` + `SECURE_AUTH_KEY`) via SHA-256 — environment-specific,
never stored. Storage format: `hex(nonce):hex(ciphertext)`.
- **`PRV_Key_Manager_Renderer`** (`includes/core/class-prv-key-manager-renderer.php`):
renders the write-only "Provider API Key" card in Settings. Shows source status only
("Set via wp-config (takes precedence)" / "Set via admin" / "Not set") plus last-run
health. Password input always renders empty. Constant defined → input disabled with note.
- **`PRV_Key_Test_Ajax`** (`includes/core/class-prv-key-test-ajax.php`): AJAX handler for
the "Test key" button. Resolves key via `PRV_Key_Store::get_key()`, runs one cheap probe,
reports valid/invalid with `aria-live` result; key never in response.
- **Key set/remove POST actions** in `PRV_Settings_Controller`:
`handle_key_set()` (encrypts + stores; empty input = no-op) and `handle_key_remove()`.
- **`prv_key_set`, `prv_key_remove`, `prv_key_error`** notices in `PRV_Settings_Renderer`.
- **`wp_ajax_prv_test_key`** hook registered in `PRV_Settings_Page` → dispatches to `PRV_Key_Test_Ajax`.
- **Tests** (`tests/unit/test-key-store.php`): encrypt↔decrypt round-trip, wrong-key fails
silently, resolver precedence (constant > option > none), write-only render emits no key,
Remove clears option, constant takes precedence.
### Changed
- **`PRV_OpenRouter_Provider`**: removed direct `PRV_OPENROUTER_API_KEY` constant reads;
`probe()` now calls `PRV_Key_Store::get_key()`. New `probe_with_key(string $q, string $key)`
method for the test path (PRV_Key_Test_Ajax injects the resolved key). `is_configured()`
now checks `PRV_Key_Store::get_key() !== ''`.
- **`PRV_Perplexity_Provider`**: same key-resolver migration — `probe()` calls `PRV_Key_Store::get_key()`.
`is_configured()` checks the store. Direct constant reads removed.
- **`PRV_Model_Test_Ajax`**: resolves key via `PRV_Key_Store::get_key()` instead of checking
the constant directly. Returns a specific "key not configured — set via Settings" message
when no key is available.
- **`PRV_Settings_Renderer`**: replaced read-only `render_api_key_status()` with a call to
`PRV_Key_Manager_Renderer::render()`. Added key-action notices. Provider API Key card is
now separate from the Probe Configuration form.
- **`PRV_Settings_Page`**: added `NONCE_KEY` + `NONCE_KEY_TEST` constants; registered
`admin_post_prv_key_set`, `admin_post_prv_key_remove`, `wp_ajax_prv_test_key`.
- **`uninstall.php`**: updated comment to explicitly list `prv_provider_key_enc` as a v0.2.3
addition purged by the `prv_` wildcard DELETE.
- **`tests/unit/test-uninstall.php`**: seeds + asserts `prv_provider_key_enc` purge.
### Security
- Plaintext API key never stored in any option, transient, log, or echoed to the browser.
- The encrypted option (`prv_provider_key_enc`) is ciphertext only; decryption is server-side
at probe time only. The encryption key is derived from WP salts — not stored.
- `manage_options` + nonce required for all key actions (set, remove, test).
- `wp-config` constant always takes precedence; admin path is only active when absent.
---
## [0.2.2] — 2026-06-14
Kill white WP admin gutters around the dark "Assay" UI — CSS only, no logic change.
### Fixed
- **Admin chrome (dashboard + settings):** `#wpcontent`, `#wpbody`, and `#wpbody-content` now receive the dark page background (`#14181C`) on both PR Vision screens. WP's default `20px` left padding on `#wpcontent` is removed so no light gutter appears between the left menu and the dark content area.
- **WP admin bar / first-element gap:** `#wpbody` dark fill removes the white strip between the admin bar and the first card.
- **Page `<h1>` header:** `.wrap > h1` colour set to the light text token (`#EEF2F5`) so the dashboard heading renders on dark, not white.
- **Proxy-note banner (dashboard):** `.notice.prv-proxy-note` overridden to dark surface (`#1C2228`) with teal left border — banner now sits on dark bg matching the surrounding cards.
- **Scope:** entire CSS block delivered via `wp_add_inline_style('wp-admin', …)` inside `PRV_Admin_Page::enqueue_assets()`, which is already guarded by `strpos($hook, 'pr-vision')`. Styles are absent on every other WP admin page. WP admin bar and left menu are not touched.
---
## [0.1.1] — 2026-06-13
Rename to PR Vision; fix www-strip in competitor display; PHPCS gating; remove dead cache.
### Changed
- **Rename:** plugin, repo, slug, class prefix, option/hook/table prefix all updated from `pgm_`/`PGM_` to `prv_`/`PRV_` and from `peptide-geo-monitor`/`GEO Monitor` to `pr-vision`/`PR Vision`. No data migration required (no `pgm_*` data existed).
### Fixed
- **P2-A:** `ltrim($host, 'www.')` character-mask bug in `PRV_Citation_Detector::parse_domains()` replaced with `str_starts_with($host, 'www.') ? substr($host, 4) : $host`. Domains starting with `w` (e.g. `wikipedia.org`, `webmd.com`) are no longer corrupted in competitor standings.
- **P2-C:** Removed dead in-run cache from `PRV_Probe_Runner::run()`. The `peptides × intents × models` loop visits each `(slug, intent, model)` triplet exactly once, so the cache could never produce a hit on a single traversal.
### CI
- **P2-B:** Dropped `continue-on-error: true` from the PHPCS step — WordPress Coding Standards now gates the build. PHPCS was already green on v0.1.0; this enforces the DoD for future commits.
### Tests
- Added four assertions to `test-citation-detector.php` covering `wikipedia.org`, `webmd.com` (non-www domains starting with `w`) and `www.examine.com` to lock in the P2-A fix.
---
## [0.1.0] — 2026-06-13
**Initial release — AI-visibility dashboard.**
### Added
- `PGM_Probe_Provider` interface + `PGM_Probe_Result` value object.
- `PGM_Perplexity_Provider`: Perplexity `sonar` via OpenRouter/Cloudflare AI Gateway; parses `citations[]` array.
- `PGM_OpenRouter_Provider`: generic parameterised provider for GPT-search and Gemini-search models; parses annotations + inline URL regex fallback.
- `PGM_Gateway_Client`: shared CF AI Gateway HTTP client with exponential-backoff retry and cURL auth injection (mirrors PRAutoBlogger pattern).
- `PGM_Citation_Detector`: domain extraction from citation arrays/URLs, `is_cited()`, `get_our_position()`.
- `PGM_Cost_Ledger`: month-to-date spend from DB, `can_afford()` pre-check, `update_row_cost()` settlement.
- `PGM_Probe_Runner`: full peptides × intents × models run with budget cap abort, in-run cache, and partial-run persistence.
- `PGM_Table_Manager`: `{prefix}pgm_ai_visibility` via `dbDelta`, schema-version option, `drop_table()` for uninstall.
- `PGM_Config`: typed getters + `seed_defaults()` with ~12 peptides, 3 intents, 3 models.
- `PGM_Cron`: weekly `pgm_weekly_probe` WP-Cron; `schedule_weekly()` / `clear_schedule()`.
- `PGM_Data_Collector` + `PGM_Dashboard_Panel` interfaces (collector/panel seam).
- `PGM_Collector_Registry`: singleton registry for future extensibility.
- `PGM_Ai_Visibility_Collector`: trendline (per-run visibility score), standings (per-peptide latest), MTD cost.
- `PGM_Ai_Visibility_Panel`: proxy-metric note, Chart.js trendline, per-peptide standings table, MTD cost bar, "Run now" button.
- `PGM_Admin_Page`: "GEO Monitor" top-level menu, Chart.js enqueue, Run now POST handler (nonce + `manage_options`).
- `uninstall.php`: DROP TABLE + DELETE `pgm_*` options + `wp_clear_scheduled_hook`.
- `PRV_Probe_Provider` interface + `PRV_Probe_Result` value object.
- `PRV_Perplexity_Provider`: Perplexity `sonar` via OpenRouter/Cloudflare AI Gateway; parses `citations[]` array.
- `PRV_OpenRouter_Provider`: generic parameterised provider for GPT-search and Gemini-search models; parses annotations + inline URL regex fallback.
- `PRV_Gateway_Client`: shared CF AI Gateway HTTP client with exponential-backoff retry and cURL auth injection (mirrors PRAutoBlogger pattern).
- `PRV_Citation_Detector`: domain extraction from citation arrays/URLs, `is_cited()`, `get_our_position()`.
- `PRV_Cost_Ledger`: month-to-date spend from DB, `can_afford()` pre-check, `update_row_cost()` settlement.
- `PRV_Probe_Runner`: full peptides × intents × models run with budget cap abort and partial-run persistence.
- `PRV_Table_Manager`: `{prefix}prv_ai_visibility` via `dbDelta`, schema-version option, `drop_table()` for uninstall.
- `PRV_Config`: typed getters + `seed_defaults()` with ~12 peptides, 3 intents, 3 models.
- `PRV_Cron`: weekly `prv_weekly_probe` WP-Cron; `schedule_weekly()` / `clear_schedule()`.
- `PRV_Data_Collector` + `PRV_Dashboard_Panel` interfaces (collector/panel seam).
- `PRV_Collector_Registry`: singleton registry for future extensibility.
- `PRV_Ai_Visibility_Collector`: trendline (per-run visibility score), standings (per-peptide latest), MTD cost.
- `PRV_Ai_Visibility_Panel`: proxy-metric note, Chart.js trendline, per-peptide standings table, MTD cost bar, "Run now" button.
- `PRV_Admin_Page`: "PR Vision" top-level menu, Chart.js enqueue, Run now POST handler (nonce + `manage_options`).
- `uninstall.php`: DROP TABLE + DELETE `prv_*` options + `wp_clear_scheduled_hook`.
- Unit tests: citation detection, score formula, cost ledger + budget cap abort, provider response parsing (mock HTTP), cron registration, uninstall purge.
- `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**.

View File

@@ -1,8 +1,8 @@
# Context — Domain Glossary (Peptide GEO Monitor)
# Context — Domain Glossary (PR Vision)
**Version:** 0.1.0 | **Last updated:** 2026-06-13
**Version:** 0.2.1 | **Last updated:** 2026-06-14
This file is the ubiquitous-language glossary for `peptide-geo-monitor`. Every term below has a precise meaning within this codebase. AI agents and human contributors should use this terminology consistently.
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.
---
@@ -11,42 +11,42 @@ This file is the ubiquitous-language glossary for `peptide-geo-monitor`. Every t
### AI Visibility
Whether and where peptiderepo.com appears in AI assistant responses to peptide-related queries. The core metric this plugin measures.
**Code identifiers:** `PGM_Ai_Visibility_Collector`, `PGM_Ai_Visibility_Panel`, table column `cited`, option prefix `pgm_`.
**Code identifiers:** `PRV_Ai_Visibility_Collector`, `PRV_Ai_Visibility_Panel`, table column `cited`, option prefix `prv_`.
### Probe
A single server-side LLM API call with a specific `(peptide, intent, model)` combination. The result is a `PGM_Probe_Result` value object.
A single server-side LLM API call with a specific `(peptide, intent, model)` combination. The result is a `PRV_Probe_Result` value object.
**Code identifiers:** `PGM_Probe_Provider::probe()`, `PGM_Probe_Runner::run()`.
**Code identifiers:** `PRV_Probe_Provider::probe()`, `PRV_Probe_Runner::run()`.
### Probe Run
A batch execution of all configured `peptides × prompt_intents × models`. Identified by a `run_id` (UUID). One cron tick = one run; the "Run now" button also triggers a run.
**Code identifiers:** `PGM_Probe_Runner::run()`, `run_id` column in `pgm_ai_visibility`.
**Code identifiers:** `PRV_Probe_Runner::run()`, `run_id` column in `prv_ai_visibility`.
### Peptide
A compound being tracked (e.g. BPC-157, TB-500). Stored as a slug + label pair in the `pgm_peptides` option.
A compound being tracked (e.g. BPC-157, TB-500). Stored as a slug + label pair in the `prv_peptides` option.
**Code identifiers:** `pgm_peptides` option, `peptide_slug` + `peptide_label` columns.
**Code identifiers:** `prv_peptides` option, `peptide_slug` + `peptide_label` columns.
### Prompt Intent
A query template describing a searcher's intent (e.g. "what is {peptide}", "{peptide} reconstitution guide"). The `{peptide}` placeholder is replaced with the peptide's label at runtime.
**Code identifiers:** `pgm_prompt_intents` option, `prompt_intent` column.
**Code identifiers:** `prv_prompt_intents` option, `prompt_intent` column.
### Citation / Cited
A probe is **cited** when `peptiderepo.com` appears in the LLM's source/citation list for that response. The `cited` column is `TINYINT(1)` (1 = cited, 0 = not cited).
**Code identifiers:** `PGM_Citation_Detector::is_cited()`, `cited` column, `PGM_Probe_Result::is_cited()`.
**Code identifiers:** `PRV_Citation_Detector::is_cited()`, `cited` column, `PRV_Probe_Result::is_cited()`.
### Source Domains
The list of domains returned by the LLM as its sources for a given response (e.g. `["peptiderepo.com", "examine.com"]`). Stored as JSON in `source_domains`.
**Code identifiers:** `PGM_Citation_Detector::parse_domains()`, `PGM_Probe_Result::get_source_domains()`, `source_domains` column.
**Code identifiers:** `PRV_Citation_Detector::parse_domains()`, `PRV_Probe_Result::get_source_domains()`, `source_domains` column.
### Our Position
The 1-based rank of `peptiderepo.com` in the source domains list for a cited probe (`NULL` when not cited). Position 1 = we appear first.
**Code identifiers:** `PGM_Citation_Detector::get_our_position()`, `our_position` column, `PGM_Probe_Result::get_our_position()`.
**Code identifiers:** `PRV_Citation_Detector::get_our_position()`, `our_position` column, `PRV_Probe_Result::get_our_position()`.
### Visibility Score
A composite metric summarising peptiderepo.com's AI visibility for a run.
@@ -60,24 +60,59 @@ visibility_score = round((base_score + position_bonus) / 2, 4)
- **Range:** [0.0, 1.0]. Score 1.0 requires 100% citation rate at position 1 for all probes.
- **Why position-weighted?** Being cited at position 1 is more valuable than position 10. The 1/position term gives a graduated bonus: position 1 = 1.0, position 2 = 0.5, position 5 = 0.2, etc.
- **Stored:** Computed from aggregate DB rows per run in `PGM_Ai_Visibility_Collector::compute_score()`, not persisted as a column (recomputed on page load).
- **Stored:** Computed from aggregate DB rows per run in `PRV_Ai_Visibility_Collector::compute_score()`, not persisted as a column (recomputed on page load).
**Code identifiers:** `PGM_Ai_Visibility_Collector::compute_score()`, trendline data in `collect()`.
**Code identifiers:** `PRV_Ai_Visibility_Collector::compute_score()`, trendline data in `collect()`.
### Monthly Budget Cap
The maximum USD spend on LLM API calls in a calendar month. Stored in `pgm_monthly_budget_usd` (default `$5.00`). The runner checks `PGM_Cost_Ledger::can_afford()` **before** each probe and stops gracefully when the cap is reached.
The maximum USD spend on LLM API calls in a calendar month. Stored in `prv_monthly_budget_usd` (default `$5.00`). The runner checks `PRV_Cost_Ledger::can_afford()` **before** each probe and stops gracefully when the cap is reached.
**Code identifiers:** `PGM_Cost_Ledger`, `pgm_monthly_budget_usd` option, `PGM_DEFAULT_MONTHLY_BUDGET_USD` constant.
**Code identifiers:** `PRV_Cost_Ledger`, `prv_monthly_budget_usd` option, `PRV_DEFAULT_MONTHLY_BUDGET_USD` constant.
### Provider
An implementation of `PGM_Probe_Provider` that wraps one LLM backend. v1 ships `PGM_Perplexity_Provider` (sonar) and `PGM_OpenRouter_Provider` (parameterised).
An implementation of `PRV_Probe_Provider` that wraps one LLM backend. v1 ships `PRV_Perplexity_Provider` (sonar) and `PRV_OpenRouter_Provider` (parameterised).
**Code identifiers:** `interface-pgm-probe-provider.php`, `PGM_Perplexity_Provider`, `PGM_OpenRouter_Provider`.
**Code identifiers:** `interface-prv-probe-provider.php`, `PRV_Perplexity_Provider`, `PRV_OpenRouter_Provider`.
### Collector / Panel Seam
The extensibility mechanism: `PGM_Data_Collector` produces data; `PGM_Dashboard_Panel` renders it. Both are registered with `PGM_Collector_Registry`. v1 ships only the AI-visibility pair; future SEO categories add new pairs without touching the shell.
The extensibility mechanism: `PRV_Data_Collector` produces data; `PRV_Dashboard_Panel` renders it. Both are registered with `PRV_Collector_Registry`. v1 ships only the AI-visibility pair; future SEO categories add new pairs without touching the shell.
**Code identifiers:** `interface-pgm-data-collector.php`, `interface-pgm-dashboard-panel.php`, `PGM_Collector_Registry`.
**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.
@@ -85,9 +120,24 @@ Generative Engine Optimization — the practice of improving a brand's visibilit
### Proxy Metric
The plugin's scores are API-probe measurements, not real-world consumer LLM usage. The admin page labels these explicitly as "a directional proxy, not the consumer ChatGPT/Gemini apps."
**Code identifiers:** `PGM_Ai_Visibility_Panel::render_proxy_note()`.
**Code identifiers:** `PRV_Ai_Visibility_Panel::render_proxy_note()`.
### MTD Cost
Month-to-date spend in USD, summed from the `cost_usd` column for rows in the current calendar month.
**Code identifiers:** `PGM_Cost_Ledger::get_month_to_date_usd()`.
**Code identifiers:** `PRV_Cost_Ledger::get_month_to_date_usd()`.
### Key Store (v0.2.3)
The subsystem that manages the OpenRouter API key with a precedence chain and encrypted-at-rest storage. Precedence: `PRV_OPENROUTER_API_KEY` wp-config constant (highest) → encrypted admin option `prv_provider_key_enc` → none. All key reads by providers and AJAX handlers route through `PRV_Key_Store::get_key()`. The plaintext key is never stored in any option, log, or client-side surface.
**Code identifiers:** `PRV_Key_Store`, `PRV_Key_Store::get_key()`, `PRV_Key_Store::store_key()`, `PRV_Key_Store::clear_key()`, `PRV_Key_Store::get_source()`, `prv_provider_key_enc` option.
### Write-only Key UI (v0.2.3)
The Settings page section that allows the operator to set/replace/clear the API key without the key ever being rendered in the browser. The password input always renders empty. The UI shows only the key *source* (wp-config / admin / not set) and the last-run health state. The key value is never output to page HTML, JS, REST responses, or logs.
**Code identifiers:** `PRV_Key_Manager_Renderer`, `PRV_Key_Test_Ajax`, `PRV_Settings_Controller::handle_key_set()`, `PRV_Settings_Controller::handle_key_remove()`.
### Encryption Key (v0.2.3)
The symmetric key used to encrypt/decrypt `prv_provider_key_enc`. Derived via SHA-256 from `AUTH_KEY + SECURE_AUTH_KEY` (or `wp_salt()` fallback) — environment-specific and never stored. Derived fresh at every encrypt/decrypt call.
**Code identifiers:** `PRV_Key_Store::derive_encryption_key()`, `PRV_Crypto_Helper::encrypt()`, `PRV_Crypto_Helper::decrypt()`.

View File

@@ -1,6 +1,6 @@
# Conventions — Peptide GEO Monitor
# Conventions — PR Vision
**Version:** 0.1.0 | **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.
@@ -16,20 +16,20 @@ Applies to all contributors and AI agents working in this repo. Where this docum
| Concept | Convention | Example |
|---------|-----------|---------|
| Class | `PGM_` prefix + PascalCase | `PGM_Cost_Ledger` |
| Interface | `PGM_` prefix + PascalCase | `PGM_Probe_Provider` |
| File | `class-pgm-*.php` or `interface-pgm-*.php` | `class-pgm-probe-runner.php` |
| Option | `pgm_` prefix | `pgm_monthly_budget_usd` |
| Hook / cron | `pgm_` prefix | `pgm_weekly_probe` |
| Constant | `PGM_` prefix | `PGM_TARGET_DOMAIN` |
| wp-config key | `PGM_` prefix | `PGM_OPENROUTER_API_KEY` |
| Class | `PRV_` prefix + PascalCase | `PRV_Cost_Ledger` |
| Interface | `PRV_` prefix + PascalCase | `PRV_Probe_Provider` |
| File | `class-prv-*.php` or `interface-prv-*.php` | `class-prv-probe-runner.php` |
| Option | `prv_` prefix | `prv_monthly_budget_usd` |
| Hook / cron | `prv_` prefix | `prv_weekly_probe` |
| Constant | `PRV_` prefix | `PRV_TARGET_DOMAIN` |
| wp-config key | `PRV_` prefix | `PRV_OPENROUTER_API_KEY` |
| Test file | `test-*.php` | `test-citation-detector.php` |
## 3. File structure
- **One class per file.** Interfaces get their own file.
- **300-line limit** (enforced by CI). Split at the method boundary when approaching the limit.
- Autoloader resolves `PGM_Foo_Bar` → `class-pgm-foo-bar.php` via a search across `includes/` subdirectories.
- Autoloader resolves `PRV_Foo_Bar` → `class-prv-foo-bar.php` via a search across `includes/` subdirectories.
## 4. Docblocks
@@ -43,7 +43,7 @@ Every class must have a preamble docblock:
*
* @see <cross-reference 1>
* @see <cross-reference 2>
* @package PeptideGeoMonitor
* @package PrVision
*/
```
@@ -63,22 +63,32 @@ Every public method must have:
## 6. Tests
- Tests live in `tests/unit/test-*.php` and are run by the CI `lint-php` job via `php $test`.
- Each test file `require_once`s `tests/bootstrap.php` and calls `exit( pgm_test_summary() )`.
- Use `pgm_assert()`, `pgm_assert_equals()`, `pgm_assert_throws()`.
- Mock HTTP via `$GLOBALS['pgm_test_state']['remote_posts']` (queued responses).
- Each test file `require_once`s `tests/bootstrap.php` and calls `exit( prv_test_summary() )`.
- Use `prv_assert()`, `prv_assert_equals()`, `prv_assert_throws()`.
- Mock HTTP via `$GLOBALS['prv_test_state']['remote_posts']` (queued responses).
- No PHPUnit dependency.
## 7. Adding a new provider
1. Create `includes/providers/class-pgm-{name}-provider.php`.
2. Implement `PGM_Probe_Provider` (`probe()`, `get_name()`, `is_configured()`).
3. Add a branch in `PGM_Probe_Runner::resolve_provider()`.
1. Create `includes/providers/class-prv-{name}-provider.php`.
2. Implement `PRV_Probe_Provider` (`probe()`, `get_name()`, `is_configured()`).
3. Add a branch in `PRV_Probe_Runner::resolve_provider()`.
4. Update `ARCHITECTURE.md` §5 (Providers table).
5. Add a parse test in `tests/unit/test-provider-parsing.php`.
## 8. Adding a new collector/panel pair
1. Implement `PGM_Data_Collector` in `includes/collector/`.
2. Implement `PGM_Dashboard_Panel` in `includes/panel/`.
3. Register both in `PGM_Plugin::init()` via `PGM_Collector_Registry::instance()`.
1. Implement `PRV_Data_Collector` in `includes/collector/`.
2. Implement `PRV_Dashboard_Panel` in `includes/panel/`.
3. Register both in `PRV_Plugin::init()` via `PRV_Collector_Registry::instance()`.
4. Update `ARCHITECTURE.md` §6 (Collector/Panel seam).
## 9. API key access (v0.2.3)
**Never read `PRV_OPENROUTER_API_KEY` directly in provider or handler code.**
All key reads must go through `PRV_Key_Store::get_key()`. This maintains the precedence chain (constant → encrypted option → none) and ensures a key set via the admin UI is used without code changes.
- Providers: call `PRV_Key_Store::get_key()` in `probe()`.
- AJAX handlers: call `PRV_Key_Store::get_key()` before any LLM call.
- New provider that needs the key: implement `is_configured()` as `'' !== PRV_Key_Store::get_key()`.
- Do NOT pass the resolved key to any client-side surface, log, exception message, or `error_log()`.

View File

@@ -1,4 +1,4 @@
# Peptide GEO Monitor
# PR Vision
An internal WordPress plugin that runs weekly server-side LLM probes to measure whether peptiderepo.com is cited by AI assistants across core peptides.
@@ -18,11 +18,11 @@ The displayed metric is a **directional proxy** — API probes do not replicate
```php
// OpenRouter API key (sk-or-…) — required for all providers.
define( 'PGM_OPENROUTER_API_KEY', 'sk-or-YOUR_KEY_HERE' );
define( 'PRV_OPENROUTER_API_KEY', 'sk-or-YOUR_KEY_HERE' );
// Cloudflare AI Gateway routing (optional — falls back to direct OpenRouter).
define( 'PGM_CF_ACCOUNT_ID', 'YOUR_CF_ACCOUNT_ID' );
define( 'PGM_CF_GATEWAY_ID', 'YOUR_GATEWAY_NAME' );
define( 'PRV_CF_ACCOUNT_ID', 'YOUR_CF_ACCOUNT_ID' );
define( 'PRV_CF_GATEWAY_ID', 'YOUR_GATEWAY_NAME' );
```
**Never commit these values. They are read at runtime from wp-config.php only.**
@@ -37,7 +37,7 @@ See `ARCHITECTURE.md` for the full file tree, data flow, and design decisions.
## Admin page
Navigate to **GEO Monitor** in the WordPress admin sidebar (`manage_options` required).
Navigate to **PR Vision** in the WordPress admin sidebar (`manage_options` required).
The page shows:
- A proxy-metric disclaimer note.
@@ -50,26 +50,26 @@ The page shows:
## Scheduling
A weekly WP-Cron event (`pgm_weekly_probe`) is registered on plugin activation and cleared on deactivation. Probe runs (cron or manual) respect the hard monthly budget cap.
A weekly WP-Cron event (`prv_weekly_probe`) is registered on plugin activation and cleared on deactivation. Probe runs (cron or manual) respect the hard monthly budget cap.
---
## Cost cap
Default cap: **$5.00 USD/month** (configurable via the `pgm_monthly_budget_usd` option). The runner checks the month-to-date spend **before** each API call and stops gracefully when the cap is reached — partial runs are recorded, never over-spend.
Default cap: **$5.00 USD/month** (configurable via the `prv_monthly_budget_usd` option). The runner checks the month-to-date spend **before** each API call and stops gracefully when the cap is reached — partial runs are recorded, never over-spend.
---
## Uninstall
Uninstalling the plugin drops the `{prefix}pgm_ai_visibility` table and deletes all `pgm_*` options. Deactivation only clears the cron schedule.
Uninstalling the plugin drops the `{prefix}prv_ai_visibility` table and deletes all `prv_*` options. Deactivation only clears the cron schedule.
---
## Developer notes
- PHP 8.1+ required. No runtime Composer dependencies.
- All classes prefixed `PGM_`. All options/hooks prefixed `pgm_`.
- All classes prefixed `PRV_`. All options/hooks prefixed `prv_`.
- Files are kept under 300 lines; one class per file.
- See `CONVENTIONS.md` for naming patterns and extension guides.
- See `CONTEXT.md` for the domain glossary including the visibility score formula.

View File

@@ -1,6 +1,6 @@
{
"name": "peptiderepo/peptide-geo-monitor",
"description": "AI-visibility (GEO) monitoring dashboard for peptiderepo.com — weekly LLM probes, cost ledger, trendline admin panel.",
"name": "peptiderepo/pr-vision",
"description": "AI-visibility (GEO) monitoring dashboard for peptiderepo.com \u2014 weekly LLM probes, cost ledger, trendline admin panel.",
"type": "wordpress-plugin",
"license": "GPL-2.0-or-later",
"require": {
@@ -18,7 +18,7 @@
}
},
"scripts": {
"lint": "phpcs",
"lint": "phpcs -n",
"lint:fix": "phpcbf"
}
}

View File

@@ -1,90 +1,108 @@
<?php
/**
* AI-visibility data collector for the PR Vision dashboard.
*
* @package PrVision
*/
declare(strict_types=1);
/**
* AI-visibility data collector — v1 implementation of PGM_Data_Collector.
* AI-visibility data collector — v1 implementation of PRV_Data_Collector.
*
* Reads the pgm_ai_visibility table and returns structured data for:
* - Trendline: per-run visibility score (% cited, position-weighted).
* Reads the prv_ai_visibility table and returns structured data for:
* - 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)
* position_bonus = Σ (1 / our_position) for cited probes / total_probes
* visibility_score = round((base_score + position_bonus) / 2, 4)
*
* Who triggers: PGM_Admin_Page calls the registered collector via the registry.
* Dependencies: $wpdb, PGM_Table_Manager, PGM_Cost_Ledger, PGM_Config.
* Who triggers: PRV_Admin_Page calls the registered collector via the registry.
* Dependencies: $wpdb, PRV_Table_Manager, PRV_Cost_Ledger, PRV_Config,
* PRV_Model_Registry, PRV_Config_Version.
*
* @see interface-pgm-data-collector.php — Interface this implements.
* @see class-pgm-ai-visibility-panel.php — Renders the data returned here.
* @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 PeptideGeoMonitor
* @package PrVision
*/
class PGM_Ai_Visibility_Collector implements PGM_Data_Collector {
class PRV_Ai_Visibility_Collector implements PRV_Data_Collector {
/**
* @var PGM_Cost_Ledger
* Budget ledger for MTD cost lookup.
*
* @var PRV_Cost_Ledger
*/
private PGM_Cost_Ledger $ledger;
private PRV_Cost_Ledger $ledger;
/**
* @param PGM_Cost_Ledger|null $ledger Injected for testing.
* Constructor.
*
* @param PRV_Cost_Ledger|null $ledger Injected for testing.
*/
public function __construct( ?PGM_Cost_Ledger $ledger = null ) {
$this->ledger = $ledger ?? new PGM_Cost_Ledger();
public function __construct( ?PRV_Cost_Ledger $ledger = null ) {
$this->ledger = $ledger ?? new PRV_Cost_Ledger();
}
/**
* {@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 {
global $wpdb;
$table = PGM_Table_Manager::get_table_name();
$table = PRV_Table_Manager::get_table_name();
// ── Trendline: one score per run_id ────────────────────────────
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
// ── 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(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"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
LIMIT 52",
ARRAY_A
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$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,
);
}
// ── Standings: latest result per peptide ────────────────────────
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$standing_rows = $wpdb->get_results(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"SELECT peptide_slug, peptide_label,
MAX(cited) AS cited,
MIN(CASE WHEN cited=1 THEN our_position ELSE NULL END) AS our_position,
@@ -95,6 +113,7 @@ class PGM_Ai_Visibility_Collector implements PGM_Data_Collector {
GROUP BY peptide_slug, peptide_label",
ARRAY_A
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$standings = array();
foreach ( (array) $standing_rows as $row ) {
@@ -109,18 +128,24 @@ class PGM_Ai_Visibility_Collector implements PGM_Data_Collector {
}
// ── Run metadata ────────────────────────────────────────────────
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$last_run = $wpdb->get_var(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"SELECT MAX(captured_at) FROM {$table}"
);
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$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' => PGM_Config::get_monthly_budget_usd(),
'monthly_cap_usd' => PRV_Config::get_monthly_budget_usd(),
'last_run_counts' => $last_run_counts,
'config_versions' => $config_versions,
);
}
@@ -146,11 +171,37 @@ class PGM_Ai_Visibility_Collector implements PGM_Data_Collector {
if ( 0 === $total_count ) {
return 0.0;
}
$base_score = $cited_count / $total_count;
$position_bonus = $position_sum / $total_count;
$base_score = $cited_count / $total_count;
$position_bonus = $position_sum / $total_count;
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.
*
@@ -163,7 +214,7 @@ class PGM_Ai_Visibility_Collector implements PGM_Data_Collector {
return array();
}
$all = array();
$all = array();
$chunks = explode( '|||', $domains_json_list );
foreach ( $chunks as $chunk ) {
$decoded = json_decode( $chunk, true );
@@ -175,6 +226,6 @@ class PGM_Ai_Visibility_Collector implements PGM_Data_Collector {
$counts = array_count_values( $all );
arsort( $counts );
$top = array_slice( array_keys( $counts ), 0, 5 );
return array_values( array_filter( $top, fn( $d ) => PGM_TARGET_DOMAIN !== $d ) );
return array_values( array_filter( $top, fn( $d ) => PRV_TARGET_DOMAIN !== $d ) );
}
}

View File

@@ -1,23 +1,29 @@
<?php
/**
* Contract for all PR Vision data collectors.
*
* @package PrVision
*/
declare(strict_types=1);
/**
* Seam: contract for all GEO-monitor data collectors.
* Seam: contract for all PR Vision data collectors.
*
* v1 implements only PGM_Ai_Visibility_Collector. Future collectors
* V1 implements only PRV_Ai_Visibility_Collector. Future collectors
* (keyword rankings, schema coverage, technical-SEO) implement this
* interface and register themselves with PGM_Collector_Registry without
* interface and register themselves with PRV_Collector_Registry without
* changing the dashboard shell.
*
* Who triggers: PGM_Collector_Registry dispatches registered collectors.
* Who triggers: PRV_Collector_Registry dispatches registered collectors.
* Dependencies: None — pure interface.
*
* @see class-pgm-ai-visibility-collector.php — v1 implementation.
* @see class-pgm-collector-registry.php — Registration hub.
* @see class-prv-ai-visibility-collector.php — v1 implementation.
* @see class-prv-collector-registry.php — Registration hub.
* @see ARCHITECTURE.md — §Collector/Panel seam.
* @package PeptideGeoMonitor
* @package PrVision
*/
interface PGM_Data_Collector {
interface PRV_Data_Collector {
/**
* Collect and return the data payload for this category.

View File

@@ -1,202 +0,0 @@
<?php
declare(strict_types=1);
/**
* GEO Monitor top-level admin page.
*
* Registers the "GEO Monitor" menu item (manage_options), enqueues Chart.js,
* handles the "Run now" POST action (nonce + capability), and delegates
* rendering to the registered panels via PGM_Collector_Registry.
*
* Who triggers: PGM_Plugin::init() → is_admin() guard.
* Dependencies: PGM_Collector_Registry, PGM_Probe_Runner.
*
* @see class-pgm-plugin.php — Calls register_hooks().
* @see class-pgm-collector-registry.php — Provides panels.
* @see class-pgm-probe-runner.php — Invoked by the Run now action.
* @see ARCHITECTURE.md — §Admin dashboard.
* @package PeptideGeoMonitor
*/
class PGM_Admin_Page {
/**
* Admin menu slug.
*/
const MENU_SLUG = 'pgm-geo-monitor';
/**
* Nonce action for the Run now form.
*/
const NONCE_ACTION = 'pgm_run_now';
/**
* Nonce field name.
*/
const NONCE_FIELD = 'pgm_nonce';
/**
* Register WordPress admin hooks.
*
* Side effects: Adds admin_menu and admin_post actions.
*
* @return void
*/
public function register_hooks(): void {
add_action( 'admin_menu', array( $this, 'add_menu_page' ) );
add_action( 'admin_post_pgm_run_now', array( $this, 'handle_run_now' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
}
/**
* Add the "GEO Monitor" top-level menu page.
*
* Side effects: Adds a WP admin menu entry.
*
* @return void
*/
public function add_menu_page(): void {
add_menu_page(
__( 'GEO Monitor', 'peptide-geo-monitor' ),
__( 'GEO Monitor', 'peptide-geo-monitor' ),
'manage_options',
self::MENU_SLUG,
array( $this, 'render_page' ),
'dashicons-chart-line',
58
);
}
/**
* Enqueue Chart.js on the GEO Monitor page only.
*
* @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(
'pgm-chartjs',
'https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js',
array(),
'4.4.3',
true
);
wp_add_inline_style(
'wp-admin',
'.pgm-meta-bar{display:flex;gap:20px;margin:10px 0 15px;flex-wrap:wrap;}
.pgm-meta-item{background:#f6f7f7;padding:6px 12px;border-radius:4px;font-size:13px;}
.pgm-card{background:#fff;border:1px solid #c3c4c7;padding:16px 20px;margin-bottom:20px;border-radius:4px;}
.pgm-card h2{margin-top:0;}
.pgm-proxy-note{margin:10px 0;}
.pgm-standings th,.pgm-standings td{vertical-align:middle;}
canvas#pgm-trendline-chart{max-height:300px;}'
);
}
/**
* Handle the "Run now" POST action.
*
* Validates nonce and capability before dispatching to PGM_Probe_Runner.
* Redirects back to the admin page with a status query arg.
*
* Side effects: Triggers a full probe run; redirects.
*
* @return void
*/
public function handle_run_now(): void {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'Insufficient permissions.', 'peptide-geo-monitor' ), 403 );
}
$nonce = isset( $_POST[ self::NONCE_FIELD ] ) ? sanitize_text_field( wp_unslash( $_POST[ self::NONCE_FIELD ] ) ) : '';
if ( ! wp_verify_nonce( $nonce, self::NONCE_ACTION ) ) {
wp_die( esc_html__( 'Security check failed.', 'peptide-geo-monitor' ), 403 );
}
$runner = new PGM_Probe_Runner();
$counts = $runner->run();
$redirect = add_query_arg(
array(
'page' => self::MENU_SLUG,
'pgm_run_done' => 1,
'pgm_probed' => (int) $counts['probed'],
'pgm_skipped' => (int) ( $counts['skipped_budget'] + $counts['skipped_error'] ),
),
admin_url( 'admin.php' )
);
wp_safe_redirect( $redirect );
exit;
}
/**
* Render the full GEO Monitor admin page.
*
* Side effects: Outputs HTML directly.
*
* @return void
*/
public function render_page(): void {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'Insufficient permissions.', 'peptide-geo-monitor' ) );
}
echo '<div class="wrap">';
echo '<h1>' . esc_html__( 'GEO Monitor — AI Visibility', 'peptide-geo-monitor' ) . '</h1>';
$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="pgm_run_now">';
wp_nonce_field( self::NONCE_ACTION, self::NONCE_FIELD );
submit_button( __( 'Run now', 'peptide-geo-monitor' ), 'secondary', 'pgm_run_now_btn', false );
echo '</form>';
$registry = PGM_Collector_Registry::instance();
foreach ( $registry->get_collectors() as $key => $collector ) {
$panel = $registry->get_panel( $key );
if ( null === $panel ) {
continue;
}
$data = $collector->collect();
$panel->render( $data );
}
echo '</div>';
}
/**
* Render a success or partial notice after a Run now redirect.
*
* @return void
*/
private function render_run_done_notice(): void {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( empty( $_GET['pgm_run_done'] ) ) {
return;
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$probed = (int) ( $_GET['pgm_probed'] ?? 0 );
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$skipped = (int) ( $_GET['pgm_skipped'] ?? 0 );
echo '<div class="notice notice-success is-dismissible"><p>';
echo esc_html(
sprintf(
/* translators: 1: probed count, 2: skipped count */
__( 'Run complete: %1$d probes collected, %2$d skipped (budget or error).', 'peptide-geo-monitor' ),
$probed,
$skipped
)
);
echo '</p></div>';
}
}

View File

@@ -1,129 +0,0 @@
<?php
declare(strict_types=1);
/**
* Configuration helper: seed defaults and retrieve plugin settings.
*
* All mutable settings live in wp_options under the pgm_ prefix. This class
* provides typed getters (no magic values in call sites) and seeds the initial
* defaults on first activation.
*
* Who triggers: PGM_Activator::activate() for seeding; every component that
* needs a setting reads through here.
* Dependencies: get_option(), update_option().
*
* @see CONTEXT.md — Domain glossary: peptides, prompt intents, models.
* @see ARCHITECTURE.md — §Config (options + defaults).
* @package PeptideGeoMonitor
*/
class PGM_Config {
/**
* Seed the default option values on first activation.
*
* Uses add_option() so existing values are never overwritten on re-activation.
*
* Side effects: Writes multiple wp_options rows on first run.
*
* @return void
*/
public static function seed_defaults(): void {
add_option( 'pgm_monthly_budget_usd', PGM_DEFAULT_MONTHLY_BUDGET_USD );
add_option( 'pgm_peptides', self::default_peptides() );
add_option( 'pgm_prompt_intents', self::default_prompt_intents() );
add_option( 'pgm_models', self::default_models() );
}
/**
* Get the monthly budget cap in USD.
*
* @return float Positive float; falls back to the plugin default.
*/
public static function get_monthly_budget_usd(): float {
$val = (float) get_option( 'pgm_monthly_budget_usd', PGM_DEFAULT_MONTHLY_BUDGET_USD );
return $val > 0.0 ? $val : PGM_DEFAULT_MONTHLY_BUDGET_USD;
}
/**
* Get the list of peptide slugs/labels to probe.
*
* Each element is an associative array with 'slug' and 'label' keys.
*
* @return array<int, array{slug: string, label: string}>
*/
public static function get_peptides(): array {
$raw = get_option( 'pgm_peptides', self::default_peptides() );
return is_array( $raw ) ? $raw : self::default_peptides();
}
/**
* Get the prompt intent templates.
*
* Each string may contain {peptide} as a placeholder.
*
* @return array<int, string>
*/
public static function get_prompt_intents(): array {
$raw = get_option( 'pgm_prompt_intents', self::default_prompt_intents() );
return is_array( $raw ) ? $raw : self::default_prompt_intents();
}
/**
* Get the model identifiers to probe with.
*
* @return array<int, string>
*/
public static function get_models(): array {
$raw = get_option( 'pgm_models', self::default_models() );
return is_array( $raw ) ? $raw : self::default_models();
}
/**
* Default set of ~12 high-interest peptides (slug + human-readable label).
*
* @return array<int, array{slug: string, label: string}>
*/
private static function default_peptides(): array {
return array(
array( 'slug' => 'bpc-157', 'label' => 'BPC-157' ),
array( 'slug' => 'tb-500', 'label' => 'TB-500' ),
array( 'slug' => 'mk-677', 'label' => 'MK-677' ),
array( 'slug' => 'cjc-1295', 'label' => 'CJC-1295' ),
array( 'slug' => 'ghrp-6', 'label' => 'GHRP-6' ),
array( 'slug' => 'ipamorelin', 'label' => 'Ipamorelin' ),
array( 'slug' => 'semaglutide', 'label' => 'Semaglutide' ),
array( 'slug' => 'tirzepatide', 'label' => 'Tirzepatide' ),
array( 'slug' => 'selank', 'label' => 'Selank' ),
array( 'slug' => 'semax', 'label' => 'Semax' ),
array( 'slug' => 'nad-plus', 'label' => 'NAD+' ),
array( 'slug' => 'aod-9604', 'label' => 'AOD-9604' ),
);
}
/**
* Default prompt intent templates — three query styles per peptide.
*
* @return array<int, string>
*/
private static function default_prompt_intents(): array {
return array(
'what is {peptide}',
'{peptide} benefits and dosage',
'{peptide} reconstitution guide',
);
}
/**
* Default models: Perplexity sonar (primary citation signal) + two
* OpenRouter-routed search-capable models for breadth.
*
* @return array<int, string>
*/
private static function default_models(): array {
return array(
'perplexity/sonar',
'openai/gpt-4o-search-preview',
'google/gemini-2.0-flash-001',
);
}
}

View File

@@ -1,82 +0,0 @@
<?php
declare(strict_types=1);
/**
* WP-Cron management for the weekly AI-visibility probe.
*
* Registers the pgm_weekly_probe hook, schedules/clears the event, and
* dispatches to PGM_Probe_Runner on each tick.
*
* Who triggers: PGM_Plugin::init() (hook registration), PGM_Activator
* (schedule), PGM_Deactivator (clear).
* Dependencies: PGM_Probe_Runner.
*
* @see class-pgm-probe-runner.php — Runner invoked on each cron tick.
* @see class-pgm-activator.php — Calls schedule_weekly() on activation.
* @package PeptideGeoMonitor
*/
class PGM_Cron {
/**
* Register WordPress action hooks.
*
* Side effects: Adds action for PGM_CRON_HOOK.
*
* @return void
*/
public function register_hooks(): void {
add_action( PGM_CRON_HOOK, array( $this, 'handle_cron_tick' ) );
}
/**
* Callback fired by WP-Cron on the weekly schedule.
*
* Side effects: Instantiates and runs PGM_Probe_Runner.
*
* @return void
*/
public function handle_cron_tick(): void {
$runner = new PGM_Probe_Runner();
$runner->run();
}
/**
* Schedule the weekly cron event if not already scheduled.
*
* Safe to call on re-activation — no-op when already scheduled.
*
* Side effects: Adds a WP-Cron event.
*
* @return void
*/
public static function schedule_weekly(): void {
if ( ! wp_next_scheduled( PGM_CRON_HOOK ) ) {
wp_schedule_event( time(), 'weekly', PGM_CRON_HOOK );
}
}
/**
* Remove the weekly cron schedule.
*
* Called on deactivation. Does not purge data.
*
* Side effects: Removes the WP-Cron event.
*
* @return void
*/
public static function clear_schedule(): void {
$timestamp = wp_next_scheduled( PGM_CRON_HOOK );
if ( false !== $timestamp ) {
wp_unschedule_event( $timestamp, PGM_CRON_HOOK );
}
}
/**
* Check whether the weekly event is currently scheduled.
*
* @return bool
*/
public static function is_scheduled(): bool {
return false !== wp_next_scheduled( PGM_CRON_HOOK );
}
}

View File

@@ -1,41 +0,0 @@
<?php
declare(strict_types=1);
/**
* Main orchestrator for the Peptide GEO Monitor 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.
*
* Who triggers: plugins_loaded action (peptide-geo-monitor.php).
* Dependencies: PGM_Cron, PGM_Admin_Page, PGM_Collector_Registry.
*
* @see ARCHITECTURE.md — Boot sequence diagram.
* @package PeptideGeoMonitor
*/
class PGM_Plugin {
/**
* Wire up all WordPress hooks.
*
* Side effects: Registers admin menus, enqueues assets, and registers the
* collector/panel registry.
*
* @return void
*/
public function init(): void {
$cron = new PGM_Cron();
$cron->register_hooks();
if ( is_admin() ) {
$page = new PGM_Admin_Page();
$page->register_hooks();
}
// Register the v1 AI-visibility collector + panel.
$registry = PGM_Collector_Registry::instance();
$registry->register_collector( new PGM_Ai_Visibility_Collector() );
$registry->register_panel( 'ai_visibility', new PGM_Ai_Visibility_Panel() );
}
}

View File

@@ -1,202 +0,0 @@
<?php
declare(strict_types=1);
/**
* Orchestrates a full probe run: peptides × intents × models.
*
* For each combination the runner:
* 1. Checks the monthly budget cap (aborts gracefully when hit).
* 2. Caches responses for identical (peptide, intent, model) within the run.
* 3. Dispatches to the correct provider (resolved from PGM_Config::get_models()).
* 4. Writes a row to the pgm_ai_visibility table.
* 5. Settles the actual cost against the ledger row.
*
* Who triggers: PGM_Cron::handle_cron_tick() and PGM_Admin_Page (Run now action).
* Dependencies: PGM_Config, PGM_Cost_Ledger, PGM_Table_Manager, all providers.
*
* @see class-pgm-cron.php — Calls run() on the weekly schedule.
* @see class-pgm-cost-ledger.php — Budget cap enforcement.
* @see class-pgm-perplexity-provider.php
* @see class-pgm-openrouter-provider.php
* @see ARCHITECTURE.md — §Probe run flow.
* @package PeptideGeoMonitor
*/
class PGM_Probe_Runner {
/**
* @var PGM_Cost_Ledger
*/
private PGM_Cost_Ledger $ledger;
/**
* In-run response cache: key = "{peptide_slug}|{intent}|{model}" → PGM_Probe_Result.
*
* @var array<string, PGM_Probe_Result>
*/
private array $cache = array();
/**
* @param PGM_Cost_Ledger|null $ledger Injected for testing; auto-created otherwise.
*/
public function __construct( ?PGM_Cost_Ledger $ledger = null ) {
$this->ledger = $ledger ?? new PGM_Cost_Ledger();
}
/**
* 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
* persisted immediately so a mid-run failure doesn't lose data.
*
* Side effects: HTTP calls to LLM APIs, database writes.
*
* @return array{probed: int, skipped_budget: int, skipped_error: int}
* Summary counts for the admin log.
*/
public function run(): array {
$run_id = $this->generate_run_id();
$peptides = PGM_Config::get_peptides();
$intents = PGM_Config::get_prompt_intents();
$models = PGM_Config::get_models();
$counts = array(
'probed' => 0,
'skipped_budget' => 0,
'skipped_error' => 0,
);
foreach ( $peptides as $peptide ) {
foreach ( $intents as $intent_tpl ) {
$query = str_replace( '{peptide}', $peptide['label'], $intent_tpl );
foreach ( $models as $model ) {
$provider = $this->resolve_provider( $model );
if ( null === $provider || ! $provider->is_configured() ) {
$counts['skipped_error']++;
continue;
}
// Budget pre-check.
$estimated = $this->get_estimated_cost( $model );
if ( ! $this->ledger->can_afford( $estimated ) ) {
$counts['skipped_budget']++;
continue;
}
// In-run cache check.
$cache_key = $peptide['slug'] . '|' . $intent_tpl . '|' . $model;
if ( isset( $this->cache[ $cache_key ] ) ) {
$result = $this->cache[ $cache_key ];
} else {
try {
$result = $provider->probe( $query );
$this->cache[ $cache_key ] = $result;
} catch ( \Exception $e ) {
error_log( sprintf( 'PGM: probe failed [%s / %s]: %s', $model, $peptide['slug'], $e->getMessage() ) );
$counts['skipped_error']++;
continue;
}
}
$row_id = $this->persist_result( $run_id, $peptide, $model, $intent_tpl, $result );
if ( $row_id > 0 ) {
$this->ledger->update_row_cost( $row_id, $result->get_cost_usd() );
}
$counts['probed']++;
}
}
}
$this->cache = array(); // Reset cache after run.
return $counts;
}
/**
* Persist a single probe result to the database.
*
* @param string $run_id UUID for this run.
* @param array{slug:string, label:string} $peptide Peptide config.
* @param string $model Model identifier.
* @param string $intent_tpl Raw intent template string.
* @param PGM_Probe_Result $result Probe result.
*
* @return int Inserted row ID, or 0 on failure.
*/
private function persist_result( string $run_id, array $peptide, string $model, string $intent_tpl, PGM_Probe_Result $result ): int {
global $wpdb;
$table = PGM_Table_Manager::get_table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->insert(
$table,
array(
'run_id' => $run_id,
'captured_at' => current_time( 'mysql', true ),
'peptide_slug' => $peptide['slug'],
'peptide_label' => $peptide['label'],
'model' => $model,
'prompt_intent' => $intent_tpl,
'cited' => $result->is_cited() ? 1 : 0,
'our_position' => $result->get_our_position(),
'source_domains' => wp_json_encode( $result->get_source_domains() ),
'raw_excerpt' => $result->get_raw_excerpt(),
'cost_usd' => number_format( $result->get_cost_usd(), 8, '.', '' ),
),
array( '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%s', '%s', '%s' )
);
return (int) $wpdb->insert_id;
}
/**
* Instantiate the correct provider class for a model string.
*
* @param string $model OpenRouter model identifier.
*
* @return PGM_Probe_Provider|null Null when the model is unrecognised.
*/
private function resolve_provider( string $model ): ?PGM_Probe_Provider {
if ( 'perplexity/sonar' === $model ) {
return new PGM_Perplexity_Provider();
}
// All other models route through the generic OpenRouter provider.
return new PGM_OpenRouter_Provider( $model );
}
/**
* Conservative per-call cost estimate used for the budget pre-check.
*
* @param string $model Model identifier.
*
* @return float Estimated USD.
*/
private function get_estimated_cost( string $model ): float {
if ( 'perplexity/sonar' === $model ) {
return PGM_Perplexity_Provider::ESTIMATED_COST_PER_PROBE;
}
return PGM_OpenRouter_Provider::ESTIMATED_COST_PER_PROBE;
}
/**
* Generate a UUID v4 run identifier.
*
* @return string
*/
private function generate_run_id(): string {
return sprintf(
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
wp_rand( 0, 0xffff ),
wp_rand( 0, 0xffff ),
wp_rand( 0, 0xffff ),
wp_rand( 0, 0x0fff ) | 0x4000,
wp_rand( 0, 0x3fff ) | 0x8000,
wp_rand( 0, 0xffff ),
wp_rand( 0, 0xffff ),
wp_rand( 0, 0xffff )
);
}
}

View File

@@ -1,94 +0,0 @@
<?php
declare(strict_types=1);
/**
* Database table creation and schema versioning for PGM.
*
* Creates {prefix}pgm_ai_visibility via dbDelta (idempotent). Tracks the
* schema version in the pgm_schema_version option so future migrations can
* detect when an upgrade is needed without destructive ALTER TABLE.
*
* Who triggers: PGM_Activator::activate() on plugin activation.
* Dependencies: $wpdb global, dbDelta() (requires upgrade.php).
*
* @see class-pgm-activator.php — Calls create_table() on activation.
* @see ARCHITECTURE.md — §Storage.
* @package PeptideGeoMonitor
*/
class PGM_Table_Manager {
/**
* Name of the custom table (without $wpdb->prefix).
*/
const TABLE_BASE = 'pgm_ai_visibility';
/**
* 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.
*
* Side effects: Writes to the database; sets pgm_schema_version option.
*
* @return void
*/
public static function create_table(): void {
global $wpdb;
$table_name = $wpdb->prefix . self::TABLE_BASE;
$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,
PRIMARY KEY (id),
KEY run_id (run_id),
KEY peptide_slug (peptide_slug),
KEY captured_at (captured_at)
) {$charset_collate};";
if ( ! function_exists( 'dbDelta' ) ) {
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
}
dbDelta( $sql );
update_option( 'pgm_schema_version', PGM_SCHEMA_VERSION );
}
/**
* Get the full table name including the WP prefix.
*
* @return string
*/
public static function get_table_name(): string {
global $wpdb;
return $wpdb->prefix . self::TABLE_BASE;
}
/**
* Drop the AI-visibility table entirely.
*
* Called only from uninstall.php — never during normal deactivation.
*
* Side effects: Permanently destroys all probe data.
*
* @return void
*/
public static function drop_table(): void {
global $wpdb;
$table = $wpdb->prefix . self::TABLE_BASE;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->query( "DROP TABLE IF EXISTS {$table}" );
}
}

View File

@@ -1,4 +1,10 @@
<?php
/**
* Plugin activation handler.
*
* @package PrVision
*/
declare(strict_types=1);
/**
@@ -8,14 +14,14 @@ declare(strict_types=1);
* WP-Cron probe schedule. Safe to call on re-activation — dbDelta is
* idempotent and the cron schedule is only added when absent.
*
* Who triggers: register_activation_hook() in peptide-geo-monitor.php.
* Dependencies: PGM_Table_Manager, PGM_Cron, PGM_Config.
* Who triggers: register_activation_hook() in pr-vision.php.
* Dependencies: PRV_Table_Manager, PRV_Cron, PRV_Config.
*
* @see class-pgm-table-manager.php — Database schema creation.
* @see class-pgm-cron.php — Cron scheduling.
* @package PeptideGeoMonitor
* @see class-prv-table-manager.php — Database schema creation.
* @see class-prv-cron.php — Cron scheduling.
* @package PrVision
*/
class PGM_Activator {
class PRV_Activator {
/**
* Run all activation tasks.
@@ -25,8 +31,8 @@ class PGM_Activator {
* @return void
*/
public static function activate(): void {
PGM_Table_Manager::create_table();
PGM_Config::seed_defaults();
PGM_Cron::schedule_weekly();
PRV_Table_Manager::create_table();
PRV_Config::seed_defaults();
PRV_Cron::schedule_weekly();
}
}

View File

@@ -0,0 +1,289 @@
<?php
/**
* PR Vision top-level admin page.
*
* @package PrVision
*/
declare(strict_types=1);
/**
* PR Vision top-level admin page.
*
* 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.
*
* @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
*/
class PRV_Admin_Page {
/**
* Admin menu slug.
*/
const MENU_SLUG = 'pr-vision';
/**
* Nonce action for the Run now form.
*/
const NONCE_ACTION = 'prv_run_now';
/**
* Nonce field name.
*/
const NONCE_FIELD = 'prv_nonce';
/**
* Register WordPress admin hooks.
*
* Side effects: Adds admin_menu and admin_post actions.
*
* @return void
*/
public function register_hooks(): void {
add_action( 'admin_menu', array( $this, 'add_menu_page' ) );
add_action( 'admin_post_prv_run_now', array( $this, 'handle_run_now' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
}
/**
* Add the "PR Vision" top-level menu page.
*
* Side effects: Adds a WP admin menu entry.
*
* @return void
*/
public function add_menu_page(): void {
add_menu_page(
__( 'PR Vision', 'pr-vision' ),
__( 'PR Vision', 'pr-vision' ),
'manage_options',
self::MENU_SLUG,
array( $this, 'render_page' ),
'dashicons-chart-line',
58
);
}
/**
* 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.
*
* @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
);
wp_add_inline_style( 'wp-admin', $this->get_dashboard_css() );
}
/**
* Handle the "Run now" POST action.
*
* Validates nonce and capability before dispatching to PRV_Probe_Runner.
* Redirects back to the admin page with a status query arg.
*
* Side effects: Triggers a full probe run; redirects.
*
* @return void
*/
public function handle_run_now(): void {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'Insufficient permissions.', 'pr-vision' ), 403 );
}
$nonce = isset( $_POST[ self::NONCE_FIELD ] ) ? sanitize_text_field( wp_unslash( $_POST[ self::NONCE_FIELD ] ) ) : '';
if ( ! wp_verify_nonce( $nonce, self::NONCE_ACTION ) ) {
wp_die( esc_html__( 'Security check failed.', 'pr-vision' ), 403 );
}
$runner = new PRV_Probe_Runner();
$counts = $runner->run();
$redirect = add_query_arg(
array(
'page' => self::MENU_SLUG,
'prv_run_done' => 1,
'prv_probed' => (int) $counts['probed'],
'prv_skipped' => (int) ( $counts['skipped_budget'] + $counts['skipped_error'] ),
),
admin_url( 'admin.php' )
);
wp_safe_redirect( $redirect );
exit;
}
/**
* Render the full PR Vision admin page.
*
* Side effects: Outputs HTML directly.
*
* @return void
*/
public function render_page(): void {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'Insufficient permissions.', 'pr-vision' ) );
}
echo '<div class="wrap">';
echo '<h1>' . esc_html__( 'PR Vision — AI Visibility', 'pr-vision' ) . '</h1>';
$this->render_run_done_notice();
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 );
submit_button( __( 'Run now', 'pr-vision' ), 'secondary', 'prv_run_now_btn', false );
echo '</form>';
$registry = PRV_Collector_Registry::instance();
foreach ( $registry->get_collectors() as $key => $collector ) {
$panel = $registry->get_panel( $key );
if ( null === $panel ) {
continue;
}
$data = $collector->collect();
$panel->render( $data );
}
echo '</div>';
}
/**
* Render a success or partial notice after a Run now redirect.
*
* @return void
*/
private function render_run_done_notice(): void {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( empty( $_GET['prv_run_done'] ) ) {
return;
}
$probed = isset( $_GET['prv_probed'] ) ? absint( wp_unslash( $_GET['prv_probed'] ) ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash, Generic.Formatting.MultipleStatementAlignment.NotSameWarning, Generic.Formatting.MultipleStatementAlignment.IncorrectWarning
// phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
$skipped = isset( $_GET['prv_skipped'] ) ? absint( wp_unslash( $_GET['prv_skipped'] ) ) : 0;
echo '<div class="notice notice-success is-dismissible"><p>';
echo esc_html(
sprintf(
/* translators: 1: probed count, 2: skipped count */
__( 'Run complete: %1$d probes collected, %2$d skipped (budget or error).', 'pr-vision' ),
$probed,
$skipped
)
);
echo '</p></div>';
}
/**
* Return the dashboard CSS for the PR Vision admin page.
*
* Covers two concerns:
* 1. WP admin wrapper chrome (injected here; fires on BOTH PR Vision screens
* because the enqueue_assets guard matches 'pr-vision' in the hook suffix):
* #wpcontent / #wpbody-content are set to the dark page bg so no white
* gutter/strip shows around or above the dark UI.
* 2. Component tokens (.prv-*) — unchanged from prior versions.
*
* This inline style block is attached to wp-admin (always loaded on admin
* pages) via wp_add_inline_style() and therefore only present on the two
* PR Vision screens. It must NOT be registered globally.
*
* @return string CSS string.
*/
private function get_dashboard_css(): string {
return '
/* === PR Vision — WP admin chrome: kill white wrappers (scoped via conditional enqueue) === */
#wpcontent{background:#14181C;padding-left:0;}
#wpbody-content{background:#14181C;min-height:calc(100vh - 32px);padding-bottom:40px;}
#wpbody{background:#14181C;}
.wrap>h1,.prv-settings-wrap~*>h1,.wp-heading-inline{color:#EEF2F5;}
.notice.prv-proxy-note{background:#1C2228;border-left-color:#34C0CA;color:#EEF2F5;}
.notice.prv-proxy-note p{color:#C2CCD6;}
/* === PR Vision dashboard — "Assay" palette (dark-adapted) === */
.prv-bento{display:grid;grid-template-columns:1.25fr 1fr 1fr;gap:16px;margin:18px 0 22px;}
@media(max-width:820px){.prv-bento{grid-template-columns:1fr;}}
.prv-tile{background:#1C2228;border:1px solid #3A4651;border-radius:12px;padding:20px 22px;position:relative;min-height:140px;display:flex;flex-direction:column;}
.prv-tile-label{font-size:11.5px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:#9AA7B2;margin-bottom:6px;}
.prv-tile-big{font-size:42px;font-weight:700;color:#EEF2F5;line-height:1;font-variant-numeric:tabular-nums;margin-top:auto;}
.prv-tile-big small{font-size:16px;font-weight:600;color:#9AA7B2;margin-left:3px;}
.prv-tile-sub{font-size:12.5px;color:#9AA7B2;margin-top:8px;}
.prv-tile-delta{font-size:12.5px;font-weight:600;margin-top:8px;}
.prv-delta--up{color:#9BE635;} .prv-delta--down{color:#FF6B5E;} .prv-delta--flat{color:#9AA7B2;}
/* health pill */
.prv-health-pill{position:absolute;top:14px;right:14px;display:inline-flex;align-items:center;gap:5px;font-size:11.5px;font-weight:600;padding:3px 9px 3px 7px;border-radius:999px;}
.prv-health-dot{width:7px;height:7px;border-radius:50%;flex:0 0 7px;}
.prv-health--ok{background:rgba(155,230,53,.15);color:#9BE635;} .prv-health--ok .prv-health-dot{background:#9BE635;}
.prv-health--warn{background:rgba(255,157,77,.15);color:#FFB36E;} .prv-health--warn .prv-health-dot{background:#FF9D4D;}
.prv-health--neutral{background:rgba(136,147,160,.14);color:#B4BDC7;} .prv-health--neutral .prv-health-dot{background:#8893A0;}
/* cost tile */
.prv-cost-row{display:flex;align-items:baseline;gap:4px;margin-top:auto;}
.prv-cost-big{font-size:28px;font-weight:700;color:#EEF2F5;font-variant-numeric:tabular-nums;}
.prv-cost-cap{font-size:13px;color:#9AA7B2;}
.prv-meter{height:10px;border-radius:999px;background:#2A333C;margin-top:8px;border:1px solid #333D47;overflow:hidden;}
.prv-meter>span{display:block;height:100%;border-radius:999px;background:#34C0CA;transition:width .3s ease;}
.prv-meter--capped>span{background:#FF9D4D;}
.prv-trunc-badge{display:flex;align-items:flex-start;gap:7px;margin-top:10px;background:rgba(255,157,77,.15);border:1px solid rgba(255,157,77,.42);border-radius:6px;padding:8px 10px;font-size:12px;color:#EEF2F5;line-height:1.4;}
.prv-trunc-icon{color:#FFB36E;font-size:14px;flex:0 0 16px;margin-top:1px;}
.prv-trunc-badge b{color:#FFB36E;}
/* last run tile */
.prv-tile-ts{font-size:20px;font-weight:700;color:#EEF2F5;margin-top:auto;line-height:1.2;}
.prv-tile-meta{font-size:12.5px;color:#9AA7B2;margin-top:6px;}
/* cards */
.prv-card{background:#1C2228;border:1px solid #3A4651;border-radius:12px;margin:0 0 20px;}
.prv-card-head{display:flex;align-items:center;justify-content:space-between;padding:18px 22px 0;}
.prv-card-head h2{font-size:16px;font-weight:600;color:#EEF2F5;margin:0;}
.prv-card-body{padding:10px 22px 20px;}
/* chart */
.prv-chartbox{position:relative;height:300px;max-height:300px;padding:14px 8px 0;overflow:hidden;}
.prv-chartbox canvas{max-height:286px;}
.prv-chart-fallback{display:none;height:100%;align-items:center;justify-content:center;text-align:center;color:#9AA7B2;font-size:13px;padding:0 24px;}
.prv-chartbox.prv-noscript .prv-chart-fallback{display:flex;}
.prv-chartbox.prv-noscript canvas{display:none;}
/* chart legend */
.prv-chartcap{display:flex;align-items:center;gap:16px;flex-wrap:wrap;padding:8px 22px 4px;font-size:12.5px;color:#9AA7B2;}
.prv-chartcap-item{display:inline-flex;align-items:center;gap:6px;}
.prv-leg-solid{width:20px;height:0;border-top:3px solid #34C0CA;display:inline-block;}
.prv-leg-dash{width:20px;height:0;border-top:2px dashed #B6F25A;display:inline-block;}
.prv-leg-cfg{display:inline-flex;align-items:center;gap:3px;}
.prv-leg-cfg::before{content:"";width:0;height:14px;border-left:2px dashed #FFB36E;display:inline-block;}
.prv-leg-cfg::after{content:"";width:8px;height:8px;border-radius:50%;background:#FF9D4D;display:inline-block;}
/* config change note */
.prv-cfg-note{display:flex;gap:10px;align-items:flex-start;margin:8px 22px 14px;background:rgba(255,157,77,.15);border:1px solid rgba(255,157,77,.42);border-radius:8px;padding:11px 14px;color:#EEF2F5;font-size:13px;line-height:1.45;}
.prv-cfg-note-icon{color:#FFB36E;font-style:normal;flex:0 0 16px;margin-top:1px;}
.prv-cfg-note b{color:#FFB36E;}
/* standings table */
.prv-standings{margin:0;}
.prv-status{display:inline-flex;align-items:center;gap:4px;font-size:12.5px;font-weight:600;padding:2px 8px;border-radius:999px;}
.prv-status--cited{background:rgba(155,230,53,.15);color:#9BE635;}
.prv-status--not-yet{background:rgba(136,147,160,.14);color:#B4BDC7;}
/* proxy note */
.prv-proxy-note{margin:10px 0;}
';
}
}

View File

@@ -1,21 +1,27 @@
<?php
/**
* Class and interface autoloader for the PR Vision plugin.
*
* @package PrVision
*/
declare(strict_types=1);
/**
* Class and interface autoloader for the Peptide GEO Monitor plugin.
* Class and interface autoloader for the PR Vision plugin.
*
* Converts PGM_* class/interface names to file paths under includes/.
* Converts PRV_* class/interface names to file paths under includes/.
* Naming conventions:
* PGM_Foo_Bar (class) → class-pgm-foo-bar.php
* PGM_Foo_Bar (interface) → interface-pgm-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 (peptide-geo-monitor.php) — called once before
* any PGM_* class or interface is instantiated.
* Who triggers: Plugin boot (pr-vision.php) -- called once before
* any PRV_* class or interface is instantiated.
* Dependencies: None (pure PHP).
*
* @package PeptideGeoMonitor
* @package PrVision
*/
class PGM_Autoloader {
class PRV_Autoloader {
/**
* Register the autoloader with the SPL stack.
@@ -27,7 +33,7 @@ class PGM_Autoloader {
}
/**
* Attempt to load a PGM_* class or interface file.
* Attempt to load a PRV_* class or interface file.
*
* Walks the known include subdirectories in order, trying the class-
* filename first and the interface-filename second.
@@ -39,18 +45,18 @@ class PGM_Autoloader {
* @return void
*/
public static function load( string $class_name ): void {
if ( 0 !== strpos( $class_name, 'PGM_' ) ) {
if ( 0 !== strpos( $class_name, 'PRV_' ) ) {
return;
}
$slug = strtolower( str_replace( '_', '-', $class_name ) );
$search_dirs = array(
PGM_PLUGIN_DIR . 'includes/core/',
PGM_PLUGIN_DIR . 'includes/providers/',
PGM_PLUGIN_DIR . 'includes/collector/',
PGM_PLUGIN_DIR . 'includes/panel/',
PGM_PLUGIN_DIR . 'includes/',
PRV_PLUGIN_DIR . 'includes/core/',
PRV_PLUGIN_DIR . 'includes/providers/',
PRV_PLUGIN_DIR . 'includes/collector/',
PRV_PLUGIN_DIR . 'includes/panel/',
PRV_PLUGIN_DIR . 'includes/',
);
// Try class file first, then interface file.

View File

@@ -1,42 +1,48 @@
<?php
/**
* Registry for PR Vision data collectors and dashboard panels.
*
* @package PrVision
*/
declare(strict_types=1);
/**
* Registry: holds all registered collectors and panels.
*
* PGM_Plugin::init() registers the v1 AI-visibility collector + panel.
* PRV_Plugin::init() registers the v1 AI-visibility collector + panel.
* Future collectors/panels call register_collector()/register_panel()
* without touching the dashboard shell.
*
* Who triggers: PGM_Plugin::init() on plugins_loaded.
* Dependencies: PGM_Data_Collector, PGM_Dashboard_Panel interfaces.
* Who triggers: PRV_Plugin::init() on plugins_loaded.
* Dependencies: PRV_Data_Collector, PRV_Dashboard_Panel interfaces.
*
* @see interface-pgm-data-collector.php — Collector contract.
* @see interface-pgm-dashboard-panel.php — Panel contract.
* @see class-pgm-plugin.php — Calls register_* on boot.
* @see interface-prv-data-collector.php — Collector contract.
* @see interface-prv-dashboard-panel.php — Panel contract.
* @see class-prv-plugin.php — Calls register_* on boot.
* @see ARCHITECTURE.md — §Collector/Panel seam.
* @package PeptideGeoMonitor
* @package PrVision
*/
class PGM_Collector_Registry {
class PRV_Collector_Registry {
/**
* Singleton instance.
*
* @var PGM_Collector_Registry|null
* @var PRV_Collector_Registry|null
*/
private static ?PGM_Collector_Registry $instance = null;
private static ?PRV_Collector_Registry $instance = null;
/**
* Registered collectors keyed by their collector key.
*
* @var array<string, PGM_Data_Collector>
* @var array<string, PRV_Data_Collector>
*/
private array $collectors = array();
/**
* Registered panels keyed by their panel key (matches collector key).
*
* @var array<string, PGM_Dashboard_Panel>
* @var array<string, PRV_Dashboard_Panel>
*/
private array $panels = array();
@@ -60,30 +66,30 @@ class PGM_Collector_Registry {
/**
* Register a data collector.
*
* @param PGM_Data_Collector $collector
* @param PRV_Data_Collector $collector The collector to register.
*
* @return void
*/
public function register_collector( PGM_Data_Collector $collector ): void {
public function register_collector( PRV_Data_Collector $collector ): void {
$this->collectors[ $collector->get_key() ] = $collector;
}
/**
* Register a dashboard panel.
*
* @param string $key Panel key (must match a collector key).
* @param PGM_Dashboard_Panel $panel
* @param string $key Panel key (must match a collector key).
* @param PRV_Dashboard_Panel $panel The panel to register.
*
* @return void
*/
public function register_panel( string $key, PGM_Dashboard_Panel $panel ): void {
public function register_panel( string $key, PRV_Dashboard_Panel $panel ): void {
$this->panels[ $key ] = $panel;
}
/**
* Get all registered collectors.
*
* @return array<string, PGM_Data_Collector>
* @return array<string, PRV_Data_Collector>
*/
public function get_collectors(): array {
return $this->collectors;
@@ -94,9 +100,9 @@ class PGM_Collector_Registry {
*
* @param string $key Panel key.
*
* @return PGM_Dashboard_Panel|null
* @return PRV_Dashboard_Panel|null
*/
public function get_panel( string $key ): ?PGM_Dashboard_Panel {
public function get_panel( string $key ): ?PRV_Dashboard_Panel {
return $this->panels[ $key ] ?? null;
}

View File

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

View File

@@ -0,0 +1,210 @@
<?php
/**
* Configuration getters and default-seeding for PR Vision.
*
* @package PrVision
*/
declare(strict_types=1);
/**
* Configuration helper: seed defaults and retrieve plugin settings.
*
* 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. 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(), PRV_Model_Registry.
*
* @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.
*
* Uses add_option() so existing values are never overwritten on re-activation.
*
* Side effects: Writes multiple wp_options rows on first run.
*
* @return void
*/
public static function seed_defaults(): void {
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( 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.
}
/**
* Get the monthly budget cap in USD.
*
* @return float Positive float; falls back to the plugin default.
*/
public static function get_monthly_budget_usd(): float {
$val = (float) get_option( 'prv_monthly_budget_usd', PRV_DEFAULT_MONTHLY_BUDGET_USD );
return $val > 0.0 ? $val : PRV_DEFAULT_MONTHLY_BUDGET_USD;
}
/**
* Get the list of peptide slugs/labels to probe.
*
* Each element is an associative array with 'slug' and 'label' keys.
*
* @return array<int, array{slug: string, label: string}>
*/
public static function get_peptides(): array {
$raw = get_option( 'prv_peptides', self::default_peptides() );
return is_array( $raw ) ? $raw : self::default_peptides();
}
/**
* Get the prompt intent templates.
*
* Each string may contain {peptide} as a placeholder.
*
* @return array<int, string>
*/
public static function get_prompt_intents(): array {
$raw = get_option( 'prv_prompt_intents', self::default_prompt_intents() );
return is_array( $raw ) ? $raw : self::default_prompt_intents();
}
/**
* 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 {
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,
);
}
/**
* Default set of ~12 high-interest peptides (slug + human-readable label).
*
* @return array<int, array{slug: string, label: string}>
*/
private static function default_peptides(): array {
return array(
array(
'slug' => 'bpc-157',
'label' => 'BPC-157',
),
array(
'slug' => 'tb-500',
'label' => 'TB-500',
),
array(
'slug' => 'mk-677',
'label' => 'MK-677',
),
array(
'slug' => 'cjc-1295',
'label' => 'CJC-1295',
),
array(
'slug' => 'ghrp-6',
'label' => 'GHRP-6',
),
array(
'slug' => 'ipamorelin',
'label' => 'Ipamorelin',
),
array(
'slug' => 'semaglutide',
'label' => 'Semaglutide',
),
array(
'slug' => 'tirzepatide',
'label' => 'Tirzepatide',
),
array(
'slug' => 'selank',
'label' => 'Selank',
),
array(
'slug' => 'semax',
'label' => 'Semax',
),
array(
'slug' => 'nad-plus',
'label' => 'NAD+',
),
array(
'slug' => 'aod-9604',
'label' => 'AOD-9604',
),
);
}
/**
* Default prompt intent templates -- three query styles per peptide.
*
* @return array<int, string>
*/
private static function default_prompt_intents(): array {
return array(
'what is {peptide}',
'{peptide} benefits and dosage',
'{peptide} reconstitution guide',
);
}
}

View File

@@ -1,4 +1,10 @@
<?php
/**
* Month-to-date cost tracking and budget-cap enforcement.
*
* @package PrVision
*/
declare(strict_types=1);
/**
@@ -9,19 +15,19 @@ declare(strict_types=1);
* throwing — a partial run that respects the budget is acceptable; an
* over-spend is not.
*
* Costs are stored in the pgm_ai_visibility table (cost_usd column per row)
* Costs are stored in the prv_ai_visibility table (cost_usd column per row)
* and summarized by summing the current calendar month's rows. No separate
* ledger table is needed; the visibility table IS the ledger.
*
* Who triggers: PGM_Probe_Runner before and after each API call.
* Dependencies: $wpdb, PGM_Table_Manager.
* Who triggers: PRV_Probe_Runner before and after each API call.
* Dependencies: $wpdb, PRV_Table_Manager.
*
* @see class-pgm-probe-runner.php — Calls can_afford() and record_call().
* @see class-pgm-table-manager.php — Table where cost_usd is stored.
* @see class-prv-probe-runner.php — Calls can_afford() and record_call().
* @see class-prv-table-manager.php — Table where cost_usd is stored.
* @see CONTEXT.md — "monthly cap" definition.
* @package PeptideGeoMonitor
* @package PrVision
*/
class PGM_Cost_Ledger {
class PRV_Cost_Ledger {
/**
* Retrieve the total probe cost incurred in the current calendar month (UTC).
@@ -33,7 +39,7 @@ class PGM_Cost_Ledger {
public function get_month_to_date_usd(): float {
global $wpdb;
$table = PGM_Table_Manager::get_table_name();
$table = PRV_Table_Manager::get_table_name();
$month = gmdate( 'Y-m' );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
@@ -60,8 +66,8 @@ class PGM_Cost_Ledger {
* @return bool True when the call can proceed within budget.
*/
public function can_afford( float $estimated_cost_usd ): bool {
$cap = PGM_Config::get_monthly_budget_usd();
$spent = $this->get_month_to_date_usd();
$cap = PRV_Config::get_monthly_budget_usd();
$spent = $this->get_month_to_date_usd();
return ( $spent + $estimated_cost_usd ) <= $cap;
}
@@ -71,7 +77,7 @@ class PGM_Cost_Ledger {
* @return float Remaining USD; 0.0 when the cap is already hit.
*/
public function get_remaining_budget_usd(): float {
$cap = PGM_Config::get_monthly_budget_usd();
$cap = PRV_Config::get_monthly_budget_usd();
$spent = $this->get_month_to_date_usd();
return max( 0.0, $cap - $spent );
}
@@ -79,13 +85,13 @@ class PGM_Cost_Ledger {
/**
* Record a completed API call's cost against a specific row ID.
*
* Updates the cost_usd column on the row created by PGM_Probe_Runner.
* Updates the cost_usd column on the row created by PRV_Probe_Runner.
* The run is already written to the table before the cost is settled so
* even partial runs (budget abort) have their completed rows costed.
*
* Side effects: Database write.
*
* @param int $row_id Primary key of the pgm_ai_visibility row.
* @param int $row_id Primary key of the prv_ai_visibility row.
* @param float $cost_usd Actual cost in USD.
*
* @return bool True on success.
@@ -93,9 +99,9 @@ class PGM_Cost_Ledger {
public function update_row_cost( int $row_id, float $cost_usd ): bool {
global $wpdb;
$table = PGM_Table_Manager::get_table_name();
$table = PRV_Table_Manager::get_table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$result = $wpdb->update(
$table,
array( 'cost_usd' => number_format( $cost_usd, 8, '.', '' ) ),

View File

@@ -0,0 +1,152 @@
<?php
/**
* WP-Cron management for the weekly AI-visibility probe.
*
* @package PrVision
*/
declare(strict_types=1);
/**
* 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. 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), 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() on activation.
* @see class-prv-settings-page.php -- Calls reschedule() on cadence change.
* @package PrVision
*/
class PRV_Cron {
/**
* Register WordPress action hooks.
*
* Side effects: Adds action for PRV_CRON_HOOK.
*
* @return void
*/
public function register_hooks(): void {
add_action( PRV_CRON_HOOK, array( $this, 'handle_cron_tick' ) );
}
/**
* 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 probe cron event using the configured cadence.
*
* Safe to call on re-activation -- no-op when already scheduled at the
* correct recurrence; clears and reschedules when recurrence differs.
*
* 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 {
self::schedule();
}
/**
* Remove the cron schedule.
*
* Called on deactivation. Does not purge data.
*
* Side effects: Removes the WP-Cron event.
*
* @return void
*/
public static function clear_schedule(): void {
$timestamp = wp_next_scheduled( PRV_CRON_HOOK );
if ( false !== $timestamp ) {
wp_unschedule_event( $timestamp, PRV_CRON_HOOK );
}
}
/**
* Check whether the probe event is currently scheduled.
*
* @return bool
*/
public static function is_scheduled(): bool {
return false !== wp_next_scheduled( PRV_CRON_HOOK );
}
/**
* Return the timestamp of the next scheduled run.
*
* @return int|false Timestamp or false if not scheduled.
*/
public static function next_run_timestamp() {
return wp_next_scheduled( PRV_CRON_HOOK );
}
}

View File

@@ -0,0 +1,185 @@
<?php
/**
* Symmetric encryption/decryption helper for PR Vision.
*
* @package PrVision
*/
declare(strict_types=1);
/**
* Symmetric encryption/decryption using libsodium (preferred) or OpenSSL AES-256-GCM.
*
* Provides a narrow, single-purpose interface: encrypt() / decrypt(). The
* encryption key is always supplied by the caller (PRV_Key_Store derives it
* from WP salts). No key material is persisted here.
*
* Algorithm selection:
* - libsodium XSalsa20-Poly1305 (sodium_crypto_secretbox): used when the
* sodium extension is available (PHP 7.2+ default). Nonce = 24 bytes (random).
* - OpenSSL AES-256-GCM fallback: used when sodium is unavailable. IV = 12 bytes
* (random); 16-byte GCM auth tag appended to ciphertext.
*
* Storage format (both algorithms): hex(nonce_or_iv) : hex(ciphertext[+tag])
* The algorithm is inferred from nonce length at decrypt time.
*
* Security invariants:
* - Plaintext is NEVER stored, logged, or returned beyond the caller.
* - Each encrypt() call uses a fresh random nonce/IV.
* - Decryption silently returns '' on any failure (no info leak via exception).
*
* Who triggers: PRV_Key_Store — all encryption/decryption delegated here.
* Dependencies: sodium_crypto_secretbox (optional), openssl_encrypt (fallback).
*
* @see class-prv-key-store.php -- Calls encrypt() / decrypt().
* @package PrVision
*/
class PRV_Crypto_Helper {
/**
* Separator between hex-encoded nonce/IV and ciphertext in stored format.
*/
const SEPARATOR = ':';
/**
* Encrypt plaintext using libsodium or OpenSSL fallback.
*
* @param string $plaintext Value to encrypt.
* @param string $enc_key 32-byte raw binary encryption key.
*
* @return string hex(nonce):hex(ciphertext), or empty string on error.
*/
public static function encrypt( string $plaintext, string $enc_key ): string {
if ( function_exists( 'sodium_crypto_secretbox' ) ) {
return self::encrypt_sodium( $plaintext, $enc_key );
}
return self::encrypt_openssl( $plaintext, $enc_key );
}
/**
* Decrypt a stored ciphertext produced by encrypt().
*
* Algorithm is inferred from nonce length: sodium nonce = 24 bytes (48 hex).
*
* @param string $stored Encoded ciphertext (hex(nonce):hex(ciphertext)).
* @param string $enc_key 32-byte raw binary encryption key.
*
* @return string Plaintext, or empty string on any error.
*/
public static function decrypt( string $stored, string $enc_key ): string {
$sep_pos = strpos( $stored, self::SEPARATOR );
if ( false === $sep_pos ) {
return '';
}
$nonce_hex = substr( $stored, 0, $sep_pos );
$cipher_hex = substr( $stored, $sep_pos + 1 );
if ( '' === $nonce_hex || '' === $cipher_hex ) {
return '';
}
// Infer algorithm from nonce length: sodium = 24 bytes (48 hex chars).
$nonce_byte_count = strlen( $nonce_hex ) / 2;
if ( function_exists( 'sodium_crypto_secretbox_open' )
&& SODIUM_CRYPTO_SECRETBOX_NONCEBYTES === (int) $nonce_byte_count ) {
return self::decrypt_sodium( $nonce_hex, $cipher_hex, $enc_key );
}
// OpenSSL path: IV = 12 bytes (24 hex), 16-byte tag appended.
return self::decrypt_openssl( $nonce_hex, $cipher_hex, $enc_key );
}
// ── Private algorithm implementations ─────────────────────────────────
/**
* Encrypt using libsodium secretbox (XSalsa20-Poly1305).
*
* @param string $plaintext Value to encrypt.
* @param string $enc_key 32-byte raw key.
*
* @return string hex(nonce):hex(ciphertext), or empty on error.
*/
private static function encrypt_sodium( string $plaintext, string $enc_key ): string {
try {
$nonce = random_bytes( SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
$ciphertext = sodium_crypto_secretbox( $plaintext, $nonce, $enc_key );
return bin2hex( $nonce ) . self::SEPARATOR . bin2hex( $ciphertext );
} catch ( \Throwable $e ) {
return '';
}
}
/**
* Decrypt using libsodium secretbox.
*
* @param string $nonce_hex Hex-encoded 24-byte nonce.
* @param string $cipher_hex Hex-encoded ciphertext with Poly1305 MAC.
* @param string $enc_key 32-byte raw key.
*
* @return string Plaintext, or empty on failure.
*/
private static function decrypt_sodium( string $nonce_hex, string $cipher_hex, string $enc_key ): string {
try {
$nonce = hex2bin( $nonce_hex );
$ciphertext = hex2bin( $cipher_hex );
if ( false === $nonce || false === $ciphertext ) {
return '';
}
$result = sodium_crypto_secretbox_open( $ciphertext, $nonce, $enc_key );
return ( false === $result ) ? '' : $result;
} catch ( \Throwable $e ) {
return '';
}
}
/**
* Encrypt using OpenSSL AES-256-GCM (sodium fallback).
*
* Appends the 16-byte GCM auth tag to the ciphertext before hex-encoding,
* so decrypt() can split it without a separate field.
*
* @param string $plaintext Value to encrypt.
* @param string $enc_key 32-byte raw key.
*
* @return string hex(iv):hex(ciphertext+tag), or empty on error.
*/
private static function encrypt_openssl( string $plaintext, string $enc_key ): string {
if ( ! function_exists( 'openssl_encrypt' ) ) {
return '';
}
$iv = random_bytes( 12 ); // GCM standard IV length.
$tag = '';
$enc = openssl_encrypt( $plaintext, 'aes-256-gcm', $enc_key, OPENSSL_RAW_DATA, $iv, $tag, '', 16 );
if ( false === $enc || '' === $tag ) {
return '';
}
return bin2hex( $iv ) . self::SEPARATOR . bin2hex( $enc . $tag );
}
/**
* Decrypt using OpenSSL AES-256-GCM.
*
* Splits off the 16-byte auth tag appended by encrypt_openssl().
*
* @param string $iv_hex Hex-encoded 12-byte IV.
* @param string $cipher_hex Hex-encoded ciphertext with 16-byte GCM tag appended.
* @param string $enc_key 32-byte raw key.
*
* @return string Plaintext, or empty on failure.
*/
private static function decrypt_openssl( string $iv_hex, string $cipher_hex, string $enc_key ): string {
if ( ! function_exists( 'openssl_decrypt' ) ) {
return '';
}
$iv = hex2bin( $iv_hex );
$cipher_raw = hex2bin( $cipher_hex );
if ( false === $iv || false === $cipher_raw || strlen( $cipher_raw ) < 16 ) {
return '';
}
$tag = substr( $cipher_raw, -16 );
$ciphertext = substr( $cipher_raw, 0, -16 );
$result = openssl_decrypt( $ciphertext, 'aes-256-gcm', $enc_key, OPENSSL_RAW_DATA, $iv, $tag );
return ( false === $result ) ? '' : $result;
}
}

View File

@@ -1,4 +1,10 @@
<?php
/**
* Plugin deactivation handler.
*
* @package PrVision
*/
declare(strict_types=1);
/**
@@ -7,23 +13,23 @@ declare(strict_types=1);
* Clears the scheduled WP-Cron event so probes stop running when the plugin
* is deactivated. Does NOT purge data — that is uninstall's job.
*
* Who triggers: register_deactivation_hook() in peptide-geo-monitor.php.
* Dependencies: PGM_Cron.
* Who triggers: register_deactivation_hook() in pr-vision.php.
* Dependencies: PRV_Cron.
*
* @see class-pgm-cron.php — Cron registration + clearing.
* @see class-prv-cron.php — Cron registration + clearing.
* @see uninstall.php — Full data purge on uninstall.
* @package PeptideGeoMonitor
* @package PrVision
*/
class PGM_Deactivator {
class PRV_Deactivator {
/**
* Clear the weekly cron schedule.
*
* Side effects: Removes the pgm_weekly_probe event from WP-Cron.
* Side effects: Removes the prv_weekly_probe event from WP-Cron.
*
* @return void
*/
public static function deactivate(): void {
PGM_Cron::clear_schedule();
PRV_Cron::clear_schedule();
}
}

View File

@@ -0,0 +1,212 @@
<?php
/**
* Renderer for the Provider API Key manager card on the Settings page.
*
* @package PrVision
*/
declare(strict_types=1);
/**
* Renders the write-only Provider API Key manager card.
*
* Outputs only the key SOURCE (constant / admin / not set) and the
* last-run API status. The stored key value is NEVER included in any
* output: not in HTML, not in JS, not in data attributes.
*
* Three states:
* - SOURCE_CONSTANT: constant defined → lock the input, show note.
* - SOURCE_OPTION: admin key stored → show "Set via admin" + Replace/Remove.
* - SOURCE_NONE: no key → show "Not set" + Set form.
*
* The form submits to admin-post.php (prv_key_set or prv_key_remove),
* nonce-protected. The password input renders empty on every page load.
*
* Who triggers: PRV_Settings_Renderer::render_key_manager_card().
* Dependencies: PRV_Key_Store, PRV_Settings_Page (nonce constants).
*
* @see class-prv-key-store.php -- Source detection.
* @see class-prv-settings-renderer.php -- Calls render().
* @see class-prv-settings-page.php -- Nonce constants + AJAX action.
* @package PrVision
*/
class PRV_Key_Manager_Renderer {
/**
* Render the full Provider API Key card.
*
* Side effects: Outputs HTML directly. Contains no key value.
*
* @return void
*/
public function render(): void {
$source = PRV_Key_Store::get_source();
$is_const = PRV_Key_Store::SOURCE_CONSTANT === $source;
$is_set_opt = PRV_Key_Store::SOURCE_OPTION === $source;
$status = (string) get_option( 'prv_api_key_status', 'unknown' );
$last_check = (string) get_option( 'prv_api_key_last_check', '' );
echo '<div class="prv-card" id="prv-key-card">';
echo '<h2>' . esc_html__( 'Provider API Key', 'pr-vision' ) . '</h2>';
$this->render_status_badge( $source, $status, $last_check );
$this->render_key_form( $is_const, $is_set_opt );
$this->render_test_button( $source );
echo '</div>';
}
/**
* Render the read-only source + health status badge.
*
* @param string $source One of PRV_Key_Store::SOURCE_* constants.
* @param string $api_status Last stored API status (ok/failed/unknown).
* @param string $last_check ISO date of last status update.
*
* @return void
*/
private function render_status_badge( string $source, string $api_status, string $last_check ): void {
echo '<div class="prv-field">';
if ( PRV_Key_Store::SOURCE_CONSTANT === $source ) {
echo '<span class="prv-api-status prv-api-ok" id="prv-key-source-badge">';
echo '&#9679; ' . esc_html__( 'Set via wp-config (takes precedence)', 'pr-vision' );
echo '</span>';
} elseif ( PRV_Key_Store::SOURCE_OPTION === $source ) {
echo '<span class="prv-api-status prv-api-ok" id="prv-key-source-badge">';
echo '&#9679; ' . esc_html__( 'Set via admin', 'pr-vision' );
echo '</span>';
} else {
echo '<span class="prv-api-status prv-api-undef" id="prv-key-source-badge">';
echo '&#9679; ' . esc_html__( 'Not set', 'pr-vision' );
echo '</span>';
}
// Last-run health state.
if ( '' !== $last_check && PRV_Key_Store::SOURCE_NONE !== $source ) {
if ( 'failed' === $api_status ) {
echo '&nbsp;<span class="prv-api-status prv-api-fail">' . esc_html__( 'Last run: failed (key may be invalid or revoked)', 'pr-vision' ) . '</span>';
} elseif ( 'ok' === $api_status ) {
echo '&nbsp;<span class="prv-api-status prv-api-ok">' . esc_html__( 'Last run: OK', 'pr-vision' ) . '</span>';
}
}
echo '</div>';
}
/**
* Render the set/replace/remove key form.
*
* The password input always renders empty. When constant is set, the
* input is disabled with an explanatory note.
*
* @param bool $is_const Whether the constant is currently defined.
* @param bool $is_set_opt Whether an admin-stored (option) key exists.
*
* @return void
*/
private function render_key_form( bool $is_const, bool $is_set_opt ): void {
echo '<form method="post" action="' . esc_url( admin_url( 'admin-post.php' ) ) . '" id="prv-key-set-form" autocomplete="off">';
echo '<input type="hidden" name="action" value="prv_key_set">';
wp_nonce_field( PRV_Settings_Page::NONCE_KEY, 'prv_nonce' );
echo '<div class="prv-field" style="margin-top:12px">';
echo '<label class="prv-label" for="prv_api_key">';
echo esc_html(
$is_set_opt
? __( 'Replace key (leave empty to keep current)', 'pr-vision' )
: __( 'Set key', 'pr-vision' )
);
echo '</label>';
// Input always renders empty — never outputs stored key.
echo '<input type="password" id="prv_api_key" name="prv_api_key" '
. 'class="prv-input" style="max-width:420px" '
. 'value="" autocomplete="new-password" '
. 'placeholder="' . esc_attr( $is_const ? __( 'Managed via wp-config — cannot override', 'pr-vision' ) : __( 'sk-or-…', 'pr-vision' ) ) . '"'
. ( $is_const ? ' disabled aria-disabled="true"' : '' ) . '>';
if ( $is_const ) {
echo '<p style="color:var(--prv-text-muted);font-size:12px;margin:4px 0 0">'
. esc_html__( 'PRV_OPENROUTER_API_KEY is defined in wp-config.php and takes precedence. Remove it from wp-config to manage the key here.', 'pr-vision' )
. '</p>';
}
echo '</div>';
if ( ! $is_const ) {
echo '<button type="submit" class="prv-btn prv-btn-primary" style="margin-top:4px">'
. esc_html( $is_set_opt ? __( 'Replace key', 'pr-vision' ) : __( 'Set key', 'pr-vision' ) )
. '</button>';
}
echo '</form>';
// Remove key form (only when admin option is set, not constant).
if ( $is_set_opt && ! $is_const ) {
echo '<form method="post" action="' . esc_url( admin_url( 'admin-post.php' ) ) . '" id="prv-key-remove-form" style="margin-top:8px">';
echo '<input type="hidden" name="action" value="prv_key_remove">';
wp_nonce_field( PRV_Settings_Page::NONCE_KEY, 'prv_nonce' );
echo '<button type="submit" class="prv-btn prv-btn-danger" '
. 'onclick="return confirm(\'' . esc_js( __( 'Remove the stored API key? The next probe run will fail unless PRV_OPENROUTER_API_KEY is set in wp-config.php.', 'pr-vision' ) ) . '\')">'
. esc_html__( 'Remove key', 'pr-vision' )
. '</button>';
echo '</form>';
}
}
/**
* Render the Test key button and aria-live result area.
*
* Only shown when a key source is available. The test sends one cheap
* probe via AJAX and reports valid/invalid without leaking the key.
*
* @param string $source Current key source.
*
* @return void
*/
private function render_test_button( string $source ): void {
if ( PRV_Key_Store::SOURCE_NONE === $source ) {
return;
}
$nonce = wp_create_nonce( PRV_Settings_Page::NONCE_KEY_TEST );
$ajax_url = admin_url( 'admin-ajax.php' );
echo '<div style="margin-top:12px;display:flex;align-items:center;gap:12px">';
echo '<button type="button" id="prv-test-key-btn" class="prv-btn prv-btn-ghost" '
. 'data-ajax-url="' . esc_url( $ajax_url ) . '" '
. 'data-nonce="' . esc_attr( $nonce ) . '">'
. esc_html__( 'Test key', 'pr-vision' )
. '</button>';
echo '<span id="prv-test-key-result" aria-live="polite" role="status" style="font-size:13px"></span>';
echo '</div>';
// Inline JS — only a button wirer, no key value in JS.
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo '<script>(function(){
var btn=document.getElementById("prv-test-key-btn");
if(!btn)return;
btn.addEventListener("click",function(){
var res=document.getElementById("prv-test-key-result");
btn.disabled=true;btn.setAttribute("aria-busy","true");
if(res){res.textContent="Testing…";}
fetch(btn.dataset.ajaxUrl,{
method:"POST",
headers:{"Content-Type":"application/x-www-form-urlencoded"},
body:new URLSearchParams({action:"prv_test_key",prv_nonce:btn.dataset.nonce})
}).then(function(r){return r.json();}).then(function(d){
btn.disabled=false;btn.removeAttribute("aria-busy");
if(d.success){
if(res){res.textContent="✓ "+(d.data?d.data.message:"Key valid");res.style.color="var(--prv-lime)";}
}else{
if(res){res.textContent="✗ "+(d.data?d.data.message:"Key invalid");res.style.color="var(--prv-red-chip)";}
}
}).catch(function(){
btn.disabled=false;btn.removeAttribute("aria-busy");
if(res){res.textContent="Request error";res.style.color="var(--prv-red-chip)";}
});
});
})();</script>';
}
}

View File

@@ -0,0 +1,186 @@
<?php
/**
* PR Vision API key resolver and encrypted-at-rest key store.
*
* @package PrVision
*/
declare(strict_types=1);
/**
* Resolves and stores the OpenRouter API key with precedence and encryption.
*
* Precedence order (first match wins):
* 1. `PRV_OPENROUTER_API_KEY` wp-config constant (highest — most secure).
* 2. Encrypted admin option `prv_provider_key_enc`.
* 3. Empty string (no key configured).
*
* Encryption is delegated to PRV_Crypto_Helper (libsodium XSalsa20-Poly1305
* preferred; OpenSSL AES-256-GCM fallback). The encryption key is derived
* from WP salts (`AUTH_KEY` + `SECURE_AUTH_KEY`) via SHA-256 — environment-
* specific, never stored.
*
* Security invariants:
* - Plaintext key NEVER stored in any option, transient, log, or echoed.
* - Decryption occurs only at resolve time, server-side only.
* - Ciphertext purged on uninstall via the `prv_` wildcard DELETE.
*
* Who triggers: PRV_Perplexity_Provider, PRV_OpenRouter_Provider,
* PRV_Key_Test_Ajax.
* Dependencies: PRV_Crypto_Helper, wp_salt(), update_option(),
* delete_option(), get_option().
*
* @see class-prv-crypto-helper.php -- Low-level encrypt/decrypt.
* @see class-prv-perplexity-provider.php -- Calls get_key().
* @see class-prv-openrouter-provider.php -- Calls get_key().
* @see class-prv-key-test-ajax.php -- Calls get_key() for validation.
* @see class-prv-settings-renderer.php -- Calls get_source() for UI status.
* @see class-prv-settings-controller.php -- Calls store_key() / clear_key().
* @see ARCHITECTURE.md -- §Key management.
* @package PrVision
*/
class PRV_Key_Store {
/**
* WP option name for the encrypted key ciphertext.
*/
const OPTION_ENC = 'prv_provider_key_enc';
/**
* Source constant: key comes from wp-config constant.
*/
const SOURCE_CONSTANT = 'constant';
/**
* Source constant: key comes from the encrypted admin option.
*/
const SOURCE_OPTION = 'option';
/**
* Source constant: no key is configured.
*/
const SOURCE_NONE = 'none';
/**
* Resolve the API key following the precedence chain.
*
* Returns the plaintext key for server-side use ONLY. Callers MUST NOT
* pass this value to any client-facing surface (page, REST, log, exception).
*
* @return string Plaintext API key, or empty string when none is configured.
*/
public static function get_key(): string {
// 1. wp-config constant wins when defined and non-empty.
if ( self::constant_is_set() ) {
return (string) constant( 'PRV_OPENROUTER_API_KEY' );
}
// 2. Encrypted admin option.
$enc = get_option( self::OPTION_ENC, '' );
if ( is_string( $enc ) && '' !== $enc ) {
$enc_key = self::derive_encryption_key();
if ( '' !== $enc_key ) {
$decrypted = PRV_Crypto_Helper::decrypt( $enc, $enc_key );
if ( '' !== $decrypted ) {
return $decrypted;
}
}
}
return '';
}
/**
* Determine the source of the resolved key.
*
* Returns one of: SOURCE_CONSTANT, SOURCE_OPTION, SOURCE_NONE.
* Safe for UI output — never returns the key value itself.
*
* @return string One of the SOURCE_* class constants.
*/
public static function get_source(): string {
if ( self::constant_is_set() ) {
return self::SOURCE_CONSTANT;
}
$enc = get_option( self::OPTION_ENC, '' );
if ( is_string( $enc ) && '' !== $enc ) {
return self::SOURCE_OPTION;
}
return self::SOURCE_NONE;
}
/**
* Encrypt and store a new API key.
*
* Encrypts via PRV_Crypto_Helper and stores only the ciphertext. The
* plaintext is never persisted. Caller is responsible for capability and
* nonce checks before calling this method.
*
* Side effects: Writes to wp_options (`prv_provider_key_enc`).
*
* @param string $plaintext_key The raw API key to store.
*
* @return bool True on success, false if encryption fails or key is empty.
*/
public static function store_key( string $plaintext_key ): bool {
if ( '' === $plaintext_key ) {
return false;
}
$enc_key = self::derive_encryption_key();
if ( '' === $enc_key ) {
return false;
}
$enc = PRV_Crypto_Helper::encrypt( $plaintext_key, $enc_key );
if ( '' === $enc ) {
return false;
}
update_option( self::OPTION_ENC, $enc, false );
return true;
}
/**
* Delete the stored encrypted key (reverts to constant or none).
*
* Side effects: Deletes `prv_provider_key_enc` from wp_options.
*
* @return void
*/
public static function clear_key(): void {
delete_option( self::OPTION_ENC );
}
/**
* True when the PRV_OPENROUTER_API_KEY constant is defined and non-empty.
*
* @return bool
*/
public static function constant_is_set(): bool {
return defined( 'PRV_OPENROUTER_API_KEY' )
&& '' !== (string) constant( 'PRV_OPENROUTER_API_KEY' );
}
/**
* Derive a 32-byte raw binary encryption key from WP salts via SHA-256.
*
* Uses AUTH_KEY + SECURE_AUTH_KEY when defined; falls back to wp_salt().
* The derived key is site-specific and never persisted.
*
* @return string 32-byte raw binary key, or empty string when salts unavailable.
*/
public static function derive_encryption_key(): string {
$salt = '';
if ( defined( 'AUTH_KEY' ) && defined( 'SECURE_AUTH_KEY' ) ) {
$salt = AUTH_KEY . SECURE_AUTH_KEY;
} elseif ( function_exists( 'wp_salt' ) ) {
// wp_salt() is available after WP loads; safe in plugin context.
$salt = wp_salt( 'auth' ) . wp_salt( 'secure_auth' );
}
if ( '' === $salt ) {
return '';
}
return hash( 'sha256', $salt, true ); // Raw binary — exactly 32 bytes.
}
}

View File

@@ -0,0 +1,94 @@
<?php
/**
* AJAX handler: Test the resolved API key validity.
*
* @package PrVision
*/
declare(strict_types=1);
/**
* AJAX handler for the "Test key" button in the Provider API Key section.
*
* Resolves the API key via PRV_Key_Store::get_key() and performs a single
* cheap probe. Reports valid/invalid without revealing the key value.
* Rate-limited to one test per 30 seconds.
*
* Who triggers: wp_ajax_prv_test_key (registered by PRV_Settings_Page).
* Dependencies: PRV_Key_Store, PRV_OpenRouter_Provider, PRV_Gateway_Client.
*
* @see class-prv-key-store.php -- Key resolution.
* @see class-prv-settings-page.php -- Registers the wp_ajax_ hook.
* @see class-prv-settings-renderer.php -- Renders the "Test key" button.
* @package PrVision
*/
class PRV_Key_Test_Ajax {
/**
* Rate-limit window in seconds.
*/
const RATE_LIMIT_SECONDS = 30;
/**
* Transient key for the rate-limit lock.
*/
const RATE_LIMIT_TRANSIENT = 'prv_key_test_lock';
/**
* Cheap probe query — one call, minimal tokens.
*/
private const PROBE_QUERY = 'what is BPC-157';
/**
* Handle the prv_test_key AJAX request.
*
* Verifies manage_options + nonce, rate-limits, resolves the key, probes,
* and returns JSON success/error without ever including the key value.
*
* Side effects: HTTP call to LLM provider; sets a rate-limit 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_KEY_TEST ) ) {
wp_send_json_error( array( 'message' => 'Security check failed.' ) );
}
if ( false !== get_transient( self::RATE_LIMIT_TRANSIENT ) ) {
wp_send_json_error( array( 'message' => 'Rate limited — wait 30 s between tests.' ) );
}
set_transient( self::RATE_LIMIT_TRANSIENT, 1, self::RATE_LIMIT_SECONDS );
$api_key = PRV_Key_Store::get_key();
if ( '' === $api_key ) {
wp_send_json_error( array( 'message' => 'No API key configured.' ) );
}
$start = microtime( true );
$provider = new PRV_OpenRouter_Provider( 'openai/gpt-4o-mini', null, null );
try {
$provider->probe_with_key( self::PROBE_QUERY, $api_key );
$ms = (int) round( ( microtime( true ) - $start ) * 1000 );
wp_send_json_success(
array(
'valid' => true,
'latency_ms' => $ms,
'message' => sprintf( 'Key valid · %d ms', $ms ),
)
);
} catch ( \Exception $e ) {
wp_send_json_error(
array(
'valid' => false,
'message' => 'Key invalid or provider error — check key and try again.',
)
);
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,105 @@
<?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.
*
* Key is resolved via PRV_Key_Store::get_key() (constant → admin option → none).
*
* Who triggers: wp_ajax_prv_test_model (registered by PRV_Settings_Page).
* Dependencies: PRV_Key_Store, PRV_Model_Registry, PRV_Perplexity_Provider,
* PRV_OpenRouter_Provider.
*
* @see class-prv-settings-page.php -- Registers the wp_ajax_ hook.
* @see class-prv-key-store.php -- Key resolution.
* @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 );
// Resolve key through the store — never bypass to a direct constant read.
$api_key = PRV_Key_Store::get_key();
if ( '' === $api_key ) {
wp_send_json_error( array( 'message' => 'API key not configured — set it in wp-config.php or via Settings → Provider API Key.' ) );
}
$start = microtime( true );
$provider = ( 'perplexity/sonar' === $slug )
? new PRV_Perplexity_Provider()
: new PRV_OpenRouter_Provider( $slug );
try {
if ( $provider instanceof PRV_OpenRouter_Provider ) {
$provider->probe_with_key( 'what is BPC-157', $api_key );
} else {
$provider->probe( 'what is BPC-157' );
}
$ms = (int) round( ( microtime( true ) - $start ) * 1000 );
wp_send_json_success(
array(
'resolved' => true,
'latency_ms' => $ms,
'slug' => $slug,
'message' => sprintf( 'Resolved · %d ms', $ms ),
)
);
} catch ( \Exception $e ) {
wp_send_json_error(
array(
'resolved' => false,
'slug' => $slug,
'message' => 'Not resolved -- provider returned error (slug retired?)',
)
);
}
}
}

View File

@@ -0,0 +1,59 @@
<?php
/**
* Main orchestrator for the PR Vision plugin.
*
* @package PrVision
*/
declare(strict_types=1);
/**
* Main orchestrator for the PR Vision plugin.
*
* 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_Upgrader, PRV_Cron, PRV_Admin_Page, PRV_Settings_Page,
* PRV_Collector_Registry.
*
* @see ARCHITECTURE.md -- Boot sequence diagram.
* @see class-prv-upgrader.php -- Runs migrations on every boot.
* @package PrVision
*/
class PRV_Plugin {
/**
* Wire up all WordPress hooks.
*
* 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.
$registry = PRV_Collector_Registry::instance();
$registry->register_collector( new PRV_Ai_Visibility_Collector() );
$registry->register_panel( 'ai_visibility', new PRV_Ai_Visibility_Panel() );
}
}

View File

@@ -1,28 +1,36 @@
<?php
/**
* Immutable value object returned by every probe provider.
*
* @package PrVision
*/
declare(strict_types=1);
/**
* Value object: the result of a single LLM probe call.
*
* Returned by every PGM_Probe_Provider implementation. Immutable — all
* Returned by every PRV_Probe_Provider implementation. Immutable — all
* fields are set in the constructor and read via typed getters.
*
* Who triggers: PGM_Perplexity_Provider and PGM_OpenRouter_Provider.
* Who triggers: PRV_Perplexity_Provider and PRV_OpenRouter_Provider.
* Dependencies: None.
*
* @see interface-pgm-probe-provider.php — Provider interface that returns this.
* @see class-pgm-probe-runner.php — Consumes the result for storage.
* @see interface-prv-probe-provider.php — Provider interface that returns this.
* @see class-prv-probe-runner.php — Consumes the result for storage.
* @see CONTEXT.md — "probe result", "cited", "our_position".
* @package PeptideGeoMonitor
* @package PrVision
*/
class PGM_Probe_Result {
class PRV_Probe_Result {
/**
* @param string $raw_excerpt First ~500 chars of the LLM's response.
* @param string[] $source_domains Domains extracted from the provider's citations.
* @param bool $cited Whether peptiderepo.com appears in source_domains.
* @param int|null $our_position 1-based position of peptiderepo.com in sources, or null.
* @param float $cost_usd Estimated call cost in USD.
* Constructor.
*
* @param string $raw_excerpt First ~500 chars of the LLM's response.
* @param string[] $source_domains Domains extracted from the provider's citations.
* @param bool $cited Whether peptiderepo.com appears in source_domains.
* @param int|null $our_position 1-based position of peptiderepo.com in sources, or null.
* @param float $cost_usd Estimated call cost in USD.
*/
public function __construct(
private readonly string $raw_excerpt,
@@ -33,6 +41,8 @@ class PGM_Probe_Result {
) {}
/**
* Get the raw LLM response excerpt.
*
* @return string
*/
public function get_raw_excerpt(): string {
@@ -40,6 +50,8 @@ class PGM_Probe_Result {
}
/**
* Get the extracted source domains.
*
* @return string[]
*/
public function get_source_domains(): array {
@@ -47,6 +59,8 @@ class PGM_Probe_Result {
}
/**
* Whether peptiderepo.com was cited.
*
* @return bool
*/
public function is_cited(): bool {
@@ -54,6 +68,8 @@ class PGM_Probe_Result {
}
/**
* Get 1-based position of peptiderepo.com in source list.
*
* @return int|null 1-based position or null when not cited.
*/
public function get_our_position(): ?int {
@@ -61,6 +77,8 @@ class PGM_Probe_Result {
}
/**
* Get the estimated cost of this probe call.
*
* @return float Estimated cost in USD.
*/
public function get_cost_usd(): float {

View File

@@ -0,0 +1,300 @@
<?php
/**
* Orchestrates a full probe run across peptides, intents, and models.
*
* @package PrVision
*/
declare(strict_types=1);
/**
* Orchestrates a full probe run: peptides x intents x models.
*
* For each combination the runner:
* 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_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-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 {
/**
* Budget ledger for cost-cap enforcement.
*
* @var PRV_Cost_Ledger
*/
private PRV_Cost_Ledger $ledger;
/**
* Constructor.
*
* @param PRV_Cost_Ledger|null $ledger Injected for testing; auto-created otherwise.
*/
public function __construct( ?PRV_Cost_Ledger $ledger = null ) {
$this->ledger = $ledger ?? new PRV_Cost_Ledger();
}
/**
* Execute the full probe run.
*
* Acquires a run-lock so the cron and "Run now" cannot collide.
* Iterates peptides x prompt intents x models. Aborts at the budget cap
* without throwing -- partial results are kept. Each successful probe is
* persisted immediately so a mid-run failure doesn't lose data.
*
* Side effects: HTTP calls to LLM APIs, database writes, option writes (health).
*
* @return array{probed: int, skipped_budget: int, skipped_error: int, truncated: bool, run_id: string}
* Summary counts plus truncation flag for the admin display.
*/
public function run(): array {
$counts = array(
'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 );
foreach ( $models as $model ) {
$provider = $this->resolve_provider( $model );
if ( null === $provider || ! $provider->is_configured() ) {
++$counts['skipped_error'];
if ( isset( $model_outcomes[ $model ] ) ) {
++$model_outcomes[ $model ]['errors'];
}
continue;
}
// Budget pre-check.
$estimated = $this->get_estimated_cost( $model );
if ( ! $this->ledger->can_afford( $estimated ) ) {
++$counts['skipped_budget'];
$budget_hit = true;
continue;
}
try {
$result = $provider->probe( $query );
} catch ( \Exception $e ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( sprintf( 'PRV: probe failed [%s / %s]: %s', $model, $peptide['slug'], $e->getMessage() ) );
++$counts['skipped_error'];
if ( isset( $model_outcomes[ $model ] ) ) {
++$model_outcomes[ $model ]['errors'];
}
continue;
}
$row_id = $this->persist_result( $run_id, $peptide, $model, $intent_tpl, $result, $config_ver );
if ( $row_id > 0 ) {
$this->ledger->update_row_cost( $row_id, $result->get_cost_usd() );
}
++$counts['probed'];
if ( isset( $model_outcomes[ $model ] ) ) {
++$model_outcomes[ $model ]['probed'];
}
}
}
}
if ( $budget_hit && $counts['probed'] > 0 ) {
$counts['truncated'] = true;
}
// Record truncation state for dashboard display.
if ( $counts['truncated'] ) {
update_option( 'prv_last_run_truncated', 1 );
update_option( 'prv_last_run_truncated_at', current_time( 'mysql', true ) );
} else {
update_option( 'prv_last_run_truncated', 0 );
}
// Persist run summary for dashboard.
update_option( 'prv_last_run_at', current_time( 'mysql', true ) );
update_option( 'prv_last_run_counts', $counts );
// Update per-model run-health.
PRV_Model_Registry::update_health( $run_id, $model_outcomes );
// Update API-key status (derive from whether probes succeeded).
$this->update_api_key_status( $counts, $model_outcomes );
return $counts;
}
/**
* Persist a single probe result to the database with config_version stamp.
*
* @param string $run_id UUID for this run.
* @param array<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 $config_ver
): int {
global $wpdb;
$table = PRV_Table_Manager::get_table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->insert(
$table,
array(
'run_id' => $run_id,
'captured_at' => current_time( 'mysql', true ),
'peptide_slug' => $peptide['slug'],
'peptide_label' => $peptide['label'],
'model' => $model,
'prompt_intent' => $intent_tpl,
'cited' => $result->is_cited() ? 1 : 0,
'our_position' => $result->get_our_position(),
'source_domains' => wp_json_encode( $result->get_source_domains() ),
'raw_excerpt' => $result->get_raw_excerpt(),
'cost_usd' => number_format( $result->get_cost_usd(), 8, '.', '' ),
'config_version' => $config_ver,
),
array( '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%s', '%s', '%s', '%d' )
);
return (int) $wpdb->insert_id;
}
/**
* Derive + persist the API-key status from run outcomes.
*
* @param array<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.
*
* @param string $model OpenRouter model identifier.
*
* @return PRV_Probe_Provider|null Null when the model is unrecognised.
*/
private function resolve_provider( string $model ): ?PRV_Probe_Provider {
if ( 'perplexity/sonar' === $model ) {
return new PRV_Perplexity_Provider();
}
// All other models route through the generic OpenRouter provider.
return new PRV_OpenRouter_Provider( $model );
}
/**
* Conservative per-call cost estimate used for the budget pre-check.
*
* @param string $model Model identifier.
*
* @return float Estimated USD.
*/
private function get_estimated_cost( string $model ): float {
if ( 'perplexity/sonar' === $model ) {
return PRV_Perplexity_Provider::ESTIMATED_COST_PER_PROBE;
}
return PRV_OpenRouter_Provider::ESTIMATED_COST_PER_PROBE;
}
/**
* Generate a UUID v4 run identifier.
*
* @return string
*/
private function generate_run_id(): string {
return sprintf(
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
wp_rand( 0, 0xffff ),
wp_rand( 0, 0xffff ),
wp_rand( 0, 0xffff ),
wp_rand( 0, 0x0fff ) | 0x4000,
wp_rand( 0, 0x3fff ) | 0x8000,
wp_rand( 0, 0xffff ),
wp_rand( 0, 0xffff ),
wp_rand( 0, 0xffff )
);
}
}

View File

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

View File

@@ -0,0 +1,289 @@
<?php
/**
* PR Vision settings POST-action controller.
*
* @package PrVision
*/
declare(strict_types=1);
/**
* Handles the six settings POST actions: save config, model CRUD, key set/remove.
*
* Extracted from PRV_Settings_Page to keep each file under the 300-line
* limit. PRV_Settings_Page registers all hooks and delegates the 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).
*
* Key security invariants:
* - handle_key_set() reads the raw key from POST, encrypts it via PRV_Key_Store,
* and immediately drops the reference. The plaintext is never stored in an
* option, logged, or echoed.
* - handle_key_remove() calls PRV_Key_Store::clear_key() -- no plaintext involved.
* - The key value is NEVER included in any redirect URL or notice.
*
* Who triggers: PRV_Settings_Page -- via delegation in register_hooks().
* Dependencies: PRV_Config, PRV_Config_Version, PRV_Cron, PRV_Model_Registry,
* PRV_Key_Store.
*
* @see class-prv-settings-page.php -- Page registration, dispatch, Run-now, AJAX.
* @see class-prv-key-store.php -- Encryption and storage.
* @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.
$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 set/replace API key POST.
*
* Reads the raw key from POST, encrypts it via PRV_Key_Store, then
* drops the plaintext reference. Empty submission (no-op) is silently
* ignored. The key value NEVER appears in the redirect URL or notices.
*
* Side effects: Stores encrypted key in prv_provider_key_enc; redirects.
*
* @return void
*/
public function handle_key_set(): void {
( $this->nonce_cb )( PRV_Settings_Page::NONCE_KEY );
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce verified above.
$raw_key = isset( $_POST['prv_api_key'] )
? sanitize_text_field( wp_unslash( (string) $_POST['prv_api_key'] ) )
: '';
// phpcs:enable WordPress.Security.NonceVerification.Missing
$result_arg = 'prv_key_unchanged';
if ( '' !== $raw_key ) {
$stored = PRV_Key_Store::store_key( $raw_key );
$result_arg = $stored ? 'prv_key_set' : 'prv_key_error';
// Explicitly unset to prevent the plaintext lingering in memory.
unset( $raw_key );
}
wp_safe_redirect(
add_query_arg(
array(
'page' => self::MENU_SLUG,
$result_arg => 1,
),
admin_url( 'admin.php' )
)
);
exit;
}
/**
* Handle remove API key POST.
*
* Clears the encrypted option, reverting to constant or none.
*
* Side effects: Deletes prv_provider_key_enc; redirects.
*
* @return void
*/
public function handle_key_remove(): void {
( $this->nonce_cb )( PRV_Settings_Page::NONCE_KEY );
PRV_Key_Store::clear_key();
wp_safe_redirect(
add_query_arg(
array(
'page' => self::MENU_SLUG,
'prv_key_removed' => 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.
$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.
$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.
$id = sanitize_text_field( wp_unslash( (string) ( $_POST['prv_model_id'] ?? '' ) ) );
// phpcs:enable WordPress.Security.NonceVerification.Missing
if ( '' !== $id ) {
PRV_Model_Registry::remove( $id );
PRV_Config_Version::bump_version_if_changed();
}
wp_safe_redirect(
add_query_arg(
array(
'page' => self::MENU_SLUG,
'prv_model_removed' => 1,
),
admin_url( 'admin.php' )
)
);
exit;
}
}

View File

@@ -0,0 +1,213 @@
<?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/test-key,
* 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_Key_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-model handler.
* @see class-prv-key-test-ajax.php -- AJAX test-key handler.
* @see class-prv-key-store.php -- Key storage actions.
* @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';
/** Nonce action for key set/remove POST actions. */
const NONCE_KEY = 'prv_key_action';
/** Nonce action for Test key AJAX. */
const NONCE_KEY_TEST = 'prv_key_test';
/**
* Register WordPress admin hooks.
*
* POST handlers for save/model-CRUD/key 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_key_set', array( $ctrl, 'handle_key_set' ) );
add_action( 'admin_post_prv_key_remove', array( $ctrl, 'handle_key_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( 'wp_ajax_prv_test_key', array( $this, 'handle_test_key' ) );
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();
}
/**
* Dispatch the AJAX test-key request to PRV_Key_Test_Ajax.
*
* Side effects: Exits via PRV_Key_Test_Ajax::handle().
*
* @return void
*/
public function handle_test_key(): void {
( new PRV_Key_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 all POST handlers.
*
* @param string $action Nonce action.
*
* @return void
*/
public function require_admin_nonce( string $action ): void {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'Insufficient permissions.', 'pr-vision' ), 403 );
}
$nonce = isset( $_POST['prv_nonce'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['prv_nonce'] ) ) : '';
if ( ! wp_verify_nonce( $nonce, $action ) ) {
wp_die( esc_html__( 'Security check failed.', 'pr-vision' ), 403 );
}
}
}

View File

@@ -0,0 +1,291 @@
<?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, provider API key
* card (via PRV_Key_Manager_Renderer), 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,
* PRV_Key_Manager_Renderer.
*
* @see class-prv-settings-page.php -- Instantiates and calls render().
* @see class-prv-model-manager-table.php -- Renders the model table.
* @see class-prv-key-manager-renderer.php -- Renders the key card.
* @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();
( new PRV_Key_Manager_Renderer() )->render();
$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, key actions, 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>';
}
if ( ! empty( $_GET['prv_key_set'] ) ) {
echo '<div class="prv-notice prv-notice-success">' . esc_html__( 'API key saved.', 'pr-vision' ) . '</div>';
}
if ( ! empty( $_GET['prv_key_removed'] ) ) {
echo '<div class="prv-notice prv-notice-success">' . esc_html__( 'API key removed.', 'pr-vision' ) . '</div>';
}
if ( ! empty( $_GET['prv_key_error'] ) ) {
echo '<div class="prv-notice prv-notice-warning">' . esc_html__( 'Could not save API key — encryption failed. Check server logs.', 'pr-vision' ) . '</div>';
}
// phpcs:enable WordPress.Security.NonceVerification.Recommended
}
/**
* Render the secondary config form (cadence, budget, peptides, intents).
*
* @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();
echo '</div></form>';
}
/**
* Render cadence selector with next-run timestamp.
*
* @return void
*/
private function render_cadence_field(): void {
$cadence = PRV_Config::get_cadence();
$next_ts = PRV_Cron::next_run_timestamp();
$next_str = $next_ts ? gmdate( 'Y-m-d H:i T', (int) $next_ts ) : 'Not scheduled';
echo '<div class="prv-field"><label class="prv-label" for="prv_cadence">' . esc_html__( 'Probe cadence', 'pr-vision' ) . '</label>';
echo '<select class="prv-input" id="prv_cadence" name="prv_cadence" data-scoring-relevant="1" style="max-width:200px">';
$cadence_options = array(
'weekly' => 'Weekly',
'twicedaily' => 'Twice daily',
'daily' => 'Daily',
);
foreach ( $cadence_options as $v => $l ) {
echo '<option value="' . esc_attr( $v ) . '"' . selected( $cadence, $v, false ) . '>' . esc_html( $l ) . '</option>';
}
echo '</select>';
echo '<p style="color:var(--prv-text-muted);font-size:12px;margin:4px 0 0">' . esc_html__( 'Next run: ', 'pr-vision' ) . esc_html( $next_str ) . '</p></div>';
}
/**
* Render monthly budget cap input.
*
* @return void
*/
private function render_budget_field(): void {
$budget = PRV_Config::get_monthly_budget_usd();
echo '<div class="prv-field"><label class="prv-label" for="prv_monthly_budget_usd">' . esc_html__( 'Monthly budget cap (USD)', 'pr-vision' ) . '</label>';
echo '<input class="prv-input" style="max-width:160px;" type="number" min="0.01" step="0.01" id="prv_monthly_budget_usd" name="prv_monthly_budget_usd" value="' . esc_attr( number_format( $budget, 2, '.', '' ) ) . '" data-scoring-relevant="1"></div>';
}
/**
* Render live projected-cost inset panel.
*
* @return void
*/
private function render_projected_cost(): void {
$cost = PRV_Config::get_projected_cost();
$cls = $cost['over_cap'] ? 'prv-cost-inset over-cap' : 'prv-cost-inset';
echo '<div id="prv-cost-inset" class="' . esc_attr( $cls ) . '" aria-live="polite">';
echo '<strong>Projected cost:</strong> ~$' . esc_html( number_format( $cost['per_run_usd'], 4 ) ) . '/run &nbsp;·&nbsp; ';
echo '~$' . esc_html( number_format( $cost['per_month_usd'], 4 ) ) . '/month &nbsp;·&nbsp; ';
echo esc_html( (string) $cost['probe_count'] ) . ' probes/run';
if ( $cost['over_cap'] ) {
echo ' &nbsp;<strong>&#9888; Over cap — runs will truncate</strong>';
}
echo '</div>';
}
/**
* Render peptide set textarea (JSON).
*
* @return void
*/
private function render_peptides_field(): void {
$peptides = PRV_Config::get_peptides();
echo '<div class="prv-field"><label class="prv-label">' . esc_html__( 'Peptide set', 'pr-vision' ) . '</label>';
echo '<p style="color:var(--prv-text-muted);font-size:12px;margin:0 0 6px">' . esc_html( count( $peptides ) ) . ' peptides (JSON array of {slug, label}).</p>';
echo '<textarea class="prv-input" name="prv_peptides_json" rows="5" style="font-family:monospace;font-size:12px;" data-scoring-relevant="1">' . esc_textarea( (string) wp_json_encode( $peptides ) ) . '</textarea></div>';
}
/**
* Render prompt intents textarea.
*
* @return void
*/
private function render_intents_field(): void {
$intents = PRV_Config::get_prompt_intents();
echo '<div class="prv-field"><label class="prv-label" for="prv_prompt_intents_text">' . esc_html__( 'Prompt intents (one per line, use {peptide})', 'pr-vision' ) . '</label>';
echo '<textarea class="prv-input" id="prv_prompt_intents_text" name="prv_prompt_intents_text" rows="4" data-scoring-relevant="1">' . esc_textarea( implode( "\n", $intents ) ) . '</textarea></div>';
}
/**
* Render the sticky save-bar with scoring-change warning and Run now.
*
* @return void
*/
private function render_save_bar(): void {
echo '<div class="prv-savebar" id="prv-savebar">';
echo '<button type="submit" form="prv-config-form" class="prv-btn prv-btn-primary">' . esc_html__( 'Save settings', 'pr-vision' ) . '</button>';
echo '<span id="prv-savebar-warn" class="prv-savebar-warn" role="alert"></span>';
echo '<form method="post" action="' . esc_url( admin_url( 'admin-post.php' ) ) . '" id="prv-run-form" style="margin:0">';
echo '<input type="hidden" name="action" value="prv_settings_run_now">';
wp_nonce_field( PRV_Settings_Page::NONCE_RUN, 'prv_nonce' );
$locked = PRV_Run_Lock::is_locked();
echo '<button type="submit" id="prv-run-btn" class="prv-btn prv-btn-ghost"' . ( $locked ? ' disabled' : '' ) . '>';
echo $locked ? esc_html__( 'Run in progress…', 'pr-vision' ) : esc_html__( 'Run now', 'pr-vision' );
echo '</button>';
echo '</form>';
$next_ts = PRV_Cron::next_run_timestamp();
if ( $next_ts ) {
echo '<span style="color:var(--prv-text-muted);font-size:12px">' . esc_html__( 'Next auto-run: ', 'pr-vision' ) . esc_html( gmdate( 'Y-m-d H:i T', (int) $next_ts ) ) . '</span>';
}
echo '</div>';
}
/**
* Render inline JS: scoring-change warn, sort headers, Test AJAX, Run-now guard.
*
* @return void
*/
private function render_scripts(): void {
$ajax_url = admin_url( 'admin-ajax.php' );
$nonce = wp_create_nonce( PRV_Settings_Page::NONCE_TEST );
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo '<script>(function(){
var ajaxUrl=' . wp_json_encode( $ajax_url ) . ',testNonce=' . wp_json_encode( $nonce ) . ';
var runFired=false;
var rf=document.getElementById("prv-run-form");
if(rf){rf.addEventListener("submit",function(e){if(runFired){e.preventDefault();return;}runFired=true;var b=document.getElementById("prv-run-btn");if(b){b.disabled=true;b.textContent="Running…";}});}
document.querySelectorAll("[data-scoring-relevant]").forEach(function(f){f.addEventListener("change",function(){var w=document.getElementById("prv-savebar-warn");if(w){w.textContent="Scoring-relevant change — this breaks comparison with prior runs. A new config version will be recorded.";w.classList.add("visible");}var s=document.querySelector(".prv-savebar .prv-btn-primary");if(s){s.textContent="Save anyway";}});});
document.querySelectorAll("th[aria-sort] button").forEach(function(btn){btn.addEventListener("click",function(){var th=btn.closest("th"),table=th.closest("table"),idx=Array.from(th.parentElement.children).indexOf(th);var asc=th.getAttribute("aria-sort")!=="ascending";table.querySelectorAll("th[aria-sort]").forEach(function(h){h.setAttribute("aria-sort","none");});th.setAttribute("aria-sort",asc?"ascending":"descending");var tb=table.querySelector("tbody"),rows=Array.from(tb.querySelectorAll("tr"));rows.sort(function(a,b){var ac=(a.cells[idx]||{}).textContent||"",bc=(b.cells[idx]||{}).textContent||"";return asc?ac.localeCompare(bc):bc.localeCompare(ac);});rows.forEach(function(r){tb.appendChild(r);});});});
document.querySelectorAll(".prv-test-btn").forEach(function(btn){btn.addEventListener("click",function(){var mid=btn.dataset.modelId,chip=document.getElementById("prv-test-chip-"+mid);btn.disabled=true;btn.setAttribute("aria-busy","true");if(chip){chip.textContent="Testing…";chip.className="prv-badge prv-badge-unknown";}fetch(ajaxUrl,{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({action:"prv_test_model",model_id:mid,prv_nonce:testNonce})}).then(function(r){return r.json();}).then(function(d){btn.disabled=false;btn.removeAttribute("aria-busy");if(d.success){if(chip){chip.textContent="✓ "+d.data.message;chip.className="prv-badge prv-badge-healthy";}}else{if(chip){chip.textContent="✗ "+(d.data?d.data.message:"Failed");chip.className="prv-badge prv-badge-retired";}}}).catch(function(){btn.disabled=false;btn.removeAttribute("aria-busy");if(chip){chip.textContent="Error";chip.className="prv-badge prv-badge-retired";}});});});
})();</script>';
}
}

View File

@@ -0,0 +1,111 @@
<?php
/**
* Database table creation and schema versioning for PR Vision.
*
* @package PrVision
*/
declare(strict_types=1);
/**
* Database table creation and schema versioning for PR Vision.
*
* Creates {prefix}prv_ai_visibility via dbDelta (idempotent). Tracks the
* schema version in the prv_schema_version option so future migrations can
* detect when an upgrade is needed without destructive ALTER TABLE.
*
* 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-upgrader.php -- Calls create_table() on upgrade.
* @see ARCHITECTURE.md -- Section Storage.
* @package PrVision
*/
class PRV_Table_Manager {
/**
* Name of the custom table (without $wpdb->prefix).
*/
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.
*
* @return void
*/
public static function create_table(): void {
global $wpdb;
$table_name = $wpdb->prefix . self::TABLE_BASE;
$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,
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 config_version (config_version)
) {$charset_collate};";
if ( ! function_exists( 'dbDelta' ) ) {
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
}
dbDelta( $sql );
update_option( 'prv_schema_version', self::SCHEMA_VERSION );
}
/**
* Get the full table name including the WP prefix.
*
* @return string
*/
public static function get_table_name(): string {
global $wpdb;
return $wpdb->prefix . self::TABLE_BASE;
}
/**
* Drop the AI-visibility table entirely.
*
* Called only from uninstall.php -- never during normal deactivation.
*
* Side effects: Permanently destroys all probe data.
*
* @return void
*/
public static function drop_table(): void {
global $wpdb;
$table = $wpdb->prefix . self::TABLE_BASE;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->query( "DROP TABLE IF EXISTS {$table}" );
}
}

View File

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

View File

@@ -1,152 +0,0 @@
<?php
declare(strict_types=1);
/**
* AI-visibility dashboard panel — renders the GEO Monitor admin page.
*
* Renders four sections:
* 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.
*
* All output is escaped. Chart.js is enqueued via PGM_Admin_Page.
*
* Who triggers: PGM_Admin_Page::render_page() via PGM_Collector_Registry.
* Dependencies: PGM_Ai_Visibility_Collector (data format contract).
*
* @see interface-pgm-dashboard-panel.php — Interface this implements.
* @see class-pgm-ai-visibility-collector.php — Produces the $data array.
* @see class-pgm-admin-page.php — Enqueues Chart.js; calls render().
* @package PeptideGeoMonitor
*/
class PGM_Ai_Visibility_Panel implements PGM_Dashboard_Panel {
/**
* {@inheritDoc}
*
* @param array<string, mixed> $data Payload from PGM_Ai_Visibility_Collector.
*
* @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', 'peptide-geo-monitor' );
$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'] : PGM_DEFAULT_MONTHLY_BUDGET_USD;
$this->render_proxy_note();
$this->render_meta_bar( $last_run_at, $mtd_cost, $cap );
$this->render_trendline( $trendline );
$this->render_standings( $standings );
}
/**
* {@inheritDoc}
*/
public function get_title(): string {
return __( 'AI Visibility', 'peptide-geo-monitor' );
}
/**
* Render the proxy-metric disclaimer note.
*
* @return void
*/
private function render_proxy_note(): void {
echo '<div class="notice notice-info pgm-proxy-note"><p>';
echo '<strong>' . esc_html__( 'Note:', 'peptide-geo-monitor' ) . '</strong> ';
echo esc_html__( 'Scores are measured via API probes — a directional proxy, not the consumer ChatGPT/Gemini apps (different retrieval, personalisation, and system prompts).', 'peptide-geo-monitor' );
echo '</p></div>';
}
/**
* Render the run metadata bar (last run, cost, cap).
*
* @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.
*
* @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="pgm-meta-bar">';
echo '<span class="pgm-meta-item"><strong>' . esc_html__( 'Last run:', 'peptide-geo-monitor' ) . '</strong> ' . esc_html( $last_run_at ) . '</span>';
echo '<span class="pgm-meta-item"><strong>' . esc_html__( 'MTD cost:', 'peptide-geo-monitor' ) . '</strong> $' . esc_html( number_format( $mtd_cost, 4 ) ) . ' / $' . esc_html( number_format( $cap, 2 ) ) . ' (' . esc_html( $pct ) . '% of cap)</span>';
echo '</div>';
}
/**
* Render the Chart.js visibility score trendline.
*
* @param array<int, array{run_id: string, captured_at: string, score: float}> $trendline
*
* @return void
*/
private function render_trendline( array $trendline ): void {
echo '<div class="pgm-card"><h2>' . esc_html__( 'Visibility Score — Trend', 'peptide-geo-monitor' ) . '</h2>';
if ( empty( $trendline ) ) {
echo '<p>' . esc_html__( 'No probe runs recorded yet. Click "Run now" to collect the first data point.', 'peptide-geo-monitor' ) . '</p></div>';
return;
}
$labels = array();
$scores = array();
foreach ( $trendline as $point ) {
$labels[] = esc_js( date( 'M j', strtotime( $point['captured_at'] ) ) );
$scores[] = (float) $point['score'];
}
$labels_json = wp_json_encode( $labels );
$scores_json = wp_json_encode( $scores );
echo '<canvas id="pgm-trendline-chart" height="120"></canvas>';
echo '<script>document.addEventListener("DOMContentLoaded",function(){';
echo 'var ctx=document.getElementById("pgm-trendline-chart").getContext("2d");';
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>';
}
/**
* Render the per-peptide current standings table.
*
* @param array<string, array{label: string, cited: bool, our_position: int|null, top_domains: string[], model_count: int}> $standings
*
* @return void
*/
private function render_standings( array $standings ): void {
echo '<div class="pgm-card"><h2>' . esc_html__( 'Current Standings', 'peptide-geo-monitor' ) . '</h2>';
if ( empty( $standings ) ) {
echo '<p>' . esc_html__( 'No standings data yet.', 'peptide-geo-monitor' ) . '</p></div>';
return;
}
echo '<table class="wp-list-table widefat fixed striped pgm-standings">';
echo '<thead><tr>';
echo '<th>' . esc_html__( 'Peptide', 'peptide-geo-monitor' ) . '</th>';
echo '<th>' . esc_html__( 'Cited?', 'peptide-geo-monitor' ) . '</th>';
echo '<th>' . esc_html__( 'Our Position', 'peptide-geo-monitor' ) . '</th>';
echo '<th>' . esc_html__( 'Top Competing Domains', 'peptide-geo-monitor' ) . '</th>';
echo '</tr></thead><tbody>';
foreach ( $standings as $row ) {
$cited_icon = $row['cited'] ? '&#x2705;' : '&#x274C;';
$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
echo '<td>' . esc_html( $position ) . '</td>';
echo '<td>' . wp_kses_post( $domains ) . '</td>';
echo '</tr>';
}
echo '</tbody></table></div>';
}
}

View File

@@ -0,0 +1,238 @@
<?php
/**
* AI-visibility dashboard panel renderer.
*
* @package PrVision
*/
declare(strict_types=1);
/**
* AI-visibility dashboard panel — orchestrates the PR Vision admin page.
*
* Renders five sections by delegating the visual heavy-lifting to helper classes:
* 1. Proxy-metric disclaimer note.
* 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.
*
* 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), 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}
*
* @param array<string, mixed> $data Payload from PRV_Ai_Visibility_Collector.
*
* @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;
$last_run_counts = is_array( $data['last_run_counts'] ?? null ) ? $data['last_run_counts'] : array();
$this->render_proxy_note();
$this->render_bento_band( $trendline, $standings, $last_run_at, $mtd_cost, $cap, $last_run_counts );
$this->renderer->render_trendline( $trendline );
$this->render_standings( $standings );
}
/**
* {@inheritDoc}
*/
public function get_title(): string {
return __( 'AI Visibility', 'pr-vision' );
}
/**
* Render the proxy-metric disclaimer note.
*
* @return void
*/
private function render_proxy_note(): void {
echo '<div class="notice notice-info prv-proxy-note"><p>';
echo '<strong>' . esc_html__( 'Note:', 'pr-vision' ) . '</strong> ';
echo esc_html__( 'Scores are measured via API probes — a directional proxy, not the consumer ChatGPT/Gemini apps (different retrieval, personalisation, and system prompts).', 'pr-vision' );
echo '</p></div>';
}
/**
* Prepare bento-band data and delegate rendering to PRV_Dashboard_Renderer.
*
* @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_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 );
foreach ( $standings as $row ) {
if ( ! empty( $row['cited'] ) ) {
$cited_count++;
}
}
$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 );
$this->renderer->render_bento(
$current_score,
$prev_score,
$cited_count,
$total_count,
$health,
$mtd_cost,
$cap,
$pct,
$truncated,
$last_run_at,
$total_count
);
}
/**
* Render the per-peptide current standings table.
*
* @param array<string, array{label: string, cited: bool, our_position: int|null, top_domains: string[], model_count: int}> $standings Per-peptide standing data.
*
* @return void
*/
private function render_standings( array $standings ): void {
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></div>';
return;
}
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>';
echo '<th>' . esc_html__( 'Top Competing Domains', 'pr-vision' ) . '</th>';
echo '</tr></thead><tbody>';
foreach ( $standings as $row ) {
$is_cited = ! empty( $row['cited'] );
$cited_dot = $is_cited ? '&#x25CF;' : '&#x25CB;';
$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>';
// 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></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' ),
);
}
}

View 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">&#x2139;</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">&#x26A0;</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">&#x25B2; +' . esc_html( number_format( $diff, 2 ) ) . '%</div>';
}
if ( $diff < 0 ) {
return '<div class="prv-tile-delta prv-delta--down">&#x25BC; ' . esc_html( number_format( $diff, 2 ) ) . '%</div>';
}
return '<div class="prv-tile-delta prv-delta--flat">&#x2014; ' . esc_html__( 'no change', 'pr-vision' ) . '</div>';
}
}

View File

@@ -1,22 +1,28 @@
<?php
/**
* Contract for all PR Vision dashboard panels.
*
* @package PrVision
*/
declare(strict_types=1);
/**
* Seam: contract for all GEO-monitor dashboard panels.
* Seam: contract for all PR Vision dashboard panels.
*
* Each panel renders one data category within the GEO Monitor admin page.
* v1 ships only PGM_Ai_Visibility_Panel. Future panels (keyword rankings,
* schema coverage…) implement this and register with PGM_Collector_Registry.
* Each panel renders one data category within the PR Vision admin page.
* v1 ships only PRV_Ai_Visibility_Panel. Future panels (keyword rankings,
* schema coverage…) implement this and register with PRV_Collector_Registry.
*
* Who triggers: PGM_Admin_Page when building the GEO Monitor page.
* Who triggers: PRV_Admin_Page when building the PR Vision page.
* Dependencies: None — pure interface.
*
* @see class-pgm-ai-visibility-panel.php — v1 implementation.
* @see class-pgm-collector-registry.php — Registration hub.
* @see class-prv-ai-visibility-panel.php — v1 implementation.
* @see class-prv-collector-registry.php — Registration hub.
* @see ARCHITECTURE.md — §Collector/Panel seam.
* @package PeptideGeoMonitor
* @package PrVision
*/
interface PGM_Dashboard_Panel {
interface PRV_Dashboard_Panel {
/**
* Render the panel HTML into the current output buffer.

View File

@@ -1,160 +0,0 @@
<?php
declare(strict_types=1);
/**
* Generic OpenRouter provider — parameterised for GPT-search and Gemini-search.
*
* One class handles any OpenRouter-routed model. The model identifier is set
* at construction time; the runner instantiates one provider per model from
* PGM_Config::get_models().
*
* GPT-search (openai/gpt-4o-search-preview) and Gemini-search
* (google/gemini-2.0-flash-001) do not return a structured citations array
* via OpenRouter — citations are parsed from URLs embedded in the response
* text or from any 'annotations' key the model may include.
*
* Required WP-config constant:
* PGM_OPENROUTER_API_KEY — OpenRouter key (sk-or-…)
*
* Who triggers: PGM_Probe_Runner via PGM_Probe_Provider interface.
* Dependencies: PGM_Gateway_Client, PGM_Citation_Detector, PGM_Probe_Result.
*
* @see interface-pgm-probe-provider.php — Interface this implements.
* @see class-pgm-gateway-client.php — Shared HTTP + retry logic.
* @see class-pgm-citation-detector.php — Domain extraction + detection.
* @see ARCHITECTURE.md — §Provider implementations.
* @package PeptideGeoMonitor
*/
class PGM_OpenRouter_Provider implements PGM_Probe_Provider {
/**
* Estimated cost per probe call in USD (conservative cross-model default).
*/
const ESTIMATED_COST_PER_PROBE = 0.003;
/**
* @var string OpenRouter model identifier (e.g. "openai/gpt-4o-search-preview").
*/
private string $model;
/**
* @var PGM_Gateway_Client
*/
private PGM_Gateway_Client $gateway;
/**
* @var PGM_Citation_Detector
*/
private PGM_Citation_Detector $detector;
/**
* @param string $model OpenRouter model identifier.
* @param PGM_Gateway_Client|null $gateway Injected for testing.
* @param PGM_Citation_Detector|null $detector Injected for testing.
*/
public function __construct( string $model, ?PGM_Gateway_Client $gateway = null, ?PGM_Citation_Detector $detector = null ) {
$this->model = $model;
$this->gateway = $gateway ?? new PGM_Gateway_Client();
$this->detector = $detector ?? new PGM_Citation_Detector();
}
/**
* {@inheritDoc}
*
* @param string $query
* @return PGM_Probe_Result
* @throws \RuntimeException On permanent API failure.
*/
public function probe( string $query ): PGM_Probe_Result {
$api_key = $this->resolve_api_key();
$body = array(
'model' => $this->model,
'messages' => array(
array( 'role' => 'user', 'content' => $query ),
),
'max_tokens' => 512,
);
$data = $this->gateway->post_to_gateway( 'openrouter', $body, $api_key );
return $this->parse_response( $data );
}
/**
* Parse the OpenRouter response into a PGM_Probe_Result.
*
* Attempts to extract citations from:
* 1. `annotations` array (some models include structured source references).
* 2. URLs found inline in the response text (regex fallback).
*
* @param array<string, mixed> $data Decoded API response.
*
* @return PGM_Probe_Result
*/
public function parse_response( array $data ): PGM_Probe_Result {
$content = '';
if ( isset( $data['choices'][0]['message']['content'] ) ) {
$content = (string) $data['choices'][0]['message']['content'];
}
$raw_excerpt = mb_substr( $content, 0, 500 );
// 1. Prefer structured annotations when present.
$raw_citations = array();
if ( isset( $data['choices'][0]['message']['annotations'] ) && is_array( $data['choices'][0]['message']['annotations'] ) ) {
foreach ( $data['choices'][0]['message']['annotations'] as $ann ) {
if ( isset( $ann['url_citation']['url'] ) ) {
$raw_citations[] = $ann['url_citation']['url'];
}
}
}
// 2. Regex fallback: extract URLs from response text.
if ( empty( $raw_citations ) && '' !== $content ) {
preg_match_all( '#https?://[^\s\)\]\"\'<>]+#i', $content, $matches );
$raw_citations = $matches[0] ?? array();
}
$domains = $this->detector->parse_domains( $raw_citations );
$cited = $this->detector->is_cited( $domains );
$our_position = $this->detector->get_our_position( $domains );
// Cost estimation from usage data.
$cost_usd = self::ESTIMATED_COST_PER_PROBE;
if ( isset( $data['usage']['total_tokens'] ) ) {
// Rough blended rate ~$2/1M tokens across GPT-4o and Gemini Flash.
$cost_usd = (float) $data['usage']['total_tokens'] * 0.000002;
}
return new PGM_Probe_Result( $raw_excerpt, $domains, $cited, $our_position, $cost_usd );
}
/**
* {@inheritDoc}
*/
public function get_name(): string {
return 'OpenRouter/' . $this->model;
}
/**
* {@inheritDoc}
*/
public function is_configured(): bool {
return defined( 'PGM_OPENROUTER_API_KEY' ) && '' !== PGM_OPENROUTER_API_KEY;
}
/**
* Retrieve the OpenRouter API key from wp-config constants.
*
* @return string
* @throws \RuntimeException When the constant is not defined or empty.
*/
private function resolve_api_key(): string {
if ( ! defined( 'PGM_OPENROUTER_API_KEY' ) || '' === PGM_OPENROUTER_API_KEY ) {
throw new \RuntimeException(
__( 'PGM_OPENROUTER_API_KEY constant is not defined. Add it to wp-config.php.', 'peptide-geo-monitor' )
);
}
return PGM_OPENROUTER_API_KEY;
}
}

View File

@@ -1,149 +0,0 @@
<?php
declare(strict_types=1);
/**
* Perplexity sonar provider — primary citation signal.
*
* Routes through the Cloudflare AI Gateway (openrouter path) because
* OpenRouter proxies Perplexity models. Perplexity's sonar returns
* an explicit `citations` array — the most faithful citation signal
* available (real web retrieval vs. training-time knowledge).
*
* Required WP-config constant:
* PGM_OPENROUTER_API_KEY — OpenRouter key (sk-or-…)
*
* Who triggers: PGM_Probe_Runner via the PGM_Probe_Provider interface.
* Dependencies: PGM_Gateway_Client, PGM_Citation_Detector, PGM_Probe_Result.
*
* @see interface-pgm-probe-provider.php — Interface this implements.
* @see class-pgm-gateway-client.php — Shared HTTP + retry logic.
* @see class-pgm-citation-detector.php — Domain extraction + detection.
* @see ARCHITECTURE.md — §Provider implementations.
* @see CONTEXT.md — "Perplexity sonar", "citations".
* @package PeptideGeoMonitor
*/
class PGM_Perplexity_Provider implements PGM_Probe_Provider {
/**
* Model identifier passed to OpenRouter for Perplexity routing.
*/
const MODEL = 'perplexity/sonar';
/**
* Estimated cost per probe call in USD (sonar is ~$0.005 / 1k tokens).
* Used by the ledger's can_afford() pre-check. Actual cost settled post-call.
*/
const ESTIMATED_COST_PER_PROBE = 0.005;
/**
* @var PGM_Gateway_Client
*/
private PGM_Gateway_Client $gateway;
/**
* @var PGM_Citation_Detector
*/
private PGM_Citation_Detector $detector;
/**
* @param PGM_Gateway_Client|null $gateway Injected for testing; auto-created otherwise.
* @param PGM_Citation_Detector|null $detector Injected for testing; auto-created otherwise.
*/
public function __construct( ?PGM_Gateway_Client $gateway = null, ?PGM_Citation_Detector $detector = null ) {
$this->gateway = $gateway ?? new PGM_Gateway_Client();
$this->detector = $detector ?? new PGM_Citation_Detector();
}
/**
* {@inheritDoc}
*
* Sends the query as a user message to perplexity/sonar via OpenRouter.
* Parses the citations[] array from the OpenRouter response envelope.
*
* @param string $query
*
* @return PGM_Probe_Result
* @throws \RuntimeException On permanent API failure.
*/
public function probe( string $query ): PGM_Probe_Result {
$api_key = $this->resolve_api_key();
$body = array(
'model' => self::MODEL,
'messages' => array(
array( 'role' => 'user', 'content' => $query ),
),
'max_tokens' => 512,
);
$data = $this->gateway->post_to_gateway( 'openrouter', $body, $api_key );
return $this->parse_response( $data );
}
/**
* Parse the OpenRouter/Perplexity response into a PGM_Probe_Result.
*
* Side effects: None.
*
* @param array<string, mixed> $data Decoded API response.
*
* @return PGM_Probe_Result
*/
public function parse_response( array $data ): PGM_Probe_Result {
// Extract text excerpt from the first choice.
$content = '';
if ( isset( $data['choices'][0]['message']['content'] ) ) {
$content = (string) $data['choices'][0]['message']['content'];
}
$raw_excerpt = mb_substr( $content, 0, 500 );
// Perplexity returns citations at the top level of the response envelope.
$raw_citations = array();
if ( isset( $data['citations'] ) && is_array( $data['citations'] ) ) {
$raw_citations = $data['citations'];
}
$domains = $this->detector->parse_domains( $raw_citations );
$cited = $this->detector->is_cited( $domains );
$our_position = $this->detector->get_our_position( $domains );
// Cost from usage data when available; fall back to estimate.
$cost_usd = self::ESTIMATED_COST_PER_PROBE;
if ( isset( $data['usage']['total_tokens'] ) ) {
// Sonar: ~$1 per 1M input, $1 per 1M output tokens. Avg ~$0.001/1k.
$cost_usd = (float) $data['usage']['total_tokens'] * 0.000001;
}
return new PGM_Probe_Result( $raw_excerpt, $domains, $cited, $our_position, $cost_usd );
}
/**
* {@inheritDoc}
*/
public function get_name(): string {
return 'Perplexity sonar';
}
/**
* {@inheritDoc}
*/
public function is_configured(): bool {
return defined( 'PGM_OPENROUTER_API_KEY' ) && '' !== PGM_OPENROUTER_API_KEY;
}
/**
* Retrieve the OpenRouter API key from wp-config constants.
*
* @return string
* @throws \RuntimeException When the constant is not defined or empty.
*/
private function resolve_api_key(): string {
if ( ! defined( 'PGM_OPENROUTER_API_KEY' ) || '' === PGM_OPENROUTER_API_KEY ) {
throw new \RuntimeException(
__( 'PGM_OPENROUTER_API_KEY constant is not defined. Add it to wp-config.php.', 'peptide-geo-monitor' )
);
}
return PGM_OPENROUTER_API_KEY;
}
}

View File

@@ -1,4 +1,10 @@
<?php
/**
* Detects whether peptiderepo.com appears in LLM source citations.
*
* @package PrVision
*/
declare(strict_types=1);
/**
@@ -8,13 +14,13 @@ declare(strict_types=1);
* without duplication. Also parses raw citation arrays/URLs into a clean
* domain list.
*
* Who triggers: PGM_Perplexity_Provider, PGM_OpenRouter_Provider.
* Who triggers: PRV_Perplexity_Provider, PRV_OpenRouter_Provider.
* Dependencies: None (pure PHP).
*
* @see CONTEXT.md — "cited", "our_position", "source_domains".
* @package PeptideGeoMonitor
* @package PrVision
*/
class PGM_Citation_Detector {
class PRV_Citation_Detector {
/**
* Parse raw citation data from an API response into a clean domain list.
@@ -43,7 +49,8 @@ class PGM_Citation_Detector {
$host = (string) wp_parse_url( $url, PHP_URL_HOST );
if ( '' !== $host ) {
$domains[] = strtolower( ltrim( $host, 'www.' ) );
$bare = str_starts_with( $host, 'www.' ) ? substr( $host, 4 ) : $host;
$domains[] = strtolower( $bare );
}
}
@@ -51,18 +58,18 @@ class PGM_Citation_Detector {
}
/**
* Detect whether PGM_TARGET_DOMAIN appears in a domain list.
* Detect whether PRV_TARGET_DOMAIN appears in a domain list.
*
* @param string[] $domains Parsed domain list from parse_domains().
*
* @return bool
*/
public function is_cited( array $domains ): bool {
return in_array( PGM_TARGET_DOMAIN, $domains, true );
return in_array( PRV_TARGET_DOMAIN, $domains, true );
}
/**
* Find the 1-based position of PGM_TARGET_DOMAIN in a domain list.
* Find the 1-based position of PRV_TARGET_DOMAIN in a domain list.
*
* Returns null when the target domain is not found.
*
@@ -71,7 +78,7 @@ class PGM_Citation_Detector {
* @return int|null
*/
public function get_our_position( array $domains ): ?int {
$pos = array_search( PGM_TARGET_DOMAIN, $domains, true );
$pos = array_search( PRV_TARGET_DOMAIN, $domains, true );
if ( false === $pos ) {
return null;
}

View File

@@ -1,4 +1,10 @@
<?php
/**
* Cloudflare AI Gateway HTTP client shared by all providers.
*
* @package PrVision
*/
declare(strict_types=1);
/**
@@ -13,19 +19,19 @@ declare(strict_types=1);
* https://gateway.ai.cloudflare.com/v1/{account_id}/{gateway_id}/openrouter
*
* Required WP-config constants (documented in README.md):
* PGM_CF_ACCOUNT_ID — Cloudflare account ID
* PGM_CF_GATEWAY_ID — AI Gateway name (e.g. "peptiderepo-prod")
* PGM_OPENROUTER_API_KEY — OpenRouter API key (sk-or-…)
* PRV_CF_ACCOUNT_ID — Cloudflare account ID
* PRV_CF_GATEWAY_ID — AI Gateway name (e.g. "peptiderepo-prod")
* PRV_OPENROUTER_API_KEY — OpenRouter API key (sk-or-…)
*
* Who triggers: PGM_Perplexity_Provider, PGM_OpenRouter_Provider.
* Who triggers: PRV_Perplexity_Provider, PRV_OpenRouter_Provider.
* Dependencies: wp_remote_post(), add_action(), curl_setopt().
*
* @see class-pgm-perplexity-provider.php — Uses post_to_gateway().
* @see class-pgm-openrouter-provider.php — Uses post_to_gateway().
* @see class-prv-perplexity-provider.php — Uses post_to_gateway().
* @see class-prv-openrouter-provider.php — Uses post_to_gateway().
* @see ARCHITECTURE.md — §External API integrations.
* @package PeptideGeoMonitor
* @package PrVision
*/
class PGM_Gateway_Client {
class PRV_Gateway_Client {
/**
* Base URL for the Cloudflare AI Gateway (without trailing slash).
@@ -55,15 +61,15 @@ class PGM_Gateway_Client {
* @throws \RuntimeException On permanent HTTP failure or parse error.
*/
public function post_to_gateway( string $provider_slug, array $body, string $api_key, string $endpoint = '/chat/completions' ): array {
$base_url = $this->build_base_url( $provider_slug );
$url = $base_url . $endpoint;
$base_url = $this->build_base_url( $provider_slug );
$url = $base_url . $endpoint;
$base_host = (string) wp_parse_url( $base_url, PHP_URL_HOST );
$headers = array(
'Authorization' => 'Bearer ' . $api_key,
'Content-Type' => 'application/json',
'HTTP-Referer' => home_url(),
'X-Title' => 'Peptide GEO Monitor',
'X-Title' => 'PR Vision',
);
// Belt-and-suspenders cURL injection — some hosts strip Authorization.
@@ -72,11 +78,11 @@ class PGM_Gateway_Client {
$last_error = '';
try {
for ( $attempt = 1; $attempt <= PGM_MAX_RETRIES; $attempt++ ) {
for ( $attempt = 1; $attempt <= PRV_MAX_RETRIES; $attempt++ ) {
$response = wp_remote_post(
$url,
array(
'timeout' => PGM_API_TIMEOUT_SECONDS,
'timeout' => PRV_API_TIMEOUT_SECONDS,
'headers' => $headers,
'body' => wp_json_encode( $body ),
)
@@ -84,8 +90,8 @@ class PGM_Gateway_Client {
if ( is_wp_error( $response ) ) {
$last_error = $response->get_error_message();
if ( $attempt < PGM_MAX_RETRIES ) {
sleep( PGM_RETRY_BASE_DELAY_SECONDS * (int) pow( 2, $attempt - 1 ) );
if ( $attempt < PRV_MAX_RETRIES ) {
sleep( PRV_RETRY_BASE_DELAY_SECONDS * (int) pow( 2, $attempt - 1 ) );
}
continue;
}
@@ -95,23 +101,22 @@ class PGM_Gateway_Client {
if ( $status >= 500 || 429 === $status ) {
$last_error = sprintf( 'HTTP %d', $status );
if ( $attempt < PGM_MAX_RETRIES ) {
if ( $attempt < PRV_MAX_RETRIES ) {
$retry_after = wp_remote_retrieve_header( $response, 'retry-after' );
$delay = $retry_after ? min( (int) $retry_after, 60 ) : PGM_RETRY_BASE_DELAY_SECONDS * (int) pow( 2, $attempt - 1 );
$delay = $retry_after ? min( (int) $retry_after, 60 ) : PRV_RETRY_BASE_DELAY_SECONDS * (int) pow( 2, $attempt - 1 );
sleep( $delay );
}
continue;
}
if ( $status >= 400 ) {
throw new \RuntimeException(
sprintf( 'GEO Monitor gateway HTTP %d: %s', $status, substr( $raw_body, 0, 300 ) )
);
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception message, not HTML output
throw new \RuntimeException( sprintf( 'PR Vision gateway HTTP %d: %s', $status, substr( $raw_body, 0, 300 ) ) );
}
$data = json_decode( $raw_body, true );
if ( ! is_array( $data ) ) {
throw new \RuntimeException( 'GEO Monitor: invalid JSON response from gateway.' );
throw new \RuntimeException( 'PR Vision: invalid JSON response from gateway.' );
}
return $data;
@@ -120,9 +125,8 @@ class PGM_Gateway_Client {
remove_action( 'http_api_curl', $curl_filter, 99 );
}
throw new \RuntimeException(
sprintf( 'GEO Monitor: gateway failed after %d attempts. Last: %s', PGM_MAX_RETRIES, $last_error )
);
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception message, not HTML output
throw new \RuntimeException( sprintf( 'PR Vision: gateway failed after %d attempts. Last: %s', PRV_MAX_RETRIES, $last_error ) );
}
/**
@@ -133,13 +137,13 @@ class PGM_Gateway_Client {
* @return string Base URL without trailing slash.
*/
public function build_base_url( string $provider_slug ): string {
if ( defined( 'PGM_CF_ACCOUNT_ID' ) && defined( 'PGM_CF_GATEWAY_ID' )
&& '' !== PGM_CF_ACCOUNT_ID && '' !== PGM_CF_GATEWAY_ID ) {
if ( defined( 'PRV_CF_ACCOUNT_ID' ) && defined( 'PRV_CF_GATEWAY_ID' )
&& '' !== PRV_CF_ACCOUNT_ID && '' !== PRV_CF_GATEWAY_ID ) {
return sprintf(
'%s/%s/%s/%s',
self::GATEWAY_BASE,
PGM_CF_ACCOUNT_ID,
PGM_CF_GATEWAY_ID,
PRV_CF_ACCOUNT_ID,
PRV_CF_GATEWAY_ID,
$provider_slug
);
}

View File

@@ -0,0 +1,185 @@
<?php
/**
* Generic OpenRouter provider for GPT-search and Gemini-search models.
*
* @package PrVision
*/
declare(strict_types=1);
/**
* Generic OpenRouter provider — parameterised for GPT-search and Gemini-search.
*
* One class handles any OpenRouter-routed model. The model identifier is set
* at construction time; the runner instantiates one provider per model from
* PRV_Config::get_models().
*
* API key is resolved through PRV_Key_Store::get_key() (constant → admin
* option → none), never read directly from the constant.
*
* Who triggers: PRV_Probe_Runner via PRV_Probe_Provider interface.
* PRV_Key_Test_Ajax (probe_with_key for test path).
* Dependencies: PRV_Key_Store, PRV_Gateway_Client, PRV_Citation_Detector,
* PRV_Probe_Result.
*
* @see interface-prv-probe-provider.php — Interface this implements.
* @see class-prv-key-store.php — Single key resolver.
* @see class-prv-gateway-client.php — Shared HTTP + retry logic.
* @see class-prv-citation-detector.php — Domain extraction + detection.
* @see ARCHITECTURE.md — §Provider implementations, §Key management.
* @package PrVision
*/
class PRV_OpenRouter_Provider implements PRV_Probe_Provider {
/**
* Estimated cost per probe call in USD (conservative cross-model default).
*/
const ESTIMATED_COST_PER_PROBE = 0.003;
/**
* OpenRouter model identifier (e.g. "openai/gpt-4o-search-preview").
*
* @var string
*/
private string $model;
/**
* HTTP gateway client.
*
* @var PRV_Gateway_Client
*/
private PRV_Gateway_Client $gateway;
/**
* Citation domain detector.
*
* @var PRV_Citation_Detector
*/
private PRV_Citation_Detector $detector;
/**
* Constructor.
*
* @param string $model OpenRouter model identifier.
* @param PRV_Gateway_Client|null $gateway Injected for testing.
* @param PRV_Citation_Detector|null $detector Injected for testing.
*/
public function __construct( string $model, ?PRV_Gateway_Client $gateway = null, ?PRV_Citation_Detector $detector = null ) {
$this->model = $model;
$this->gateway = $gateway ?? new PRV_Gateway_Client();
$this->detector = $detector ?? new PRV_Citation_Detector();
}
/**
* {@inheritDoc}
*
* Resolves the key via PRV_Key_Store (constant → option → none).
*
* @param string $query The search query to probe.
*
* @return PRV_Probe_Result
* @throws \RuntimeException On permanent API failure or no key configured.
*/
public function probe( string $query ): PRV_Probe_Result {
$api_key = PRV_Key_Store::get_key();
if ( '' === $api_key ) {
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception, not HTML
throw new \RuntimeException( 'PRV_OPENROUTER_API_KEY is not configured. Set it in wp-config.php or via Settings.' );
}
return $this->probe_with_key( $query, $api_key );
}
/**
* Probe using a caller-supplied key (used by PRV_Key_Test_Ajax).
*
* Accepts the key as a parameter so the test path can call this without
* re-resolving from the store. The key is passed only to the gateway
* (as an Authorization header); it is never logged, stored, or returned.
*
* @param string $query The search query to probe.
* @param string $api_key Plaintext API key (server-side only; never exposed).
*
* @return PRV_Probe_Result
* @throws \RuntimeException On permanent API failure.
*/
public function probe_with_key( string $query, string $api_key ): PRV_Probe_Result {
$body = array(
'model' => $this->model,
'messages' => array(
array(
'role' => 'user',
'content' => $query,
),
),
'max_tokens' => 512,
);
$data = $this->gateway->post_to_gateway( 'openrouter', $body, $api_key );
return $this->parse_response( $data );
}
/**
* Parse the OpenRouter response into a PRV_Probe_Result.
*
* Attempts to extract citations from:
* 1. `annotations` array (some models include structured source references).
* 2. URLs found inline in the response text (regex fallback).
*
* @param array<string, mixed> $data Decoded API response.
*
* @return PRV_Probe_Result
*/
public function parse_response( array $data ): PRV_Probe_Result {
$content = '';
if ( isset( $data['choices'][0]['message']['content'] ) ) {
$content = (string) $data['choices'][0]['message']['content'];
}
$raw_excerpt = mb_substr( $content, 0, 500 );
// 1. Prefer structured annotations when present.
$raw_citations = array();
if ( isset( $data['choices'][0]['message']['annotations'] ) && is_array( $data['choices'][0]['message']['annotations'] ) ) {
foreach ( $data['choices'][0]['message']['annotations'] as $ann ) {
if ( isset( $ann['url_citation']['url'] ) ) {
$raw_citations[] = $ann['url_citation']['url'];
}
}
}
// 2. Regex fallback: extract URLs from response text.
if ( empty( $raw_citations ) && '' !== $content ) {
preg_match_all( '#https?://[^\s\)\]\"\'<>]+#i', $content, $matches );
$raw_citations = $matches[0] ?? array();
}
$domains = $this->detector->parse_domains( $raw_citations );
$cited = $this->detector->is_cited( $domains );
$our_position = $this->detector->get_our_position( $domains );
// Cost estimation from usage data.
$cost_usd = self::ESTIMATED_COST_PER_PROBE;
if ( isset( $data['usage']['total_tokens'] ) ) {
// Rough blended rate ~$2/1M tokens across GPT-4o and Gemini Flash.
$cost_usd = (float) $data['usage']['total_tokens'] * 0.000002;
}
return new PRV_Probe_Result( $raw_excerpt, $domains, $cited, $our_position, $cost_usd );
}
/**
* {@inheritDoc}
*/
public function get_name(): string {
return 'OpenRouter/' . $this->model;
}
/**
* {@inheritDoc}
*
* True when any source (constant or admin option) provides a non-empty key.
*/
public function is_configured(): bool {
return '' !== PRV_Key_Store::get_key();
}
}

View File

@@ -0,0 +1,154 @@
<?php
/**
* Perplexity sonar provider — primary citation signal.
*
* @package PrVision
*/
declare(strict_types=1);
/**
* Perplexity sonar provider — primary citation signal.
*
* Routes through the Cloudflare AI Gateway (openrouter path) because
* OpenRouter proxies Perplexity models. Perplexity's sonar returns
* an explicit `citations` array — the most faithful citation signal
* available (real web retrieval vs. training-time knowledge).
*
* API key is resolved through PRV_Key_Store::get_key() (constant → admin
* option → none), never read directly from the constant.
*
* Who triggers: PRV_Probe_Runner via the PRV_Probe_Provider interface.
* Dependencies: PRV_Key_Store, PRV_Gateway_Client, PRV_Citation_Detector,
* PRV_Probe_Result.
*
* @see interface-prv-probe-provider.php — Interface this implements.
* @see class-prv-key-store.php — Single key resolver.
* @see class-prv-gateway-client.php — Shared HTTP + retry logic.
* @see class-prv-citation-detector.php — Domain extraction + detection.
* @see ARCHITECTURE.md — §Provider implementations, §Key management.
* @see CONTEXT.md — "Perplexity sonar", "citations".
* @package PrVision
*/
class PRV_Perplexity_Provider implements PRV_Probe_Provider {
/**
* Model identifier passed to OpenRouter for Perplexity routing.
*/
const MODEL = 'perplexity/sonar';
/**
* Estimated cost per probe call in USD (sonar is ~$0.005 / 1k tokens).
*/
const ESTIMATED_COST_PER_PROBE = 0.005;
/**
* HTTP gateway client.
*
* @var PRV_Gateway_Client
*/
private PRV_Gateway_Client $gateway;
/**
* Citation domain detector.
*
* @var PRV_Citation_Detector
*/
private PRV_Citation_Detector $detector;
/**
* Constructor.
*
* @param PRV_Gateway_Client|null $gateway Injected for testing; auto-created otherwise.
* @param PRV_Citation_Detector|null $detector Injected for testing; auto-created otherwise.
*/
public function __construct( ?PRV_Gateway_Client $gateway = null, ?PRV_Citation_Detector $detector = null ) {
$this->gateway = $gateway ?? new PRV_Gateway_Client();
$this->detector = $detector ?? new PRV_Citation_Detector();
}
/**
* {@inheritDoc}
*
* Resolves the key via PRV_Key_Store (constant → option → none).
*
* @param string $query The search query to probe.
*
* @return PRV_Probe_Result
* @throws \RuntimeException On permanent API failure or no key configured.
*/
public function probe( string $query ): PRV_Probe_Result {
$api_key = PRV_Key_Store::get_key();
if ( '' === $api_key ) {
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception, not HTML
throw new \RuntimeException( 'PRV_OPENROUTER_API_KEY is not configured. Set it in wp-config.php or via Settings.' );
}
$body = array(
'model' => self::MODEL,
'messages' => array(
array(
'role' => 'user',
'content' => $query,
),
),
'max_tokens' => 512,
);
$data = $this->gateway->post_to_gateway( 'openrouter', $body, $api_key );
return $this->parse_response( $data );
}
/**
* Parse the OpenRouter/Perplexity response into a PRV_Probe_Result.
*
* Side effects: None.
*
* @param array<string, mixed> $data Decoded API response.
*
* @return PRV_Probe_Result
*/
public function parse_response( array $data ): PRV_Probe_Result {
// Extract text excerpt from the first choice.
$content = '';
if ( isset( $data['choices'][0]['message']['content'] ) ) {
$content = (string) $data['choices'][0]['message']['content'];
}
$raw_excerpt = mb_substr( $content, 0, 500 );
// Perplexity returns citations at the top level of the response envelope.
$raw_citations = array();
if ( isset( $data['citations'] ) && is_array( $data['citations'] ) ) {
$raw_citations = $data['citations'];
}
$domains = $this->detector->parse_domains( $raw_citations );
$cited = $this->detector->is_cited( $domains );
$our_position = $this->detector->get_our_position( $domains );
// Cost from usage data when available; fall back to estimate.
$cost_usd = self::ESTIMATED_COST_PER_PROBE;
if ( isset( $data['usage']['total_tokens'] ) ) {
$cost_usd = (float) $data['usage']['total_tokens'] * 0.000001;
}
return new PRV_Probe_Result( $raw_excerpt, $domains, $cited, $our_position, $cost_usd );
}
/**
* {@inheritDoc}
*/
public function get_name(): string {
return 'Perplexity sonar';
}
/**
* {@inheritDoc}
*
* True when any source (constant or admin option) provides a non-empty key.
*/
public function is_configured(): bool {
return '' !== PRV_Key_Store::get_key();
}
}

View File

@@ -1,4 +1,10 @@
<?php
/**
* Contract for all LLM probe providers.
*
* @package PrVision
*/
declare(strict_types=1);
/**
@@ -8,34 +14,34 @@ declare(strict_types=1);
* OpenRouter GPT-search, OpenRouter Gemini-search, …). Swapping providers
* requires only a new class — the runner, ledger, and storage are agnostic.
*
* Who triggers: PGM_Probe_Runner for each (model, query) combination.
* Who triggers: PRV_Probe_Runner for each (model, query) combination.
* Dependencies: None — pure interface.
*
* @see class-pgm-perplexity-provider.php — Perplexity sonar implementation.
* @see class-pgm-openrouter-provider.php — OpenRouter generic implementation.
* @see class-pgm-probe-runner.php — Dispatches to providers via this interface.
* @see class-prv-perplexity-provider.php — Perplexity sonar implementation.
* @see class-prv-openrouter-provider.php — OpenRouter generic implementation.
* @see class-prv-probe-runner.php — Dispatches to providers via this interface.
* @see CONTEXT.md — "provider", "probe".
* @package PeptideGeoMonitor
* @package PrVision
*/
interface PGM_Probe_Provider {
interface PRV_Probe_Provider {
/**
* Send a search/retrieval query and return a structured probe result.
*
* Implementations MUST:
* - Extract cited source domains from the model's response.
* - Detect whether PGM_TARGET_DOMAIN appears in those sources.
* - Detect whether PRV_TARGET_DOMAIN appears in those sources.
* - Record our 1-based position (null when not cited).
* - Return the call's estimated USD cost.
* - Throw \RuntimeException on unrecoverable failure (caller logs + skips).
*
* @param string $query The natural-language query to send.
*
* @return PGM_Probe_Result
* @return PRV_Probe_Result
*
* @throws \RuntimeException When the API call fails after retries.
*/
public function probe( string $query ): PGM_Probe_Result;
public function probe( string $query ): PRV_Probe_Result;
/**
* Get a short human-readable name for this provider (e.g. "Perplexity sonar").

View File

@@ -1,68 +0,0 @@
<?php
/**
* Plugin Name: Peptide GEO Monitor
* 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.0
* Author: peptiderepo
* Author URI: https://peptiderepo.com
* License: GPL-2.0-or-later
* Text Domain: peptide-geo-monitor
* 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.
*/
declare(strict_types=1);
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/* ── Constants ────────────────────────────────────────────────────────── */
define( 'PGM_VERSION', '0.1.0' );
define( 'PGM_PLUGIN_FILE', __FILE__ );
define( 'PGM_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
define( 'PGM_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
define( 'PGM_SCHEMA_VERSION', 1 );
/** @var int Maximum HTTP retries for LLM API calls. */
define( 'PGM_MAX_RETRIES', 3 );
/** @var int API request timeout in seconds. */
define( 'PGM_API_TIMEOUT_SECONDS', 60 );
/** @var int Base backoff delay (seconds) between retries. */
define( 'PGM_RETRY_BASE_DELAY_SECONDS', 2 );
/** @var float Default monthly budget cap in USD. */
define( 'PGM_DEFAULT_MONTHLY_BUDGET_USD', 5.0 );
/** @var string WP-Cron hook name for the weekly probe run. */
define( 'PGM_CRON_HOOK', 'pgm_weekly_probe' );
/** @var string The site we are tracking citations for. */
define( 'PGM_TARGET_DOMAIN', 'peptiderepo.com' );
/* ── Autoloader ───────────────────────────────────────────────────────── */
require_once PGM_PLUGIN_DIR . 'includes/core/class-pgm-autoloader.php';
PGM_Autoloader::register();
/* ── Activation / Deactivation ────────────────────────────────────────── */
register_activation_hook( __FILE__, array( 'PGM_Activator', 'activate' ) );
register_deactivation_hook( __FILE__, array( 'PGM_Deactivator', 'deactivate' ) );
/* ── Boot ──────────────────────────────────────────────────────────────── */
add_action(
'plugins_loaded',
static function (): void {
$plugin = new PGM_Plugin();
$plugin->init();
}
);

View File

@@ -1,6 +1,6 @@
<?xml version="1.0"?>
<ruleset name="Peptide GEO Monitor">
<description>WordPress coding standards for Peptide GEO Monitor.</description>
<ruleset name="PR Vision">
<description>WordPress coding standards for PR Vision.</description>
<file>.</file>
@@ -9,8 +9,6 @@
<exclude-pattern>/tests/*</exclude-pattern>
<arg name="extensions" value="php"/>
<arg name="severity" value="1"/>
<arg name="warning-severity" value="0"/>
<arg value="sp"/>
<rule ref="WordPress"/>

69
pr-vision.php Normal file
View File

@@ -0,0 +1,69 @@
<?php
/**
* Plugin Name: PR Vision
* Plugin URI: https://peptiderepo.com
* Description: Weekly server-side LLM probes for AI-visibility (GEO) tracking. Records whether peptiderepo.com is cited by LLMs across core peptides, stores time-series, and renders an admin dashboard + settings UI.
* Version: 0.2.3
* 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.
* @package PrVision
*/
declare(strict_types=1);
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/* ── Constants ────────────────────────────────────────────────────────── */
define( 'PRV_VERSION', '0.2.3' );
define( 'PRV_PLUGIN_FILE', __FILE__ );
define( 'PRV_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
define( 'PRV_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
define( 'PRV_SCHEMA_VERSION', 2 );
/** Maximum HTTP retries for LLM API calls. @var int */
define( 'PRV_MAX_RETRIES', 3 );
/** API request timeout in seconds. @var int */
define( 'PRV_API_TIMEOUT_SECONDS', 60 );
/** Base backoff delay (seconds) between retries. @var int */
define( 'PRV_RETRY_BASE_DELAY_SECONDS', 2 );
/** Default monthly budget cap in USD. @var float */
define( 'PRV_DEFAULT_MONTHLY_BUDGET_USD', 5.0 );
/** WP-Cron hook name for the weekly probe run. @var string */
define( 'PRV_CRON_HOOK', 'prv_weekly_probe' );
/** The site we are tracking citations for. @var string */
define( 'PRV_TARGET_DOMAIN', 'peptiderepo.com' );
/* ── Autoloader ───────────────────────────────────────────────────────── */
require_once PRV_PLUGIN_DIR . 'includes/core/class-prv-autoloader.php';
PRV_Autoloader::register();
/* ── Activation / Deactivation ────────────────────────────────────────── */
register_activation_hook( __FILE__, array( 'PRV_Activator', 'activate' ) );
register_deactivation_hook( __FILE__, array( 'PRV_Deactivator', 'deactivate' ) );
/* ── Boot ──────────────────────────────────────────────────────────────── */
add_action(
'plugins_loaded',
static function (): void {
$plugin = new PRV_Plugin();
$plugin->init();
}
);

View File

@@ -1,11 +1,11 @@
<?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).
*
* @package PeptideGeoMonitor
* @package PrVision
*/
declare(strict_types=1);
@@ -16,22 +16,34 @@ if ( ! defined( 'ABSPATH' ) ) {
define( 'ABSPATH', __DIR__ . '/' );
}
define( 'PGM_VERSION', '0.1.0' );
define( 'PGM_PLUGIN_FILE', __DIR__ . '/../peptide-geo-monitor.php' );
define( 'PGM_PLUGIN_DIR', realpath( __DIR__ . '/..' ) . '/' );
define( 'PGM_PLUGIN_URL', 'http://example.test/wp-content/plugins/peptide-geo-monitor/' );
define( 'PGM_SCHEMA_VERSION', 1 );
define( 'PGM_MAX_RETRIES', 3 );
define( 'PGM_API_TIMEOUT_SECONDS', 60 );
define( 'PGM_RETRY_BASE_DELAY_SECONDS', 2 );
define( 'PGM_DEFAULT_MONTHLY_BUDGET_USD', 5.0 );
define( 'PGM_CRON_HOOK', 'pgm_weekly_probe' );
define( 'PGM_TARGET_DOMAIN', 'peptiderepo.com' );
// 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.3' );
define( 'PRV_PLUGIN_FILE', __DIR__ . '/../pr-vision.php' );
define( 'PRV_PLUGIN_DIR', realpath( __DIR__ . '/..' ) . '/' );
define( 'PRV_PLUGIN_URL', 'http://example.test/wp-content/plugins/pr-vision/' );
define( 'PRV_SCHEMA_VERSION', 2 );
define( 'PRV_MAX_RETRIES', 3 );
define( 'PRV_API_TIMEOUT_SECONDS', 60 );
define( 'PRV_RETRY_BASE_DELAY_SECONDS', 2 );
define( 'PRV_DEFAULT_MONTHLY_BUDGET_USD', 5.0 );
define( 'PRV_CRON_HOOK', 'prv_weekly_probe' );
define( 'PRV_TARGET_DOMAIN', 'peptiderepo.com' );
/* ── Global test state ─────────────────────────────────────────────────── */
$GLOBALS['pgm_test_state'] = [
$GLOBALS['prv_test_state'] = [
'options' => [],
'transients' => [],
'actions' => [],
'wpdb_insert_id' => 1,
'wpdb_results' => [],
@@ -40,9 +52,10 @@ $GLOBALS['pgm_test_state'] = [
'remote_posts' => [],
];
function pgm_test_reset(): void {
$GLOBALS['pgm_test_state'] = [
function prv_test_reset(): void {
$GLOBALS['prv_test_state'] = [
'options' => [],
'transients' => [],
'actions' => [],
'wpdb_insert_id' => 1,
'wpdb_results' => [],
@@ -50,52 +63,78 @@ function pgm_test_reset(): void {
'cron_events' => [],
'remote_posts' => [],
];
PGM_Collector_Registry::reset_for_testing();
PRV_Collector_Registry::reset_for_testing();
}
/* ── WordPress function stubs ──────────────────────────────────────────── */
function get_option( string $name, $default = false ) {
return $GLOBALS['pgm_test_state']['options'][ $name ] ?? $default;
return $GLOBALS['prv_test_state']['options'][ $name ] ?? $default;
}
function update_option( string $name, $value ): bool {
$GLOBALS['pgm_test_state']['options'][ $name ] = $value;
$GLOBALS['prv_test_state']['options'][ $name ] = $value;
return true;
}
function add_option( string $name, $value = '' ): bool {
if ( ! isset( $GLOBALS['pgm_test_state']['options'][ $name ] ) ) {
$GLOBALS['pgm_test_state']['options'][ $name ] = $value;
if ( ! isset( $GLOBALS['prv_test_state']['options'][ $name ] ) ) {
$GLOBALS['prv_test_state']['options'][ $name ] = $value;
}
return true;
}
function add_action( string $hook, $callback, int $priority = 10, int $accepted_args = 1 ): bool {
$GLOBALS['pgm_test_state']['actions'][] = compact( 'hook', 'callback', 'priority' );
function delete_option( string $name ): bool {
unset( $GLOBALS['prv_test_state']['options'][ $name ] );
return true;
}
function remove_action( string $hook, $callback, int $priority = 10 ): bool {
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 add_menu_page( ...$args ): void {}
function add_submenu_page( ...$args ): void {}
function wp_schedule_event( int $timestamp, string $recurrence, string $hook ): void {
$GLOBALS['pgm_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['pgm_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 {
unset( $GLOBALS['pgm_test_state']['cron_events'][ $hook ] );
unset( $GLOBALS['prv_test_state']['cron_events'][ $hook ] );
}
function wp_clear_scheduled_hook( string $hook ): void {
unset( $GLOBALS['pgm_test_state']['cron_events'][ $hook ] );
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 {
@@ -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['pgm_test_state']['remote_posts'];
if ( ! empty( $queue ) ) {
return array_shift( $queue );
}
$queue = &$GLOBALS['prv_test_state']['remote_posts'];
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';
@@ -220,18 +232,14 @@ class stdClass_wpdb {
}
public function query( string $sql ): bool { return true; }
public function insert( string $table, array $data, array $format = [] ): int {
$this->insert_id = $GLOBALS['pgm_test_state']['wpdb_insert_id']++;
return 1;
}
public function update( string $table, array $data, array $where, array $data_format = [], array $where_format = [] ): int {
$this->insert_id = $GLOBALS['prv_test_state']['wpdb_insert_id']++;
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['pgm_test_state']['wpdb_results'] ) ? $GLOBALS['pgm_test_state']['wpdb_results'] : [];
}
public function get_var( string $query ): mixed {
return $GLOBALS['pgm_test_state']['wpdb_var'];
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']; }
}
$wpdb = new stdClass_wpdb();
@@ -240,42 +248,40 @@ function dbDelta( string $sql ): array { return []; }
/* ── Test assertion helpers ────────────────────────────────────────────── */
$GLOBALS['pgm_test_report'] = [ 'pass' => 0, 'fail' => 0, 'failures' => [] ];
$GLOBALS['prv_test_report'] = [ 'pass' => 0, 'fail' => 0, 'failures' => [] ];
function pgm_assert( bool $condition, string $label ): void {
function prv_assert( bool $condition, string $label ): void {
if ( $condition ) {
$GLOBALS['pgm_test_report']['pass']++;
$GLOBALS['prv_test_report']['pass']++;
echo " PASS: {$label}\n";
} else {
$GLOBALS['pgm_test_report']['fail']++;
$GLOBALS['pgm_test_report']['failures'][] = $label;
$GLOBALS['prv_test_report']['fail']++;
$GLOBALS['prv_test_report']['failures'][] = $label;
echo " FAIL: {$label}\n";
}
}
function pgm_assert_equals( $expected, $actual, string $label ): void {
function prv_assert_equals( $expected, $actual, string $label ): void {
$pass = ( $expected === $actual );
pgm_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 pgm_assert_throws( callable $fn, string $exception_class, string $label ): void {
function prv_assert_throws( callable $fn, string $exception_class, string $label ): void {
try {
$fn();
pgm_assert( false, $label . ' — expected ' . $exception_class . ' but no exception thrown' );
prv_assert( false, $label . ' -- expected ' . $exception_class . ' but no exception thrown' );
} catch ( \Throwable $e ) {
pgm_assert( $e instanceof $exception_class, $label . ' — got ' . get_class( $e ) );
prv_assert( $e instanceof $exception_class, $label . ' -- got ' . get_class( $e ) );
}
}
function pgm_test_summary(): int {
$r = $GLOBALS['pgm_test_report'];
function prv_test_summary(): int {
$r = $GLOBALS['prv_test_report'];
echo "\n---\n";
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;
@@ -283,8 +289,8 @@ function pgm_test_summary(): int {
/* ── Load autoloader + all plugin classes ──────────────────────────────── */
require_once __DIR__ . '/../includes/core/class-pgm-autoloader.php';
PGM_Autoloader::register();
require_once __DIR__ . '/../includes/providers/interface-pgm-probe-provider.php';
require_once __DIR__ . '/../includes/collector/interface-pgm-data-collector.php';
require_once __DIR__ . '/../includes/panel/interface-pgm-dashboard-panel.php';
require_once __DIR__ . '/../includes/core/class-prv-autoloader.php';
PRV_Autoloader::register();
require_once __DIR__ . '/../includes/providers/interface-prv-probe-provider.php';
require_once __DIR__ . '/../includes/collector/interface-prv-data-collector.php';
require_once __DIR__ . '/../includes/panel/interface-prv-dashboard-panel.php';

View File

@@ -1,74 +1,74 @@
<?php
/**
* Tests for PGM_Citation_Detector: domain extraction and cite detection.
* Tests for PRV_Citation_Detector: domain extraction and cite detection.
*
* @package PeptideGeoMonitor
* @package PrVision
*/
declare(strict_types=1);
require_once __DIR__ . '/../bootstrap.php';
echo "=== PGM Citation Detector Tests ===\n";
echo "=== PRV Citation Detector Tests ===\n";
// ── Helpers ──────────────────────────────────────────────────────────────
$detector = new PGM_Citation_Detector();
$detector = new PRV_Citation_Detector();
// ── Test: parse plain URL strings ────────────────────────────────────────
pgm_test_reset();
prv_test_reset();
$domains = $detector->parse_domains( [
'https://peptiderepo.com/bpc-157/',
'https://www.examine.com/supplements/bpc-157/',
'https://pubmed.ncbi.nlm.nih.gov/12345',
] );
pgm_assert( in_array( 'peptiderepo.com', $domains, true ), 'parse_domains: peptiderepo.com extracted from URL' );
pgm_assert( in_array( 'examine.com', $domains, true ), 'parse_domains: www. stripped from examine.com' );
pgm_assert( in_array( 'pubmed.ncbi.nlm.nih.gov', $domains, true ), 'parse_domains: full subdomain preserved when not www.' );
pgm_assert_equals( 3, count( $domains ), 'parse_domains: exactly 3 unique domains' );
prv_assert( in_array( 'peptiderepo.com', $domains, true ), 'parse_domains: peptiderepo.com extracted from URL' );
prv_assert( in_array( 'examine.com', $domains, true ), 'parse_domains: www. stripped from examine.com' );
prv_assert( in_array( 'pubmed.ncbi.nlm.nih.gov', $domains, true ), 'parse_domains: full subdomain preserved when not www.' );
prv_assert_equals( 3, count( $domains ), 'parse_domains: exactly 3 unique domains' );
// ── Test: parse object-style citations (Perplexity format) ───────────────
pgm_test_reset();
prv_test_reset();
$domains = $detector->parse_domains( [
[ 'url' => 'https://peptiderepo.com/tb-500/' ],
[ 'url' => 'https://examine.com/tb500' ],
[ 'url' => 'https://peptiderepo.com/bpc-157/' ], // duplicate domain
] );
pgm_assert_equals( 2, count( $domains ), 'parse_domains: object-style citations, duplicates removed' );
pgm_assert( in_array( 'peptiderepo.com', $domains, true ), 'parse_domains: object-style, peptiderepo.com present' );
prv_assert_equals( 2, count( $domains ), 'parse_domains: object-style citations, duplicates removed' );
prv_assert( in_array( 'peptiderepo.com', $domains, true ), 'parse_domains: object-style, peptiderepo.com present' );
// ── Test: parse empty input ──────────────────────────────────────────────
pgm_test_reset();
prv_test_reset();
$domains = $detector->parse_domains( [] );
pgm_assert_equals( [], $domains, 'parse_domains: empty input returns empty array' );
prv_assert_equals( [], $domains, 'parse_domains: empty input returns empty array' );
// ── Test: is_cited detection ─────────────────────────────────────────────
pgm_test_reset();
pgm_assert( $detector->is_cited( ['peptiderepo.com', 'examine.com'] ), 'is_cited: returns true when target present' );
pgm_assert( ! $detector->is_cited( ['examine.com', 'pubmed.ncbi.nlm.nih.gov'] ), 'is_cited: returns false when target absent' );
pgm_assert( ! $detector->is_cited( [] ), 'is_cited: returns false for empty list' );
prv_test_reset();
prv_assert( $detector->is_cited( ['peptiderepo.com', 'examine.com'] ), 'is_cited: returns true when target present' );
prv_assert( ! $detector->is_cited( ['examine.com', 'pubmed.ncbi.nlm.nih.gov'] ), 'is_cited: returns false when target absent' );
prv_assert( ! $detector->is_cited( [] ), 'is_cited: returns false for empty list' );
// ── Test: get_our_position ───────────────────────────────────────────────
pgm_test_reset();
prv_test_reset();
$domains = ['examine.com', 'peptiderepo.com', 'pubmed.ncbi.nlm.nih.gov'];
pgm_assert_equals( 2, $detector->get_our_position( $domains ), 'get_our_position: 1-based, position 2' );
prv_assert_equals( 2, $detector->get_our_position( $domains ), 'get_our_position: 1-based, position 2' );
$domains_first = ['peptiderepo.com', 'examine.com'];
pgm_assert_equals( 1, $detector->get_our_position( $domains_first ), 'get_our_position: position 1 when first' );
prv_assert_equals( 1, $detector->get_our_position( $domains_first ), 'get_our_position: position 1 when first' );
$domains_absent = ['examine.com', 'pubmed.ncbi.nlm.nih.gov'];
pgm_assert_equals( null, $detector->get_our_position( $domains_absent ), 'get_our_position: null when not present' );
prv_assert_equals( null, $detector->get_our_position( $domains_absent ), 'get_our_position: null when not present' );
// ── Test: malformed / unexpected input ───────────────────────────────────
pgm_test_reset();
prv_test_reset();
$domains = $detector->parse_domains( [
'not-a-url',
42,
@@ -76,6 +76,21 @@ $domains = $detector->parse_domains( [
['no_url_key' => 'foo'],
['url' => ''],
] );
pgm_assert_equals( [], $domains, 'parse_domains: gracefully skips malformed items' );
prv_assert_equals( [], $domains, 'parse_domains: gracefully skips malformed items' );
exit( pgm_test_summary() );
// ── Test: P2-A — www-strip must not corrupt non-www domains starting with 'w' ──
prv_test_reset();
$domains = $detector->parse_domains( [
'https://wikipedia.org/wiki/bpc-157',
'https://www.examine.com/bpc-157/',
'https://webmd.com/drug/bpc-157',
] );
prv_assert( in_array( 'wikipedia.org', $domains, true ), 'P2-A: wikipedia.org survives intact (no char-mask corruption)' );
prv_assert( in_array( 'examine.com', $domains, true ), 'P2-A: www.examine.com correctly stripped to examine.com' );
prv_assert( in_array( 'webmd.com', $domains, true ), 'P2-A: webmd.com survives intact (no char-mask corruption)' );
prv_assert_equals( 3, count( $domains ), 'P2-A: exactly 3 unique domains from 3 distinct URLs' );
exit( prv_test_summary() );

View File

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

View File

@@ -1,85 +1,85 @@
<?php
/**
* Tests for PGM_Cost_Ledger: MTD cost retrieval, budget pre-check, and
* Tests for PRV_Cost_Ledger: MTD cost retrieval, budget pre-check, and
* the budget-cap abort behaviour.
*
* @package PeptideGeoMonitor
* @package PrVision
*/
declare(strict_types=1);
require_once __DIR__ . '/../bootstrap.php';
echo "=== PGM Cost Ledger Tests ===\n";
echo "=== PRV Cost Ledger Tests ===\n";
// ── Test: can_afford when below cap ─────────────────────────────────────
pgm_test_reset();
$GLOBALS['pgm_test_state']['options']['pgm_monthly_budget_usd'] = 5.0;
$GLOBALS['pgm_test_state']['wpdb_var'] = '2.50'; // MTD spent
prv_test_reset();
$GLOBALS['prv_test_state']['options']['prv_monthly_budget_usd'] = 5.0;
$GLOBALS['prv_test_state']['wpdb_var'] = '2.50'; // MTD spent
$ledger = new PGM_Cost_Ledger();
pgm_assert( $ledger->can_afford( 1.0 ), 'can_afford: 2.50 spent + 1.00 = 3.50 < 5.00 → true' );
$ledger = new PRV_Cost_Ledger();
prv_assert( $ledger->can_afford( 1.0 ), 'can_afford: 2.50 spent + 1.00 = 3.50 < 5.00 → true' );
// ── Test: can_afford when at cap ─────────────────────────────────────────
pgm_test_reset();
$GLOBALS['pgm_test_state']['options']['pgm_monthly_budget_usd'] = 5.0;
$GLOBALS['pgm_test_state']['wpdb_var'] = '5.00';
prv_test_reset();
$GLOBALS['prv_test_state']['options']['prv_monthly_budget_usd'] = 5.0;
$GLOBALS['prv_test_state']['wpdb_var'] = '5.00';
$ledger = new PGM_Cost_Ledger();
pgm_assert( ! $ledger->can_afford( 0.01 ), 'can_afford: 5.00 spent + 0.01 > 5.00 → false' );
$ledger = new PRV_Cost_Ledger();
prv_assert( ! $ledger->can_afford( 0.01 ), 'can_afford: 5.00 spent + 0.01 > 5.00 → false' );
// ── Test: can_afford when exactly at cap ─────────────────────────────────
pgm_test_reset();
$GLOBALS['pgm_test_state']['options']['pgm_monthly_budget_usd'] = 5.0;
$GLOBALS['pgm_test_state']['wpdb_var'] = '4.99';
prv_test_reset();
$GLOBALS['prv_test_state']['options']['prv_monthly_budget_usd'] = 5.0;
$GLOBALS['prv_test_state']['wpdb_var'] = '4.99';
$ledger = new PGM_Cost_Ledger();
pgm_assert( $ledger->can_afford( 0.01 ), 'can_afford: 4.99 + 0.01 = 5.00 exactly → true (at cap is allowed)' );
$ledger = new PRV_Cost_Ledger();
prv_assert( $ledger->can_afford( 0.01 ), 'can_afford: 4.99 + 0.01 = 5.00 exactly → true (at cap is allowed)' );
// ── Test: get_remaining_budget_usd ──────────────────────────────────────
pgm_test_reset();
$GLOBALS['pgm_test_state']['options']['pgm_monthly_budget_usd'] = 5.0;
$GLOBALS['pgm_test_state']['wpdb_var'] = '3.25';
prv_test_reset();
$GLOBALS['prv_test_state']['options']['prv_monthly_budget_usd'] = 5.0;
$GLOBALS['prv_test_state']['wpdb_var'] = '3.25';
$ledger = new PGM_Cost_Ledger();
pgm_assert_equals( 1.75, round( $ledger->get_remaining_budget_usd(), 2 ), 'get_remaining_budget_usd: 5.00 - 3.25 = 1.75' );
$ledger = new PRV_Cost_Ledger();
prv_assert_equals( 1.75, round( $ledger->get_remaining_budget_usd(), 2 ), 'get_remaining_budget_usd: 5.00 - 3.25 = 1.75' );
// ── Test: get_remaining_budget_usd never negative ────────────────────────
pgm_test_reset();
$GLOBALS['pgm_test_state']['options']['pgm_monthly_budget_usd'] = 5.0;
$GLOBALS['pgm_test_state']['wpdb_var'] = '10.00'; // over cap
prv_test_reset();
$GLOBALS['prv_test_state']['options']['prv_monthly_budget_usd'] = 5.0;
$GLOBALS['prv_test_state']['wpdb_var'] = '10.00'; // over cap
$ledger = new PGM_Cost_Ledger();
pgm_assert_equals( 0.0, $ledger->get_remaining_budget_usd(), 'get_remaining_budget_usd: clamps to 0.0 when over cap' );
$ledger = new PRV_Cost_Ledger();
prv_assert_equals( 0.0, $ledger->get_remaining_budget_usd(), 'get_remaining_budget_usd: clamps to 0.0 when over cap' );
// ── Test: get_month_to_date_usd with null DB result (no rows yet) ────────
pgm_test_reset();
$GLOBALS['pgm_test_state']['options']['pgm_monthly_budget_usd'] = 5.0;
$GLOBALS['pgm_test_state']['wpdb_var'] = null;
prv_test_reset();
$GLOBALS['prv_test_state']['options']['prv_monthly_budget_usd'] = 5.0;
$GLOBALS['prv_test_state']['wpdb_var'] = null;
$ledger = new PGM_Cost_Ledger();
pgm_assert_equals( 0.0, $ledger->get_month_to_date_usd(), 'get_month_to_date_usd: null DB result → 0.0' );
$ledger = new PRV_Cost_Ledger();
prv_assert_equals( 0.0, $ledger->get_month_to_date_usd(), 'get_month_to_date_usd: null DB result → 0.0' );
// ── Test: update_row_cost returns true ──────────────────────────────────
pgm_test_reset();
$ledger = new PGM_Cost_Ledger();
pgm_assert( $ledger->update_row_cost( 42, 0.00123456 ), 'update_row_cost: returns true on success' );
prv_test_reset();
$ledger = new PRV_Cost_Ledger();
prv_assert( $ledger->update_row_cost( 42, 0.00123456 ), 'update_row_cost: returns true on success' );
// ── Test: budget abort mid-run (runner stops when cap hit) ──────────────
pgm_test_reset();
prv_test_reset();
// Simulate a nearly-exhausted budget.
$GLOBALS['pgm_test_state']['options']['pgm_monthly_budget_usd'] = 0.001; // $0.001 cap
$GLOBALS['pgm_test_state']['wpdb_var'] = '0.001'; // fully spent
$GLOBALS['prv_test_state']['options']['prv_monthly_budget_usd'] = 0.001; // $0.001 cap
$GLOBALS['prv_test_state']['wpdb_var'] = '0.001'; // fully spent
$ledger2 = new PGM_Cost_Ledger();
pgm_assert( ! $ledger2->can_afford( 0.000001 ), 'budget abort: can_afford returns false at cap → runner would stop' );
$ledger2 = new PRV_Cost_Ledger();
prv_assert( ! $ledger2->can_afford( 0.000001 ), 'budget abort: can_afford returns false at cap → runner would stop' );
exit( pgm_test_summary() );
exit( prv_test_summary() );

View File

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

View File

@@ -1,64 +1,64 @@
<?php
/**
* Tests for PGM_Cron: scheduling, clearing, and registration.
* Tests for PRV_Cron: scheduling, clearing, and registration.
*
* @package PeptideGeoMonitor
* @package PrVision
*/
declare(strict_types=1);
require_once __DIR__ . '/../bootstrap.php';
echo "=== PGM Cron Tests ===\n";
echo "=== PRV Cron Tests ===\n";
// ── Test: schedule_weekly adds the event ────────────────────────────────
pgm_test_reset();
pgm_assert( ! PGM_Cron::is_scheduled(), 'schedule_weekly: not scheduled before call' );
prv_test_reset();
prv_assert( ! PRV_Cron::is_scheduled(), 'schedule_weekly: not scheduled before call' );
PGM_Cron::schedule_weekly();
pgm_assert( PGM_Cron::is_scheduled(), 'schedule_weekly: event scheduled after call' );
PRV_Cron::schedule_weekly();
prv_assert( PRV_Cron::is_scheduled(), 'schedule_weekly: event scheduled after call' );
// ── Test: schedule_weekly is idempotent ──────────────────────────────────
PGM_Cron::schedule_weekly(); // Second call should no-op.
$events = $GLOBALS['pgm_test_state']['cron_events'];
pgm_assert_equals( 1, count( $events ), 'schedule_weekly: idempotent — second call does not add duplicate' );
PRV_Cron::schedule_weekly(); // Second call should no-op.
$events = $GLOBALS['prv_test_state']['cron_events'];
prv_assert_equals( 1, count( $events ), 'schedule_weekly: idempotent — second call does not add duplicate' );
// ── Test: clear_schedule removes the event ──────────────────────────────
PGM_Cron::clear_schedule();
pgm_assert( ! PGM_Cron::is_scheduled(), 'clear_schedule: event removed after clear' );
PRV_Cron::clear_schedule();
prv_assert( ! PRV_Cron::is_scheduled(), 'clear_schedule: event removed after clear' );
// ── Test: clear_schedule is safe when nothing scheduled ─────────────────
pgm_test_reset();
PGM_Cron::clear_schedule(); // Should not throw.
pgm_assert( ! PGM_Cron::is_scheduled(), 'clear_schedule: safe when nothing scheduled' );
prv_test_reset();
PRV_Cron::clear_schedule(); // Should not throw.
prv_assert( ! PRV_Cron::is_scheduled(), 'clear_schedule: safe when nothing scheduled' );
// ── Test: register_hooks registers the cron hook action ─────────────────
pgm_test_reset();
$cron = new PGM_Cron();
prv_test_reset();
$cron = new PRV_Cron();
$cron->register_hooks();
$hooks = array_column( $GLOBALS['pgm_test_state']['actions'], 'hook' );
pgm_assert( in_array( PGM_CRON_HOOK, $hooks, true ), 'register_hooks: pgm_weekly_probe action registered' );
$hooks = array_column( $GLOBALS['prv_test_state']['actions'], 'hook' );
prv_assert( in_array( PRV_CRON_HOOK, $hooks, true ), 'register_hooks: prv_weekly_probe action registered' );
// ── Test: hook constant is correct string ────────────────────────────────
pgm_assert_equals( 'pgm_weekly_probe', PGM_CRON_HOOK, 'PGM_CRON_HOOK: constant value correct' );
prv_assert_equals( 'prv_weekly_probe', PRV_CRON_HOOK, 'PRV_CRON_HOOK: constant value correct' );
// ── Test: PGM_Activator calls schedule_weekly ────────────────────────────
// ── Test: PRV_Activator calls schedule_weekly ────────────────────────────
pgm_test_reset();
prv_test_reset();
// Seed options so seed_defaults() doesn't fail.
PGM_Activator::activate();
pgm_assert( PGM_Cron::is_scheduled(), 'PGM_Activator::activate: weekly cron scheduled after activation' );
PRV_Activator::activate();
prv_assert( PRV_Cron::is_scheduled(), 'PRV_Activator::activate: weekly cron scheduled after activation' );
// ── Test: PGM_Deactivator calls clear_schedule ───────────────────────────
// ── Test: PRV_Deactivator calls clear_schedule ───────────────────────────
PGM_Deactivator::deactivate();
pgm_assert( ! PGM_Cron::is_scheduled(), 'PGM_Deactivator::deactivate: cron cleared after deactivation' );
PRV_Deactivator::deactivate();
prv_assert( ! PRV_Cron::is_scheduled(), 'PRV_Deactivator::deactivate: cron cleared after deactivation' );
exit( pgm_test_summary() );
exit( prv_test_summary() );

View 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() );

View File

@@ -0,0 +1,182 @@
<?php
/**
* Tests for PRV_Key_Store and PRV_Crypto_Helper:
* encrypt/decrypt round-trip, resolver precedence, write-only render, Remove.
*
* @package PrVision
*/
declare(strict_types=1);
require_once __DIR__ . '/../bootstrap.php';
// Provide deterministic WP-salt constants so derive_encryption_key() works.
if ( ! defined( 'AUTH_KEY' ) ) {
define( 'AUTH_KEY', 'test-auth-key-at-least-64-chars-long-for-realistic-entropy-aaaa' );
}
if ( ! defined( 'SECURE_AUTH_KEY' ) ) {
define( 'SECURE_AUTH_KEY', 'test-secure-auth-key-64-chars-long-realistic-entropy-bbbb-cccc' );
}
if ( ! function_exists( 'wp_salt' ) ) {
function wp_salt( string $scheme ): string {
return 'static-test-salt-' . $scheme;
}
}
/* ── Load classes under test ────────────────────────────────────────────── */
require_once PRV_PLUGIN_DIR . 'includes/core/class-prv-crypto-helper.php';
require_once PRV_PLUGIN_DIR . 'includes/core/class-prv-key-store.php';
/* ── Helpers ────────────────────────────────────────────────────────────── */
/**
* Capture rendered HTML from a callable.
*
* @param callable $fn Function that produces output.
*
* @return string Captured HTML.
*/
function prv_capture( callable $fn ): string {
ob_start();
$fn();
return (string) ob_get_clean();
}
echo "=== PRV_Key_Store + PRV_Crypto_Helper tests ===\n\n";
/* ─────────────────────────────────────────────────────────────────────────
* Group 1: PRV_Crypto_Helper — encrypt/decrypt round-trip
* ──────────────────────────────────────────────────────────────────────── */
echo "Group 1: PRV_Crypto_Helper encrypt/decrypt round-trip\n";
// Derive a test enc key the same way PRV_Key_Store does.
$enc_key = PRV_Key_Store::derive_encryption_key();
prv_assert( 32 === strlen( $enc_key ), 'derive_encryption_key() returns 32-byte key' );
$plaintext = 'sk-or-test-key-value-abc123';
$encrypted = PRV_Crypto_Helper::encrypt( $plaintext, $enc_key );
prv_assert( '' !== $encrypted, 'encrypt() returns non-empty string' );
prv_assert( $plaintext !== $encrypted, 'encrypt() does not return plaintext' );
prv_assert( false === strpos( $encrypted, $plaintext ), 'Plaintext not visible in ciphertext' );
$decrypted = PRV_Crypto_Helper::decrypt( $encrypted, $enc_key );
prv_assert_equals( $plaintext, $decrypted, 'decrypt(encrypt(key)) === original key' );
// Different calls produce different ciphertexts (random nonce).
$encrypted2 = PRV_Crypto_Helper::encrypt( $plaintext, $enc_key );
prv_assert( $encrypted !== $encrypted2, 'Two encryptions differ (random nonce)' );
prv_assert_equals( $plaintext, PRV_Crypto_Helper::decrypt( $encrypted2, $enc_key ), 'Second ciphertext also decrypts correctly' );
// Malformed inputs are rejected gracefully.
prv_assert_equals( '', PRV_Crypto_Helper::decrypt( '', $enc_key ), 'decrypt("") returns empty' );
prv_assert_equals( '', PRV_Crypto_Helper::decrypt( 'garbage', $enc_key ), 'decrypt(garbage) returns empty' );
prv_assert_equals( '', PRV_Crypto_Helper::decrypt( 'aabb:', $enc_key ), 'decrypt(empty cipher) returns empty' );
// Wrong key decrypts to empty (not wrong plaintext).
$wrong_key = hash( 'sha256', 'wrong-salt', true );
prv_assert_equals( '', PRV_Crypto_Helper::decrypt( $encrypted, $wrong_key ), 'decrypt with wrong key returns empty' );
/* ─────────────────────────────────────────────────────────────────────────
* Group 2: PRV_Key_Store — store_key / clear_key / get_source
* ──────────────────────────────────────────────────────────────────────── */
echo "\nGroup 2: store_key / clear_key / get_source\n";
prv_test_reset();
prv_assert_equals( PRV_Key_Store::SOURCE_NONE, PRV_Key_Store::get_source(), 'get_source() == NONE when no key stored' );
$stored = PRV_Key_Store::store_key( $plaintext );
prv_assert( $stored, 'store_key() returns true on success' );
$option_val = get_option( PRV_Key_Store::OPTION_ENC, '' );
prv_assert( '' !== $option_val, 'Option is set after store_key()' );
prv_assert( $plaintext !== $option_val, 'Option value is NOT the plaintext key (encrypted at rest)' );
prv_assert( false === strpos( (string) $option_val, $plaintext ), 'Plaintext not in stored option value' );
prv_assert( false === strpos( (string) $option_val, 'sk-or-' ), 'Key prefix not in stored option' );
prv_assert_equals( PRV_Key_Store::SOURCE_OPTION, PRV_Key_Store::get_source(), 'get_source() == OPTION after store_key()' );
PRV_Key_Store::clear_key();
prv_assert_equals( '', get_option( PRV_Key_Store::OPTION_ENC, '' ), 'Option cleared after clear_key()' );
prv_assert_equals( PRV_Key_Store::SOURCE_NONE, PRV_Key_Store::get_source(), 'get_source() == NONE after clear_key()' );
// store_key('') is a no-op.
prv_assert( ! PRV_Key_Store::store_key( '' ), 'store_key("") returns false' );
prv_assert_equals( '', get_option( PRV_Key_Store::OPTION_ENC, '' ), 'Option unchanged after store_key("")' );
/* ─────────────────────────────────────────────────────────────────────────
* Group 3: Resolver precedence — constant > option > none
* ──────────────────────────────────────────────────────────────────────── */
echo "\nGroup 3: Resolver precedence\n";
prv_test_reset();
// None scenario.
prv_assert_equals( '', PRV_Key_Store::get_key(), 'get_key() returns "" when no source' );
// Option scenario (no constant defined yet).
PRV_Key_Store::store_key( $plaintext );
prv_assert_equals( $plaintext, PRV_Key_Store::get_key(), 'get_key() returns plaintext from encrypted option' );
// Constant wins over option.
define( 'PRV_OPENROUTER_API_KEY', 'sk-or-constant-key-xyz' );
prv_assert_equals( 'sk-or-constant-key-xyz', PRV_Key_Store::get_key(), 'Constant takes precedence over option' );
prv_assert( PRV_Key_Store::constant_is_set(), 'constant_is_set() true when constant is defined' );
prv_assert_equals( PRV_Key_Store::SOURCE_CONSTANT, PRV_Key_Store::get_source(), 'get_source() == CONSTANT when constant defined' );
/* ─────────────────────────────────────────────────────────────────────────
* Group 4: Write-only — key value never in rendered HTML output
* ──────────────────────────────────────────────────────────────────────── */
echo "\nGroup 4: Write-only render — key not in output\n";
prv_test_reset();
PRV_Key_Store::store_key( $plaintext );
// Load rendering dependencies.
require_once PRV_PLUGIN_DIR . 'includes/core/class-prv-settings-page.php';
require_once PRV_PLUGIN_DIR . 'includes/core/class-prv-key-manager-renderer.php';
$html = prv_capture( function () {
( new PRV_Key_Manager_Renderer() )->render();
} );
prv_assert( '' !== $html, 'Key manager card renders non-empty HTML' );
// Plaintext key must be absent everywhere.
prv_assert( false === strpos( $html, $plaintext ), 'Plaintext key absent from rendered HTML' );
prv_assert( false === strpos( $html, 'sk-or-test' ), 'Key value prefix absent from rendered HTML' );
prv_assert( false === strpos( $html, 'sk-or-constant-key-xyz' ), 'Constant key value absent from rendered HTML' );
// Password input must render with empty value.
prv_assert( false !== strpos( $html, 'type="password"' ), 'Password input is present in output' );
prv_assert( false !== strpos( $html, 'value=""' ), 'Password input has empty value attribute' );
prv_assert( false === strpos( $html, 'value="sk-' ), 'No key value in any value= attribute' );
/* ─────────────────────────────────────────────────────────────────────────
* Group 5: Remove clears the encrypted option
* ──────────────────────────────────────────────────────────────────────── */
echo "\nGroup 5: Remove clears encrypted option\n";
prv_test_reset();
PRV_Key_Store::store_key( $plaintext );
prv_assert( '' !== get_option( PRV_Key_Store::OPTION_ENC, '' ), 'Key present before remove' );
PRV_Key_Store::clear_key();
prv_assert_equals( '', get_option( PRV_Key_Store::OPTION_ENC, '' ), 'Option empty after clear_key()' );
// get_key() falls back to constant when present.
prv_assert_equals( 'sk-or-constant-key-xyz', PRV_Key_Store::get_key(), 'get_key() returns constant after option cleared' );
/* ─────────────────────────────────────────────────────────────────────────
* Summary
* ──────────────────────────────────────────────────────────────────────── */
exit( prv_test_summary() );

View File

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

View File

@@ -1,34 +1,34 @@
<?php
/**
* Tests for PGM_Probe_Runner: budget-cap abort, in-run caching, and run mechanics.
* Tests for PRV_Probe_Runner: budget-cap abort, run-lock, model health.
*
* Uses queued mock HTTP responses (via $GLOBALS['pgm_test_state']['remote_posts'])
* Uses queued mock HTTP responses (via $GLOBALS['prv_test_state']['remote_posts'])
* and a mock ledger to verify runner behaviour without real API calls.
*
* @package PeptideGeoMonitor
* @package PrVision
*/
declare(strict_types=1);
require_once __DIR__ . '/../bootstrap.php';
echo "=== PGM Probe Runner Tests ===\n";
echo "=== PRV Probe Runner Tests ===\n";
// ── Define API key constants (required by providers) ──────────────────────
if ( ! defined( 'PGM_OPENROUTER_API_KEY' ) ) {
define( 'PGM_OPENROUTER_API_KEY', 'sk-or-test-key' );
if ( ! defined( 'PRV_OPENROUTER_API_KEY' ) ) {
define( 'PRV_OPENROUTER_API_KEY', 'sk-or-test-key' );
}
if ( ! defined( 'PGM_CF_ACCOUNT_ID' ) ) {
define( 'PGM_CF_ACCOUNT_ID', '' );
if ( ! defined( 'PRV_CF_ACCOUNT_ID' ) ) {
define( 'PRV_CF_ACCOUNT_ID', '' );
}
if ( ! defined( 'PGM_CF_GATEWAY_ID' ) ) {
define( 'PGM_CF_GATEWAY_ID', '' );
if ( ! defined( 'PRV_CF_GATEWAY_ID' ) ) {
define( 'PRV_CF_GATEWAY_ID', '' );
}
// ── Mock ledger: tracks afford calls, simulates exhaustion ────────────────
class PGM_Mock_Ledger extends PGM_Cost_Ledger {
class PRV_Mock_Ledger extends PRV_Cost_Ledger {
public int $afford_count = 0;
public int $no_afford_count = 0;
public int $exhaust_after = PHP_INT_MAX;
@@ -47,7 +47,18 @@ class PGM_Mock_Ledger extends PGM_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,126 +66,163 @@ $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 ───────────────────────────────────
pgm_test_reset();
$GLOBALS['pgm_test_state']['options']['pgm_peptides'] = [['slug'=>'bpc-157','label'=>'BPC-157']];
$GLOBALS['pgm_test_state']['options']['pgm_prompt_intents'] = ['what is {peptide}'];
$GLOBALS['pgm_test_state']['options']['pgm_models'] = ['perplexity/sonar'];
$GLOBALS['pgm_test_state']['remote_posts'] = [
prv_test_reset();
setup_simple_run( [$sonar_row] );
$GLOBALS['prv_test_state']['remote_posts'] = [
['response' => ['code' => 200], 'body' => $cited_body],
];
$ledger1 = new PGM_Mock_Ledger();
$runner1 = new PGM_Probe_Runner( $ledger1 );
$ledger1 = new PRV_Mock_Ledger();
$runner1 = new PRV_Probe_Runner( $ledger1 );
$counts1 = $runner1->run();
pgm_assert_equals( 1, $counts1['probed'], 'basic run: 1 probed' );
pgm_assert_equals( 0, $counts1['skipped_budget'], 'basic run: 0 budget skips' );
pgm_assert_equals( 0, $counts1['skipped_error'], 'basic run: 0 error skips' );
pgm_assert_equals( 1, $ledger1->afford_count, 'basic run: 1 can_afford check' );
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 ───────────
pgm_test_reset();
$GLOBALS['pgm_test_state']['options']['pgm_peptides'] = [
['slug'=>'bpc-157','label'=>'BPC-157'],
['slug'=>'tb-500','label'=>'TB-500'],
];
$GLOBALS['pgm_test_state']['options']['pgm_prompt_intents'] = ['what is {peptide}'];
$GLOBALS['pgm_test_state']['options']['pgm_models'] = ['perplexity/sonar'];
$GLOBALS['pgm_test_state']['remote_posts'] = [
prv_test_reset();
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],
];
$ledger2 = new PGM_Mock_Ledger();
$runner2 = new PGM_Probe_Runner( $ledger2 );
$ledger2 = new PRV_Mock_Ledger();
$runner2 = new PRV_Probe_Runner( $ledger2 );
$counts2 = $runner2->run();
pgm_assert_equals( 2, $counts2['probed'], '2×1×1 run: 2 probes completed' );
pgm_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 ────────────────────────────────
pgm_test_reset();
$GLOBALS['pgm_test_state']['options']['pgm_peptides'] = [
['slug'=>'bpc-157','label'=>'BPC-157'],
['slug'=>'tb-500','label'=>'TB-500'],
['slug'=>'mk-677','label'=>'MK-677'],
];
$GLOBALS['pgm_test_state']['options']['pgm_prompt_intents'] = ['what is {peptide}'];
$GLOBALS['pgm_test_state']['options']['pgm_models'] = ['perplexity/sonar'];
// Queue 3 responses but cap budget at 1.
$GLOBALS['pgm_test_state']['remote_posts'] = [
prv_test_reset();
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 PGM_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 PGM_Probe_Runner( $exhausting_ledger );
$runner3 = new PRV_Probe_Runner( $exh );
$counts3 = $runner3->run();
pgm_assert_equals( 1, $counts3['probed'], 'budget abort: 1 probe completed before cap' );
pgm_assert( $counts3['skipped_budget'] >= 2, 'budget abort: ≥2 probes skipped (budget)' );
pgm_assert_equals( 0, $counts3['skipped_error'], 'budget abort: 0 error skips' );
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_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 ─────────────────────────────
pgm_test_reset();
$GLOBALS['pgm_test_state']['options']['pgm_peptides'] = [['slug'=>'bpc-157','label'=>'BPC-157']];
$GLOBALS['pgm_test_state']['options']['pgm_prompt_intents'] = ['what is {peptide}'];
$GLOBALS['pgm_test_state']['options']['pgm_models'] = ['perplexity/sonar'];
// All retries fail with 500.
$GLOBALS['pgm_test_state']['remote_posts'] = [
prv_test_reset();
setup_simple_run( [$sonar_row] );
$GLOBALS['prv_test_state']['remote_posts'] = [
['response' => ['code' => 500], 'body' => 'Error'],
['response' => ['code' => 500], 'body' => 'Error'],
['response' => ['code' => 500], 'body' => 'Error'],
];
$ledger4 = new PGM_Mock_Ledger();
$runner4 = new PGM_Probe_Runner( $ledger4 );
$ledger4 = new PRV_Mock_Ledger();
$runner4 = new PRV_Probe_Runner( $ledger4 );
$counts4 = $runner4->run();
pgm_assert_equals( 0, $counts4['probed'], 'HTTP 500: 0 probes' );
pgm_assert( $counts4['skipped_error'] >= 1, 'HTTP 500: ≥1 error skip recorded' );
prv_assert_equals( 0, $counts4['probed'], 'HTTP 500: 0 probes' );
prv_assert( $counts4['skipped_error'] >= 1, 'HTTP 500: >=1 error skip recorded' );
// ── Test: WP_Error from wp_remote_post counts as skipped_error ──────────
pgm_test_reset();
$GLOBALS['pgm_test_state']['options']['pgm_peptides'] = [['slug'=>'bpc-157','label'=>'BPC-157']];
$GLOBALS['pgm_test_state']['options']['pgm_prompt_intents'] = ['what is {peptide}'];
$GLOBALS['pgm_test_state']['options']['pgm_models'] = ['perplexity/sonar'];
// Return WP_Error for all retries.
prv_test_reset();
setup_simple_run( [$sonar_row] );
$wp_error = new WP_Error( 'http_request_failed', 'cURL error 28: Operation timed out' );
$GLOBALS['pgm_test_state']['remote_posts'] = [ $wp_error, $wp_error, $wp_error ];
$GLOBALS['prv_test_state']['remote_posts'] = [ $wp_error, $wp_error, $wp_error ];
$ledger5 = new PGM_Mock_Ledger();
$runner5 = new PGM_Probe_Runner( $ledger5 );
$ledger5 = new PRV_Mock_Ledger();
$runner5 = new PRV_Probe_Runner( $ledger5 );
$counts5 = $runner5->run();
pgm_assert_equals( 0, $counts5['probed'], 'WP_Error: 0 probes completed' );
pgm_assert( $counts5['skipped_error'] >= 1, 'WP_Error: ≥1 error skip recorded' );
prv_assert_equals( 0, $counts5['probed'], 'WP_Error: 0 probes completed' );
prv_assert( $counts5['skipped_error'] >= 1, 'WP_Error: >=1 error skip recorded' );
// ── Test: run returns correct keys in counts array ───────────────────────
pgm_test_reset();
$GLOBALS['pgm_test_state']['options']['pgm_peptides'] = [['slug'=>'bpc-157','label'=>'BPC-157']];
$GLOBALS['pgm_test_state']['options']['pgm_prompt_intents'] = ['what is {peptide}'];
$GLOBALS['pgm_test_state']['options']['pgm_models'] = ['perplexity/sonar'];
$GLOBALS['pgm_test_state']['remote_posts'] = [
prv_test_reset();
setup_simple_run( [$sonar_row] );
$GLOBALS['prv_test_state']['remote_posts'] = [
['response' => ['code' => 200], 'body' => $cited_body],
];
$ledger6 = new PGM_Mock_Ledger();
$runner6 = new PGM_Probe_Runner( $ledger6 );
$ledger6 = new PRV_Mock_Ledger();
$runner6 = new PRV_Probe_Runner( $ledger6 );
$counts6 = $runner6->run();
pgm_assert( array_key_exists( 'probed', $counts6 ), 'run: returns probed key' );
pgm_assert( array_key_exists( 'skipped_budget', $counts6 ), 'run: returns skipped_budget key' );
pgm_assert( array_key_exists( 'skipped_error', $counts6 ), 'run: returns skipped_error key' );
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' );
exit( pgm_test_summary() );
// ── Test: run-lock refused when already locked ────────────────────────────
prv_test_reset();
setup_simple_run( [$sonar_row] );
PRV_Run_Lock::acquire();
$ledger7 = new PRV_Mock_Ledger();
$runner7 = new PRV_Probe_Runner( $ledger7 );
$counts7 = $runner7->run();
prv_assert_equals( 0, $counts7['probed'], 'run-lock: 0 probed when locked' );
prv_assert_equals( -1, $counts7['skipped_error'], 'run-lock: -1 sentinel when lock busy' );
prv_assert_equals( 0, $ledger7->afford_count, 'run-lock: no can_afford checks when locked' );
PRV_Run_Lock::release();
// ── Test: per-model health updated after run ──────────────────────────────
prv_test_reset();
setup_simple_run( [$sonar_row] );
$GLOBALS['prv_test_state']['remote_posts'] = [
['response' => ['code' => 200], 'body' => $cited_body],
];
$runner8 = new PRV_Probe_Runner( new PRV_Mock_Ledger() );
$runner8->run();
$models8 = PRV_Model_Registry::get_all();
prv_assert( count( $models8 ) > 0, 'health update: at least 1 model' );
// The sonar model should have health_status = healthy (probed=1, errors=0).
foreach ( $models8 as $m ) {
if ( 'perplexity/sonar' === $m['slug'] ) {
prv_assert_equals( 'healthy', $m['health_status'], 'health update: sonar healthy after successful run' );
prv_assert_equals( 1, $m['health_probed'], 'health update: health_probed = 1' );
prv_assert_equals( 0, $m['health_errors'], 'health update: health_errors = 0' );
}
}
// ── Test: lock is released after a successful run ─────────────────────────
prv_test_reset();
setup_simple_run( [$sonar_row] );
$GLOBALS['prv_test_state']['remote_posts'] = [
['response' => ['code' => 200], 'body' => $cited_body],
];
$runner9 = new PRV_Probe_Runner( new PRV_Mock_Ledger() );
$runner9->run();
prv_assert( false === PRV_Run_Lock::is_locked(), 'run-lock cleanup: lock released after successful run' );
exit( prv_test_summary() );

View File

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

View File

@@ -2,25 +2,25 @@
/**
* Tests for provider response parsing.
*
* Exercises PGM_Perplexity_Provider::parse_response() and
* PGM_OpenRouter_Provider::parse_response() with mock HTTP responses,
* Exercises PRV_Perplexity_Provider::parse_response() and
* PRV_OpenRouter_Provider::parse_response() with mock HTTP responses,
* verifying domain extraction, citation detection, and cost calculation.
*
* @package PeptideGeoMonitor
* @package PrVision
*/
declare(strict_types=1);
require_once __DIR__ . '/../bootstrap.php';
echo "=== PGM Provider Response Parsing Tests ===\n";
echo "=== PRV Provider Response Parsing Tests ===\n";
// ── PGM_Perplexity_Provider parsing ─────────────────────────────────────
// ── PRV_Perplexity_Provider parsing ─────────────────────────────────────
$provider = new PGM_Perplexity_Provider( null, new PGM_Citation_Detector() );
$provider = new PRV_Perplexity_Provider( null, new PRV_Citation_Detector() );
// Test: cited via citations array
pgm_test_reset();
prv_test_reset();
$data = [
'choices' => [[ 'message' => [ 'content' => 'BPC-157 is a peptide. See peptiderepo.com' ] ]],
'citations' => [
@@ -30,34 +30,34 @@ $data = [
'usage' => [ 'total_tokens' => 500 ],
];
$result = $provider->parse_response( $data );
pgm_assert( $result->is_cited(), 'Perplexity: cited=true when peptiderepo.com in citations' );
pgm_assert_equals( 1, $result->get_our_position(), 'Perplexity: position=1 when first in citations' );
pgm_assert( count( $result->get_source_domains() ) === 2, 'Perplexity: 2 source domains extracted' );
pgm_assert_equals( 500 * 0.000001, $result->get_cost_usd(), 'Perplexity: cost from token usage' );
prv_assert( $result->is_cited(), 'Perplexity: cited=true when peptiderepo.com in citations' );
prv_assert_equals( 1, $result->get_our_position(), 'Perplexity: position=1 when first in citations' );
prv_assert( count( $result->get_source_domains() ) === 2, 'Perplexity: 2 source domains extracted' );
prv_assert_equals( 500 * 0.000001, $result->get_cost_usd(), 'Perplexity: cost from token usage' );
// Test: not cited, no citations
pgm_test_reset();
prv_test_reset();
$data_empty = [
'choices' => [[ 'message' => [ 'content' => 'BPC-157 info here.' ] ]],
'citations' => [],
'usage' => [ 'total_tokens' => 200 ],
];
$result_empty = $provider->parse_response( $data_empty );
pgm_assert( ! $result_empty->is_cited(), 'Perplexity: cited=false with empty citations' );
pgm_assert_equals( null, $result_empty->get_our_position(), 'Perplexity: position=null when not cited' );
prv_assert( ! $result_empty->is_cited(), 'Perplexity: cited=false with empty citations' );
prv_assert_equals( null, $result_empty->get_our_position(), 'Perplexity: position=null when not cited' );
// Test: excerpt truncated to 500 chars
pgm_test_reset();
prv_test_reset();
$long_content = str_repeat( 'a', 600 );
$data_long = [
'choices' => [[ 'message' => [ 'content' => $long_content ] ]],
'citations' => [],
];
$result_long = $provider->parse_response( $data_long );
pgm_assert_equals( 500, mb_strlen( $result_long->get_raw_excerpt() ), 'Perplexity: excerpt truncated to 500 chars' );
prv_assert_equals( 500, mb_strlen( $result_long->get_raw_excerpt() ), 'Perplexity: excerpt truncated to 500 chars' );
// Test: object-style citations
pgm_test_reset();
prv_test_reset();
$data_obj = [
'choices' => [[ 'message' => [ 'content' => 'answer' ] ]],
'citations' => [
@@ -66,26 +66,26 @@ $data_obj = [
],
];
$result_obj = $provider->parse_response( $data_obj );
pgm_assert( $result_obj->is_cited(), 'Perplexity: object-style citations — cited=true' );
pgm_assert_equals( 1, $result_obj->get_our_position(), 'Perplexity: object-style — position=1' );
prv_assert( $result_obj->is_cited(), 'Perplexity: object-style citations — cited=true' );
prv_assert_equals( 1, $result_obj->get_our_position(), 'Perplexity: object-style — position=1' );
// Test: fallback cost when no usage data
pgm_test_reset();
prv_test_reset();
$data_no_usage = [
'choices' => [[ 'message' => [ 'content' => 'answer' ] ]],
'citations' => [],
];
$result_no_usage = $provider->parse_response( $data_no_usage );
pgm_assert_equals( PGM_Perplexity_Provider::ESTIMATED_COST_PER_PROBE, $result_no_usage->get_cost_usd(), 'Perplexity: fallback to ESTIMATED_COST_PER_PROBE when no usage' );
prv_assert_equals( PRV_Perplexity_Provider::ESTIMATED_COST_PER_PROBE, $result_no_usage->get_cost_usd(), 'Perplexity: fallback to ESTIMATED_COST_PER_PROBE when no usage' );
// ── PGM_OpenRouter_Provider parsing ─────────────────────────────────────
// ── PRV_OpenRouter_Provider parsing ─────────────────────────────────────
echo "\n--- OpenRouter provider parsing ---\n";
$or_provider = new PGM_OpenRouter_Provider( 'openai/gpt-4o-search-preview', null, new PGM_Citation_Detector() );
$or_provider = new PRV_OpenRouter_Provider( 'openai/gpt-4o-search-preview', null, new PRV_Citation_Detector() );
// Test: annotations path (structured citations)
pgm_test_reset();
prv_test_reset();
$data_ann = [
'choices' => [[
'message' => [
@@ -99,11 +99,11 @@ $data_ann = [
'usage' => [ 'total_tokens' => 300 ],
];
$result_ann = $or_provider->parse_response( $data_ann );
pgm_assert( $result_ann->is_cited(), 'OpenRouter: annotations path — cited=true' );
pgm_assert_equals( 1, $result_ann->get_our_position(), 'OpenRouter: annotations path — position=1' );
prv_assert( $result_ann->is_cited(), 'OpenRouter: annotations path — cited=true' );
prv_assert_equals( 1, $result_ann->get_our_position(), 'OpenRouter: annotations path — position=1' );
// Test: regex URL extraction fallback
pgm_test_reset();
prv_test_reset();
$data_regex = [
'choices' => [[
'message' => [
@@ -113,23 +113,23 @@ $data_regex = [
'usage' => [ 'total_tokens' => 150 ],
];
$result_regex = $or_provider->parse_response( $data_regex );
pgm_assert( $result_regex->is_cited(), 'OpenRouter: regex fallback — cited=true from inline URL' );
prv_assert( $result_regex->is_cited(), 'OpenRouter: regex fallback — cited=true from inline URL' );
// Test: not cited
pgm_test_reset();
prv_test_reset();
$data_no_cite = [
'choices' => [[ 'message' => [ 'content' => 'Some response without our domain.' ] ]],
];
$result_no_cite = $or_provider->parse_response( $data_no_cite );
pgm_assert( ! $result_no_cite->is_cited(), 'OpenRouter: not cited when peptiderepo.com absent from text' );
prv_assert( ! $result_no_cite->is_cited(), 'OpenRouter: not cited when peptiderepo.com absent from text' );
// Test: cost calculation from token usage
pgm_test_reset();
prv_test_reset();
$data_cost = [
'choices' => [[ 'message' => [ 'content' => 'test' ] ]],
'usage' => [ 'total_tokens' => 1000 ],
];
$result_cost = $or_provider->parse_response( $data_cost );
pgm_assert_equals( 1000 * 0.000002, $result_cost->get_cost_usd(), 'OpenRouter: cost from token usage (2$/1M)' );
prv_assert_equals( 1000 * 0.000002, $result_cost->get_cost_usd(), 'OpenRouter: cost from token usage (2$/1M)' );
exit( pgm_test_summary() );
exit( prv_test_summary() );

View File

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

View File

@@ -1,28 +1,28 @@
<?php
/**
* Tests for the visibility score formula in PGM_Ai_Visibility_Collector.
* Tests for the visibility score formula in PRV_Ai_Visibility_Collector.
*
* Score formula (CONTEXT.md):
* base_score = cited_probes / total_probes
* position_bonus = Σ(1/our_position for cited) / total_probes
* visibility_score = round((base_score + position_bonus) / 2, 4)
*
* @package PeptideGeoMonitor
* @package PrVision
*/
declare(strict_types=1);
require_once __DIR__ . '/../bootstrap.php';
echo "=== PGM Score Calculation Tests ===\n";
echo "=== PRV Score Calculation Tests ===\n";
// Instantiate with a null ledger (no DB calls in these tests).
$collector = new PGM_Ai_Visibility_Collector( null );
$collector = new PRV_Ai_Visibility_Collector( null );
// ── Test: zero total → score 0 ──────────────────────────────────────────
pgm_test_reset();
pgm_assert_equals( 0.0, $collector->compute_score( 0, 0, 0.0 ), 'score: zero total returns 0.0' );
prv_test_reset();
prv_assert_equals( 0.0, $collector->compute_score( 0, 0, 0.0 ), 'score: zero total returns 0.0' );
// ── Test: all cited, position 1 ─────────────────────────────────────────
@@ -30,15 +30,15 @@ pgm_assert_equals( 0.0, $collector->compute_score( 0, 0, 0.0 ), 'score: zero tot
// base_score = 10/10 = 1.0
// position_bonus = (1+1+1+…)/10 = 10/10 = 1.0
// score = (1.0 + 1.0) / 2 = 1.0
pgm_test_reset();
pgm_assert_equals( 1.0, $collector->compute_score( 10, 10, 10.0 ), 'score: all cited at position 1 → 1.0' );
prv_test_reset();
prv_assert_equals( 1.0, $collector->compute_score( 10, 10, 10.0 ), 'score: all cited at position 1 → 1.0' );
// ── Test: none cited ────────────────────────────────────────────────────
// 10 probes, 0 cited, position_sum=0
// base_score = 0, position_bonus = 0 → score = 0
pgm_test_reset();
pgm_assert_equals( 0.0, $collector->compute_score( 0, 10, 0.0 ), 'score: none cited → 0.0' );
prv_test_reset();
prv_assert_equals( 0.0, $collector->compute_score( 0, 10, 0.0 ), 'score: none cited → 0.0' );
// ── Test: half cited at position 2 ──────────────────────────────────────
@@ -46,9 +46,9 @@ pgm_assert_equals( 0.0, $collector->compute_score( 0, 10, 0.0 ), 'score: none ci
// base_score = 5/10 = 0.5
// position_bonus = 2.5/10 = 0.25
// score = (0.5 + 0.25) / 2 = 0.375
pgm_test_reset();
prv_test_reset();
$score = $collector->compute_score( 5, 10, 2.5 );
pgm_assert_equals( 0.375, $score, 'score: half cited at position 2 → 0.375' );
prv_assert_equals( 0.375, $score, 'score: half cited at position 2 → 0.375' );
// ── Test: single probe, cited at position 3 ─────────────────────────────
@@ -56,25 +56,25 @@ pgm_assert_equals( 0.375, $score, 'score: half cited at position 2 → 0.375' );
// base_score = 1.0
// position_bonus = 0.3333
// score = (1.0 + 0.3333) / 2 ≈ 0.6667 (rounded to 4 decimals)
pgm_test_reset();
prv_test_reset();
$score = $collector->compute_score( 1, 1, 1.0 / 3.0 );
pgm_assert_equals( 0.6667, $score, 'score: single probe cited at position 3 → 0.6667' );
prv_assert_equals( 0.6667, $score, 'score: single probe cited at position 3 → 0.6667' );
// ── Test: score is bounded [0, 1] ───────────────────────────────────────
// Extreme: position_sum could theoretically exceed total but that's logically
// impossible with 1-based positions (position_sum ≤ cited_count ≤ total).
// Verify edge case: 1 probe, 1 cited, position 1
pgm_test_reset();
pgm_assert( $collector->compute_score( 1, 1, 1.0 ) <= 1.0, 'score: never exceeds 1.0' );
pgm_assert( $collector->compute_score( 0, 10, 0.0 ) >= 0.0, 'score: never below 0.0' );
prv_test_reset();
prv_assert( $collector->compute_score( 1, 1, 1.0 ) <= 1.0, 'score: never exceeds 1.0' );
prv_assert( $collector->compute_score( 0, 10, 0.0 ) >= 0.0, 'score: never below 0.0' );
// ── Test: precision — 4 decimal places ──────────────────────────────────
pgm_test_reset();
prv_test_reset();
$score = $collector->compute_score( 3, 7, 1.5 );
// base = 3/7 ≈ 0.4286, bonus = 1.5/7 ≈ 0.2143, avg ≈ 0.3214
$expected = round( ( 3.0 / 7.0 + 1.5 / 7.0 ) / 2.0, 4 );
pgm_assert_equals( $expected, $score, 'score: 4-decimal precision maintained' );
prv_assert_equals( $expected, $score, 'score: 4-decimal precision maintained' );
exit( pgm_test_summary() );
exit( prv_test_summary() );

View File

@@ -1,101 +1,81 @@
<?php
/**
* Tests for uninstall purge: table drop + options deletion.
* Tests for uninstall purge: all prv_ options deleted, lock cleared.
*
* Exercises PGM_Table_Manager::drop_table() and verifies the uninstall
* script logic (simulated because it requires WP_UNINSTALL_PLUGIN).
*
* @package PeptideGeoMonitor
* @package PrVision
*/
declare(strict_types=1);
require_once __DIR__ . '/../bootstrap.php';
echo "=== PGM 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['pgm_test_state']['drop_calls'] = [];
// Populate all known prv_ options including v0.2.0 additions.
// 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['pgm_test_state']['drop_calls'][] = $sql;
}
if ( str_contains( strtoupper( $sql ), 'DELETE FROM' ) && str_contains( $sql, "pgm\\_" ) ) {
$GLOBALS['pgm_test_state']['options_deleted'] = true;
}
return true;
}
};
prv_test_reset();
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_provider_key_enc', 'dummy-encrypted-ciphertext-value' );
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' ];
// ── Test: drop_table issues DROP TABLE IF EXISTS ─────────────────────────
// 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' );
pgm_test_reset();
$GLOBALS['pgm_test_state']['drop_calls'] = [];
PGM_Table_Manager::drop_table();
// 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 ] );
}
pgm_assert( ! empty( $GLOBALS['pgm_test_state']['drop_calls'] ), 'drop_table: DROP TABLE query executed' );
$drop_sql = $GLOBALS['pgm_test_state']['drop_calls'][0] ?? '';
pgm_assert( str_contains( strtoupper( $drop_sql ), 'DROP TABLE IF EXISTS' ), 'drop_table: uses DROP TABLE IF EXISTS' );
pgm_assert( str_contains( $drop_sql, 'pgm_ai_visibility' ), 'drop_table: targets pgm_ai_visibility table' );
// Also delete transients.
delete_transient( PRV_Run_Lock::LOCK_KEY );
// ── Test: create_table sets the schema_version option ────────────────────
// Clear cron.
wp_clear_scheduled_hook( PRV_CRON_HOOK );
pgm_test_reset();
PGM_Table_Manager::create_table();
$schema_ver = get_option( 'pgm_schema_version' );
pgm_assert_equals( PGM_SCHEMA_VERSION, $schema_ver, 'create_table: pgm_schema_version option set after create' );
// 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_provider_key_enc' ), 'post-uninstall: prv_provider_key_enc (v0.2.3 encrypted key) 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' );
// ── Test: get_table_name returns prefixed name ───────────────────────────
pgm_test_reset();
$table = PGM_Table_Manager::get_table_name();
pgm_assert_equals( 'wp_pgm_ai_visibility', $table, 'get_table_name: returns wp_ prefixed table name' );
// ── Test: seed_defaults writes expected option keys ──────────────────────
pgm_test_reset();
PGM_Config::seed_defaults();
pgm_assert( isset( $GLOBALS['pgm_test_state']['options']['pgm_monthly_budget_usd'] ), 'seed_defaults: pgm_monthly_budget_usd set' );
pgm_assert( isset( $GLOBALS['pgm_test_state']['options']['pgm_peptides'] ), 'seed_defaults: pgm_peptides set' );
pgm_assert( isset( $GLOBALS['pgm_test_state']['options']['pgm_prompt_intents'] ), 'seed_defaults: pgm_prompt_intents set' );
pgm_assert( isset( $GLOBALS['pgm_test_state']['options']['pgm_models'] ), 'seed_defaults: pgm_models set' );
// ── Test: seed_defaults does not overwrite existing values ───────────────
pgm_test_reset();
$GLOBALS['pgm_test_state']['options']['pgm_monthly_budget_usd'] = 99.0;
PGM_Config::seed_defaults();
pgm_assert_equals( 99.0, $GLOBALS['pgm_test_state']['options']['pgm_monthly_budget_usd'], 'seed_defaults: does not overwrite existing pgm_monthly_budget_usd' );
// ── Test: default budget is PGM_DEFAULT_MONTHLY_BUDGET_USD ──────────────
pgm_test_reset();
PGM_Config::seed_defaults();
pgm_assert_equals( PGM_DEFAULT_MONTHLY_BUDGET_USD, $GLOBALS['pgm_test_state']['options']['pgm_monthly_budget_usd'], 'seed_defaults: default budget = PGM_DEFAULT_MONTHLY_BUDGET_USD (5.0)' );
// ── Test: default peptide count is 12 ───────────────────────────────────
pgm_test_reset();
$peptides = PGM_Config::get_peptides();
pgm_assert_equals( 12, count( $peptides ), 'get_peptides: default seed has 12 peptides' );
// ── Test: default prompt intent count is 3 ──────────────────────────────
pgm_test_reset();
$intents = PGM_Config::get_prompt_intents();
pgm_assert_equals( 3, count( $intents ), 'get_prompt_intents: default seed has 3 intents' );
// ── Test: default model count is 3 ──────────────────────────────────────
pgm_test_reset();
$models = PGM_Config::get_models();
pgm_assert_equals( 3, count( $models ), 'get_models: default seed has 3 models' );
pgm_assert( in_array( 'perplexity/sonar', $models, true ), 'get_models: perplexity/sonar in default models' );
exit( pgm_test_summary() );
exit( prv_test_summary() );

View File

@@ -1,13 +1,23 @@
<?php
/**
* Peptide GEO Monitor — full data teardown on uninstall.
* PR Vision -- full data teardown on uninstall.
*
* Drops the custom pgm_ai_visibility table and deletes every wp_options
* row whose name starts with "pgm_". WP-Cron events are already cleared
* by PGM_Deactivator on deactivation; this runs after that.
* 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-pgm-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.
*
* v0.2.3 addition: prv_provider_key_enc (encrypted API key) -- also purged
* by the prv_ wildcard DELETE below. No plaintext is stored.
*
* @see ARCHITECTURE.md -- Section Uninstall specification.
* @see class-prv-deactivator.php -- Clears cron on deactivation.
* @see class-prv-key-store.php -- Encrypted key storage (prv_provider_key_enc).
* @package PrVision
*/
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
@@ -18,17 +28,25 @@ global $wpdb;
/* ── 1. Drop the custom table ─────────────────────────────────────── */
$table = $wpdb->prefix . 'pgm_ai_visibility';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$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 pgm_ prefixed options ──────────────────────────── */
/* ── 2. Delete all prv_ prefixed options (v0.1, v0.2, v0.2.3) ───── */
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
// Covers prv_provider_key_enc (v0.2.3) along with all prior option keys.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->query(
"DELETE FROM {$wpdb->options} WHERE option_name LIKE 'pgm\_%'"
"DELETE FROM {$wpdb->options} WHERE option_name LIKE 'prv\_%'"
);
/* ── 3. Clear any remaining scheduled cron events ─────────────────── */
/* ── 3. Delete prv_ transients ────────────────────────────────────── */
wp_clear_scheduled_hook( 'pgm_weekly_probe' );
// 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' );