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
This commit is contained in:
peptiderepo
2026-06-13 17:17:52 +07:00
committed by GitHub
parent 46fda89e18
commit 401077fe45
40 changed files with 1277 additions and 1056 deletions

View File

@@ -53,7 +53,6 @@ jobs:
- name: Run PHPCS - name: Run PHPCS
run: composer run lint run: composer run lint
continue-on-error: true
# ── 300-line file check ────────────────────────────────────────── # ── 300-line file check ──────────────────────────────────────────
file-size: file-size:

View File

@@ -1,12 +1,12 @@
# Architecture — Peptide GEO Monitor # Architecture — PR Vision
**Version:** 0.1.0 | **Last updated:** 2026-06-13 **Version:** 0.1.1 | **Last updated:** 2026-06-13
--- ---
## 1. Overview ## 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 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.
**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 (keyword rankings, technical-SEO) can be added as new classes.
@@ -15,37 +15,37 @@
## 2. File tree ## 2. File tree
``` ```
peptide-geo-monitor/ pr-vision/
├── peptide-geo-monitor.php # Plugin boot: constants, autoloader, hooks ├── pr-vision.php # Plugin boot: constants, autoloader, hooks
├── uninstall.php # Full data teardown: DROP TABLE + DELETE pgm_* options ├── uninstall.php # Full data teardown: DROP TABLE + DELETE prv_* options
├── composer.json # Dev deps (PHPCS only) ├── composer.json # Dev deps (PHPCS only)
├── phpcs.xml.dist # PHPCS config (WordPress standard) ├── phpcs.xml.dist # PHPCS config (WordPress standard)
├── includes/ ├── includes/
│ ├── core/ │ ├── core/
│ │ ├── class-pgm-autoloader.php # SPL autoloader: PGM_* → includes/** │ │ ├── class-prv-autoloader.php # SPL autoloader: PRV_* → includes/**
│ │ ├── class-pgm-plugin.php # Orchestrator: boots sub-systems │ │ ├── class-prv-plugin.php # Orchestrator: boots sub-systems
│ │ ├── class-pgm-activator.php # Activation: table + defaults + cron │ │ ├── class-prv-activator.php # Activation: table + defaults + cron
│ │ ├── class-pgm-deactivator.php # Deactivation: clear cron │ │ ├── class-prv-deactivator.php # Deactivation: clear cron
│ │ ├── class-pgm-table-manager.php # dbDelta create/drop + get_table_name() │ │ ├── class-prv-table-manager.php # dbDelta create/drop + get_table_name()
│ │ ├── class-pgm-config.php # Typed getters + seed_defaults() │ │ ├── class-prv-config.php # Typed getters + seed_defaults()
│ │ ├── class-pgm-cron.php # Weekly WP-Cron schedule/clear │ │ ├── class-prv-cron.php # Weekly WP-Cron schedule/clear
│ │ ├── class-pgm-cost-ledger.php # MTD cost + hard monthly cap enforcement │ │ ├── class-prv-cost-ledger.php # MTD cost + hard monthly cap enforcement
│ │ ├── class-pgm-probe-result.php # Immutable value object from a probe call │ │ ├── class-prv-probe-result.php # Immutable value object from a probe call
│ │ ├── class-pgm-probe-runner.php # Orchestrates peptide×intent×model run │ │ ├── class-prv-probe-runner.php # Orchestrates peptide×intent×model run
│ │ ├── class-pgm-collector-registry.php # Singleton registry for collectors + panels │ │ ├── class-prv-collector-registry.php # Singleton registry for collectors + panels
│ │ └── class-pgm-admin-page.php # Admin page: menu, Run now, rendering │ │ └── class-prv-admin-page.php # Admin page: menu, Run now, rendering
│ ├── providers/ │ ├── providers/
│ │ ├── interface-pgm-probe-provider.php # probe(query): PGM_Probe_Result contract │ │ ├── interface-prv-probe-provider.php # probe(query): PRV_Probe_Result contract
│ │ ├── class-pgm-gateway-client.php # Cloudflare AI Gateway HTTP + retry │ │ ├── class-prv-gateway-client.php # Cloudflare AI Gateway HTTP + retry
│ │ ├── class-pgm-citation-detector.php # Domain extraction + cite detection │ │ ├── class-prv-citation-detector.php # Domain extraction + cite detection
│ │ ├── class-pgm-perplexity-provider.php # Perplexity sonar via OpenRouter/gateway │ │ ├── class-prv-perplexity-provider.php # Perplexity sonar via OpenRouter/gateway
│ │ └── class-pgm-openrouter-provider.php # Generic OpenRouter (GPT-search, Gemini) │ │ └── class-prv-openrouter-provider.php # Generic OpenRouter (GPT-search, Gemini)
│ ├── collector/ │ ├── collector/
│ │ ├── interface-pgm-data-collector.php # collect(): array seam │ │ ├── interface-prv-data-collector.php # collect(): array seam
│ │ └── class-pgm-ai-visibility-collector.php # AI-visibility data from DB │ │ └── class-prv-ai-visibility-collector.php # AI-visibility data from DB
│ └── panel/ │ └── panel/
│ ├── interface-pgm-dashboard-panel.php # render(data): void seam │ ├── interface-prv-dashboard-panel.php # render(data): void seam
│ └── class-pgm-ai-visibility-panel.php # Renders trendline + standings │ └── class-prv-ai-visibility-panel.php # Renders trendline + standings
├── tests/ ├── tests/
│ ├── bootstrap.php # WP-stub bootstrap (no PHPUnit) │ ├── bootstrap.php # WP-stub bootstrap (no PHPUnit)
│ └── unit/ │ └── unit/
@@ -67,37 +67,37 @@ peptide-geo-monitor/
WP-Cron (weekly) ──────────────────────────────────────┐ WP-Cron (weekly) ──────────────────────────────────────┐
Admin "Run now" (POST + nonce) ─────────────────────────┤ Admin "Run now" (POST + nonce) ─────────────────────────┤
↓ ↓
PGM_Probe_Runner::run() PRV_Probe_Runner::run()
│ │
┌───────────────────────────────────┤ ┌───────────────────────────────────┤
│ for each peptide × intent × model│ │ for each peptide × intent × model│
│ │ │ │
│ PGM_Cost_Ledger::can_afford() │ │ PRV_Cost_Ledger::can_afford() │
│ └─ ABORT gracefully if at cap │ │ └─ ABORT gracefully if at cap │
│ │ │ │
│ PGM_Probe_Provider::probe(query) │ │ PRV_Probe_Provider::probe(query) │
│ └─ PGM_Gateway_Client │ │ └─ PRV_Gateway_Client │
│ └─ Cloudflare AI Gateway │ │ └─ Cloudflare AI Gateway │
│ └─ OpenRouter │ │ └─ OpenRouter │
│ └─ LLM │ │ └─ LLM │
│ │ │ │
│ PGM_Citation_Detector │ │ PRV_Citation_Detector │
│ └─ extract domains │ │ └─ extract domains │
│ └─ detect peptiderepo.com │ │ └─ detect peptiderepo.com │
│ │ │ │
│ $wpdb→insert(pgm_ai_visibility) │ │ $wpdb→insert(prv_ai_visibility) │
│ PGM_Cost_Ledger::update_row_cost()│ │ PRV_Cost_Ledger::update_row_cost()│
└───────────────────────────────────┘ └───────────────────────────────────┘
│ │
Admin page load ────────────────────────────────────────┤ Admin page load ────────────────────────────────────────┤
↓ ↓
PGM_Admin_Page::render_page() PRV_Admin_Page::render_page()
│ │
PGM_Collector_Registry PRV_Collector_Registry
│ │
PGM_Ai_Visibility_Collector::collect() PRV_Ai_Visibility_Collector::collect()
│ (DB reads) │ (DB reads)
PGM_Ai_Visibility_Panel::render() PRV_Ai_Visibility_Panel::render()
│ (HTML output) │ (HTML output)
Browser (Chart.js) Browser (Chart.js)
``` ```
@@ -106,7 +106,7 @@ Admin page load ─────────────────────
## 4. Database ## 4. Database
**Table:** `{prefix}pgm_ai_visibility` **Table:** `{prefix}prv_ai_visibility`
| Column | Type | Notes | | Column | Type | Notes |
|--------|------|-------| |--------|------|-------|
@@ -123,7 +123,7 @@ Admin page load ─────────────────────
| raw_excerpt | LONGTEXT | First 500 chars of LLM response | | raw_excerpt | LONGTEXT | First 500 chars of LLM response |
| cost_usd | DECIMAL(12,8) | Actual call cost | | cost_usd | DECIMAL(12,8) | Actual call cost |
Schema version tracked in `pgm_schema_version` option. Schema version tracked in `prv_schema_version` option.
--- ---
@@ -133,7 +133,7 @@ Schema version tracked in `pgm_schema_version` option.
All LLM calls route through: 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 (`https://openrouter.ai/api/v1`) when the constants are absent or empty. Falls back to direct OpenRouter (`https://openrouter.ai/api/v1`) when the constants are absent or empty.
@@ -144,26 +144,26 @@ Pattern mirrors PRAutoBlogger's `class-open-router-config.php` and `class-open-r
| Provider | Model | Citation source | | Provider | Model | Citation source |
|----------|-------|-----------------| |----------|-------|-----------------|
| PGM_Perplexity_Provider | `perplexity/sonar` | `citations[]` array (primary, real-web retrieval) | | PRV_Perplexity_Provider | `perplexity/sonar` | `citations[]` array (primary, real-web retrieval) |
| PGM_OpenRouter_Provider | `openai/gpt-4o-search-preview` | annotations or inline URL regex | | PRV_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_OpenRouter_Provider | `google/gemini-2.0-flash-001` | annotations or inline URL regex |
--- ---
## 6. Collector / Panel seam ## 6. Collector / Panel seam
``` ```
PGM_Data_Collector (interface) PGM_Dashboard_Panel (interface) PRV_Data_Collector (interface) PRV_Dashboard_Panel (interface)
│ │ │ │
PGM_Ai_Visibility_Collector PGM_Ai_Visibility_Panel PRV_Ai_Visibility_Collector PRV_Ai_Visibility_Panel
│ │ │ │
└──────── PGM_Collector_Registry ──────────┘ └──────── PRV_Collector_Registry ──────────┘
(key: "ai_visibility") (key: "ai_visibility")
``` ```
To add a future SEO collector (keyword rankings, schema coverage…): To add a future SEO collector (keyword rankings, schema coverage…):
1. Implement `PGM_Data_Collector` + `PGM_Dashboard_Panel`. 1. Implement `PRV_Data_Collector` + `PRV_Dashboard_Panel`.
2. Register both in `PGM_Plugin::init()`. 2. Register both in `PRV_Plugin::init()`.
No dashboard shell changes required. No dashboard shell changes required.
--- ---

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. All notable changes to this project will be documented in this file.
@@ -7,28 +7,51 @@ Versioning: [Semantic Versioning](https://semver.org/)
--- ---
## [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 ## [0.1.0] — 2026-06-13
**Initial release — AI-visibility dashboard.** **Initial release — AI-visibility dashboard.**
### Added ### Added
- `PGM_Probe_Provider` interface + `PGM_Probe_Result` value object. - `PRV_Probe_Provider` interface + `PRV_Probe_Result` value object.
- `PGM_Perplexity_Provider`: Perplexity `sonar` via OpenRouter/Cloudflare AI Gateway; parses `citations[]` array. - `PRV_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. - `PRV_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). - `PRV_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()`. - `PRV_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. - `PRV_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. - `PRV_Probe_Runner`: full peptides × intents × models run with budget cap abort and partial-run persistence.
- `PGM_Table_Manager`: `{prefix}pgm_ai_visibility` via `dbDelta`, schema-version option, `drop_table()` for uninstall. - `PRV_Table_Manager`: `{prefix}prv_ai_visibility` via `dbDelta`, schema-version option, `drop_table()` for uninstall.
- `PGM_Config`: typed getters + `seed_defaults()` with ~12 peptides, 3 intents, 3 models. - `PRV_Config`: typed getters + `seed_defaults()` with ~12 peptides, 3 intents, 3 models.
- `PGM_Cron`: weekly `pgm_weekly_probe` WP-Cron; `schedule_weekly()` / `clear_schedule()`. - `PRV_Cron`: weekly `prv_weekly_probe` WP-Cron; `schedule_weekly()` / `clear_schedule()`.
- `PGM_Data_Collector` + `PGM_Dashboard_Panel` interfaces (collector/panel seam). - `PRV_Data_Collector` + `PRV_Dashboard_Panel` interfaces (collector/panel seam).
- `PGM_Collector_Registry`: singleton registry for future extensibility. - `PRV_Collector_Registry`: singleton registry for future extensibility.
- `PGM_Ai_Visibility_Collector`: trendline (per-run visibility score), standings (per-peptide latest), MTD cost. - `PRV_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. - `PRV_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`). - `PRV_Admin_Page`: "PR Vision" top-level menu, Chart.js enqueue, Run now POST handler (nonce + `manage_options`).
- `uninstall.php`: DROP TABLE + DELETE `pgm_*` options + `wp_clear_scheduled_hook`. - `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. - 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`. - `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`). - CI: PHP lint (8.1/8.2/8.3) + PHPCS + 300-line check (mirrors `peptide-repo-core`).

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.1.1 | **Last updated:** 2026-06-13
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 ### AI Visibility
Whether and where peptiderepo.com appears in AI assistant responses to peptide-related queries. The core metric this plugin measures. 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 ### 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 ### 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. 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 ### 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 ### 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. 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 ### 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). 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 ### 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`. 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 ### 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. 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 ### Visibility Score
A composite metric summarising peptiderepo.com's AI visibility for a run. A composite metric summarising peptiderepo.com's AI visibility for a run.
@@ -60,24 +60,24 @@ 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. - **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. - **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 ### 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 ### 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 ### 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`.
### GEO ### 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. 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 +85,9 @@ Generative Engine Optimization — the practice of improving a brand's visibilit
### Proxy Metric ### 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." 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 ### MTD Cost
Month-to-date spend in USD, summed from the `cost_usd` column for rows in the current calendar month. 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()`.

View File

@@ -1,6 +1,6 @@
# Conventions — Peptide GEO Monitor # Conventions — PR Vision
**Version:** 0.1.0 | **Last updated:** 2026-06-13 **Version:** 0.1.1 | **Last updated:** 2026-06-13
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. 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 | | Concept | Convention | Example |
|---------|-----------|---------| |---------|-----------|---------|
| Class | `PGM_` prefix + PascalCase | `PGM_Cost_Ledger` | | Class | `PRV_` prefix + PascalCase | `PRV_Cost_Ledger` |
| Interface | `PGM_` prefix + PascalCase | `PGM_Probe_Provider` | | Interface | `PRV_` prefix + PascalCase | `PRV_Probe_Provider` |
| File | `class-pgm-*.php` or `interface-pgm-*.php` | `class-pgm-probe-runner.php` | | File | `class-prv-*.php` or `interface-prv-*.php` | `class-prv-probe-runner.php` |
| Option | `pgm_` prefix | `pgm_monthly_budget_usd` | | Option | `prv_` prefix | `prv_monthly_budget_usd` |
| Hook / cron | `pgm_` prefix | `pgm_weekly_probe` | | Hook / cron | `prv_` prefix | `prv_weekly_probe` |
| Constant | `PGM_` prefix | `PGM_TARGET_DOMAIN` | | Constant | `PRV_` prefix | `PRV_TARGET_DOMAIN` |
| wp-config key | `PGM_` prefix | `PGM_OPENROUTER_API_KEY` | | wp-config key | `PRV_` prefix | `PRV_OPENROUTER_API_KEY` |
| Test file | `test-*.php` | `test-citation-detector.php` | | Test file | `test-*.php` | `test-citation-detector.php` |
## 3. File structure ## 3. File structure
- **One class per file.** Interfaces get their own file. - **One class per file.** Interfaces get their own file.
- **300-line limit** (enforced by CI). Split at the method boundary when approaching the limit. - **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 ## 4. Docblocks
@@ -43,7 +43,7 @@ Every class must have a preamble docblock:
* *
* @see <cross-reference 1> * @see <cross-reference 1>
* @see <cross-reference 2> * @see <cross-reference 2>
* @package PeptideGeoMonitor * @package PrVision
*/ */
``` ```
@@ -63,22 +63,22 @@ Every public method must have:
## 6. Tests ## 6. Tests
- Tests live in `tests/unit/test-*.php` and are run by the CI `lint-php` job via `php $test`. - 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() )`. - Each test file `require_once`s `tests/bootstrap.php` and calls `exit( prv_test_summary() )`.
- Use `pgm_assert()`, `pgm_assert_equals()`, `pgm_assert_throws()`. - Use `prv_assert()`, `prv_assert_equals()`, `prv_assert_throws()`.
- Mock HTTP via `$GLOBALS['pgm_test_state']['remote_posts']` (queued responses). - Mock HTTP via `$GLOBALS['prv_test_state']['remote_posts']` (queued responses).
- No PHPUnit dependency. - No PHPUnit dependency.
## 7. Adding a new provider ## 7. Adding a new provider
1. Create `includes/providers/class-pgm-{name}-provider.php`. 1. Create `includes/providers/class-prv-{name}-provider.php`.
2. Implement `PGM_Probe_Provider` (`probe()`, `get_name()`, `is_configured()`). 2. Implement `PRV_Probe_Provider` (`probe()`, `get_name()`, `is_configured()`).
3. Add a branch in `PGM_Probe_Runner::resolve_provider()`. 3. Add a branch in `PRV_Probe_Runner::resolve_provider()`.
4. Update `ARCHITECTURE.md` §5 (Providers table). 4. Update `ARCHITECTURE.md` §5 (Providers table).
5. Add a parse test in `tests/unit/test-provider-parsing.php`. 5. Add a parse test in `tests/unit/test-provider-parsing.php`.
## 8. Adding a new collector/panel pair ## 8. Adding a new collector/panel pair
1. Implement `PGM_Data_Collector` in `includes/collector/`. 1. Implement `PRV_Data_Collector` in `includes/collector/`.
2. Implement `PGM_Dashboard_Panel` in `includes/panel/`. 2. Implement `PRV_Dashboard_Panel` in `includes/panel/`.
3. Register both in `PGM_Plugin::init()` via `PGM_Collector_Registry::instance()`. 3. Register both in `PRV_Plugin::init()` via `PRV_Collector_Registry::instance()`.
4. Update `ARCHITECTURE.md` §6 (Collector/Panel seam). 4. Update `ARCHITECTURE.md` §6 (Collector/Panel seam).

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. 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 ```php
// OpenRouter API key (sk-or-…) — required for all providers. // 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). // Cloudflare AI Gateway routing (optional — falls back to direct OpenRouter).
define( 'PGM_CF_ACCOUNT_ID', 'YOUR_CF_ACCOUNT_ID' ); define( 'PRV_CF_ACCOUNT_ID', 'YOUR_CF_ACCOUNT_ID' );
define( 'PGM_CF_GATEWAY_ID', 'YOUR_GATEWAY_NAME' ); define( 'PRV_CF_GATEWAY_ID', 'YOUR_GATEWAY_NAME' );
``` ```
**Never commit these values. They are read at runtime from wp-config.php only.** **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 ## 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: The page shows:
- A proxy-metric disclaimer note. - A proxy-metric disclaimer note.
@@ -50,26 +50,26 @@ The page shows:
## Scheduling ## 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 ## 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 ## 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 ## Developer notes
- PHP 8.1+ required. No runtime Composer dependencies. - 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. - Files are kept under 300 lines; one class per file.
- See `CONVENTIONS.md` for naming patterns and extension guides. - See `CONVENTIONS.md` for naming patterns and extension guides.
- See `CONTEXT.md` for the domain glossary including the visibility score formula. - See `CONTEXT.md` for the domain glossary including the visibility score formula.

View File

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

View File

@@ -1,10 +1,16 @@
<?php <?php
/**
* AI-visibility data collector for the PR Vision dashboard.
*
* @package PrVision
*/
declare(strict_types=1); 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: * Reads the prv_ai_visibility table and returns structured data for:
* - Trendline: per-run visibility score (% cited, position-weighted). * - Trendline: per-run visibility score (% cited, position-weighted).
* - Standings: per-peptide latest cited status, our position, competing domains. * - Standings: per-peptide latest cited status, our position, competing domains.
* - Run metadata: last run time, MTD cost vs cap. * - Run metadata: last run time, MTD cost vs cap.
@@ -14,27 +20,31 @@ declare(strict_types=1);
* position_bonus = Σ (1 / our_position) for cited probes / total_probes * position_bonus = Σ (1 / our_position) for cited probes / total_probes
* visibility_score = round((base_score + position_bonus) / 2, 4) * visibility_score = round((base_score + position_bonus) / 2, 4)
* *
* Who triggers: PGM_Admin_Page calls the registered collector via the registry. * Who triggers: PRV_Admin_Page calls the registered collector via the registry.
* Dependencies: $wpdb, PGM_Table_Manager, PGM_Cost_Ledger, PGM_Config. * Dependencies: $wpdb, PRV_Table_Manager, PRV_Cost_Ledger, PRV_Config.
* *
* @see interface-pgm-data-collector.php — Interface this implements. * @see interface-prv-data-collector.php — Interface this implements.
* @see class-pgm-ai-visibility-panel.php — Renders the data returned here. * @see class-prv-ai-visibility-panel.php — Renders the data returned here.
* @see CONTEXT.md — Score formula + glossary. * @see CONTEXT.md — Score formula + glossary.
* @see ARCHITECTURE.md — §Storage, §Score. * @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 ) { public function __construct( ?PRV_Cost_Ledger $ledger = null ) {
$this->ledger = $ledger ?? new PGM_Cost_Ledger(); $this->ledger = $ledger ?? new PRV_Cost_Ledger();
} }
/** /**
@@ -51,12 +61,11 @@ class PGM_Ai_Visibility_Collector implements PGM_Data_Collector {
public function collect(): array { public function collect(): array {
global $wpdb; global $wpdb;
$table = PGM_Table_Manager::get_table_name(); $table = PRV_Table_Manager::get_table_name();
// ── Trendline: one score per run_id ──────────────────────────── // ── Trendline: one score per run_id ────────────────────────────
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$run_rows = $wpdb->get_results( $run_rows = $wpdb->get_results(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"SELECT run_id, MIN(captured_at) AS captured_at, "SELECT run_id, MIN(captured_at) AS captured_at,
SUM(cited) AS cited_count, SUM(cited) AS cited_count,
COUNT(*) AS total_count, COUNT(*) AS total_count,
@@ -67,6 +76,7 @@ class PGM_Ai_Visibility_Collector implements PGM_Data_Collector {
LIMIT 52", LIMIT 52",
ARRAY_A ARRAY_A
); );
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$trendline = array(); $trendline = array();
foreach ( (array) $run_rows as $row ) { foreach ( (array) $run_rows as $row ) {
@@ -82,9 +92,8 @@ class PGM_Ai_Visibility_Collector implements PGM_Data_Collector {
} }
// ── Standings: latest result per peptide ──────────────────────── // ── 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( $standing_rows = $wpdb->get_results(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"SELECT peptide_slug, peptide_label, "SELECT peptide_slug, peptide_label,
MAX(cited) AS cited, MAX(cited) AS cited,
MIN(CASE WHEN cited=1 THEN our_position ELSE NULL END) AS our_position, MIN(CASE WHEN cited=1 THEN our_position ELSE NULL END) AS our_position,
@@ -95,10 +104,11 @@ class PGM_Ai_Visibility_Collector implements PGM_Data_Collector {
GROUP BY peptide_slug, peptide_label", GROUP BY peptide_slug, peptide_label",
ARRAY_A ARRAY_A
); );
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$standings = array(); $standings = array();
foreach ( (array) $standing_rows as $row ) { foreach ( (array) $standing_rows as $row ) {
$top_domains = $this->extract_top_domains( (string) ( $row['domains_json_list'] ?? '' ) ); $top_domains = $this->extract_top_domains( (string) ( $row['domains_json_list'] ?? '' ) ); // phpcs:ignore Generic.Formatting.MultipleStatementAlignment.NotSameWarning
$standings[ (string) $row['peptide_slug'] ] = array( $standings[ (string) $row['peptide_slug'] ] = array(
'label' => (string) $row['peptide_label'], 'label' => (string) $row['peptide_label'],
'cited' => (bool) (int) $row['cited'], 'cited' => (bool) (int) $row['cited'],
@@ -109,18 +119,16 @@ class PGM_Ai_Visibility_Collector implements PGM_Data_Collector {
} }
// ── Run metadata ──────────────────────────────────────────────── // ── Run metadata ────────────────────────────────────────────────
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$last_run = $wpdb->get_var( $last_run = $wpdb->get_var( "SELECT MAX(captured_at) FROM {$table}" );
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
"SELECT MAX(captured_at) FROM {$table}"
);
return array( return array(
'trendline' => $trendline, 'trendline' => $trendline,
'standings' => $standings, 'standings' => $standings,
'last_run_at' => $last_run ? (string) $last_run : null, 'last_run_at' => $last_run ? (string) $last_run : null,
'mtd_cost_usd' => $this->ledger->get_month_to_date_usd(), '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(),
); );
} }
@@ -146,8 +154,8 @@ class PGM_Ai_Visibility_Collector implements PGM_Data_Collector {
if ( 0 === $total_count ) { if ( 0 === $total_count ) {
return 0.0; return 0.0;
} }
$base_score = $cited_count / $total_count; $base_score = $cited_count / $total_count;
$position_bonus = $position_sum / $total_count; $position_bonus = $position_sum / $total_count;
return round( ( $base_score + $position_bonus ) / 2.0, 4 ); return round( ( $base_score + $position_bonus ) / 2.0, 4 );
} }
@@ -163,7 +171,7 @@ class PGM_Ai_Visibility_Collector implements PGM_Data_Collector {
return array(); return array();
} }
$all = array(); $all = array();
$chunks = explode( '|||', $domains_json_list ); $chunks = explode( '|||', $domains_json_list );
foreach ( $chunks as $chunk ) { foreach ( $chunks as $chunk ) {
$decoded = json_decode( $chunk, true ); $decoded = json_decode( $chunk, true );
@@ -175,6 +183,6 @@ class PGM_Ai_Visibility_Collector implements PGM_Data_Collector {
$counts = array_count_values( $all ); $counts = array_count_values( $all );
arsort( $counts ); arsort( $counts );
$top = array_slice( array_keys( $counts ), 0, 5 ); $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 <?php
/**
* Contract for all PR Vision data collectors.
*
* @package PrVision
*/
declare(strict_types=1); 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 * (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. * changing the dashboard shell.
* *
* Who triggers: PGM_Collector_Registry dispatches registered collectors. * Who triggers: PRV_Collector_Registry dispatches registered collectors.
* Dependencies: None — pure interface. * Dependencies: None — pure interface.
* *
* @see class-pgm-ai-visibility-collector.php — v1 implementation. * @see class-prv-ai-visibility-collector.php — v1 implementation.
* @see class-pgm-collector-registry.php — Registration hub. * @see class-prv-collector-registry.php — Registration hub.
* @see ARCHITECTURE.md — §Collector/Panel seam. * @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. * Collect and return the data payload for this category.

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

View File

@@ -1,38 +1,44 @@
<?php <?php
/**
* PR Vision top-level admin page.
*
* @package PrVision
*/
declare(strict_types=1); declare(strict_types=1);
/** /**
* GEO Monitor top-level admin page. * PR Vision top-level admin page.
* *
* Registers the "GEO Monitor" menu item (manage_options), enqueues Chart.js, * Registers the "PR Vision" menu item (manage_options), enqueues Chart.js,
* handles the "Run now" POST action (nonce + capability), and delegates * handles the "Run now" POST action (nonce + capability), and delegates
* rendering to the registered panels via PGM_Collector_Registry. * rendering to the registered panels via PRV_Collector_Registry.
* *
* Who triggers: PGM_Plugin::init() → is_admin() guard. * Who triggers: PRV_Plugin::init() → is_admin() guard.
* Dependencies: PGM_Collector_Registry, PGM_Probe_Runner. * Dependencies: PRV_Collector_Registry, PRV_Probe_Runner.
* *
* @see class-pgm-plugin.php — Calls register_hooks(). * @see class-prv-plugin.php — Calls register_hooks().
* @see class-pgm-collector-registry.php — Provides panels. * @see class-prv-collector-registry.php — Provides panels.
* @see class-pgm-probe-runner.php — Invoked by the Run now action. * @see class-prv-probe-runner.php — Invoked by the Run now action.
* @see ARCHITECTURE.md — §Admin dashboard. * @see ARCHITECTURE.md — §Admin dashboard.
* @package PeptideGeoMonitor * @package PrVision
*/ */
class PGM_Admin_Page { class PRV_Admin_Page {
/** /**
* Admin menu slug. * Admin menu slug.
*/ */
const MENU_SLUG = 'pgm-geo-monitor'; const MENU_SLUG = 'pr-vision';
/** /**
* Nonce action for the Run now form. * Nonce action for the Run now form.
*/ */
const NONCE_ACTION = 'pgm_run_now'; const NONCE_ACTION = 'prv_run_now';
/** /**
* Nonce field name. * Nonce field name.
*/ */
const NONCE_FIELD = 'pgm_nonce'; const NONCE_FIELD = 'prv_nonce';
/** /**
* Register WordPress admin hooks. * Register WordPress admin hooks.
@@ -43,12 +49,12 @@ class PGM_Admin_Page {
*/ */
public function register_hooks(): void { public function register_hooks(): void {
add_action( 'admin_menu', array( $this, 'add_menu_page' ) ); add_action( 'admin_menu', array( $this, 'add_menu_page' ) );
add_action( 'admin_post_pgm_run_now', array( $this, 'handle_run_now' ) ); add_action( 'admin_post_prv_run_now', array( $this, 'handle_run_now' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
} }
/** /**
* Add the "GEO Monitor" top-level menu page. * Add the "PR Vision" top-level menu page.
* *
* Side effects: Adds a WP admin menu entry. * Side effects: Adds a WP admin menu entry.
* *
@@ -56,8 +62,8 @@ class PGM_Admin_Page {
*/ */
public function add_menu_page(): void { public function add_menu_page(): void {
add_menu_page( add_menu_page(
__( 'GEO Monitor', 'peptide-geo-monitor' ), __( 'PR Vision', 'pr-vision' ),
__( 'GEO Monitor', 'peptide-geo-monitor' ), __( 'PR Vision', 'pr-vision' ),
'manage_options', 'manage_options',
self::MENU_SLUG, self::MENU_SLUG,
array( $this, 'render_page' ), array( $this, 'render_page' ),
@@ -67,7 +73,7 @@ class PGM_Admin_Page {
} }
/** /**
* Enqueue Chart.js on the GEO Monitor page only. * Enqueue Chart.js on the PR Vision page only.
* *
* @param string $hook Current admin page hook. * @param string $hook Current admin page hook.
* *
@@ -79,7 +85,7 @@ class PGM_Admin_Page {
} }
wp_enqueue_script( wp_enqueue_script(
'pgm-chartjs', 'prv-chartjs',
'https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js', 'https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js',
array(), array(),
'4.4.3', '4.4.3',
@@ -88,20 +94,20 @@ class PGM_Admin_Page {
wp_add_inline_style( wp_add_inline_style(
'wp-admin', 'wp-admin',
'.pgm-meta-bar{display:flex;gap:20px;margin:10px 0 15px;flex-wrap:wrap;} '.prv-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;} .prv-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;} .prv-card{background:#fff;border:1px solid #c3c4c7;padding:16px 20px;margin-bottom:20px;border-radius:4px;}
.pgm-card h2{margin-top:0;} .prv-card h2{margin-top:0;}
.pgm-proxy-note{margin:10px 0;} .prv-proxy-note{margin:10px 0;}
.pgm-standings th,.pgm-standings td{vertical-align:middle;} .prv-standings th,.prv-standings td{vertical-align:middle;}
canvas#pgm-trendline-chart{max-height:300px;}' canvas#prv-trendline-chart{max-height:300px;}'
); );
} }
/** /**
* Handle the "Run now" POST action. * Handle the "Run now" POST action.
* *
* Validates nonce and capability before dispatching to PGM_Probe_Runner. * Validates nonce and capability before dispatching to PRV_Probe_Runner.
* Redirects back to the admin page with a status query arg. * Redirects back to the admin page with a status query arg.
* *
* Side effects: Triggers a full probe run; redirects. * Side effects: Triggers a full probe run; redirects.
@@ -110,23 +116,23 @@ class PGM_Admin_Page {
*/ */
public function handle_run_now(): void { public function handle_run_now(): void {
if ( ! current_user_can( 'manage_options' ) ) { if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'Insufficient permissions.', 'peptide-geo-monitor' ), 403 ); wp_die( esc_html__( 'Insufficient permissions.', 'pr-vision' ), 403 );
} }
$nonce = isset( $_POST[ self::NONCE_FIELD ] ) ? sanitize_text_field( wp_unslash( $_POST[ self::NONCE_FIELD ] ) ) : ''; $nonce = isset( $_POST[ self::NONCE_FIELD ] ) ? sanitize_text_field( wp_unslash( $_POST[ self::NONCE_FIELD ] ) ) : '';
if ( ! wp_verify_nonce( $nonce, self::NONCE_ACTION ) ) { if ( ! wp_verify_nonce( $nonce, self::NONCE_ACTION ) ) {
wp_die( esc_html__( 'Security check failed.', 'peptide-geo-monitor' ), 403 ); wp_die( esc_html__( 'Security check failed.', 'pr-vision' ), 403 );
} }
$runner = new PGM_Probe_Runner(); $runner = new PRV_Probe_Runner();
$counts = $runner->run(); $counts = $runner->run();
$redirect = add_query_arg( $redirect = add_query_arg(
array( array(
'page' => self::MENU_SLUG, 'page' => self::MENU_SLUG,
'pgm_run_done' => 1, 'prv_run_done' => 1,
'pgm_probed' => (int) $counts['probed'], 'prv_probed' => (int) $counts['probed'],
'pgm_skipped' => (int) ( $counts['skipped_budget'] + $counts['skipped_error'] ), 'prv_skipped' => (int) ( $counts['skipped_budget'] + $counts['skipped_error'] ),
), ),
admin_url( 'admin.php' ) admin_url( 'admin.php' )
); );
@@ -136,7 +142,7 @@ class PGM_Admin_Page {
} }
/** /**
* Render the full GEO Monitor admin page. * Render the full PR Vision admin page.
* *
* Side effects: Outputs HTML directly. * Side effects: Outputs HTML directly.
* *
@@ -144,22 +150,22 @@ class PGM_Admin_Page {
*/ */
public function render_page(): void { public function render_page(): void {
if ( ! current_user_can( 'manage_options' ) ) { if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'Insufficient permissions.', 'peptide-geo-monitor' ) ); wp_die( esc_html__( 'Insufficient permissions.', 'pr-vision' ) );
} }
echo '<div class="wrap">'; echo '<div class="wrap">';
echo '<h1>' . esc_html__( 'GEO Monitor — AI Visibility', 'peptide-geo-monitor' ) . '</h1>'; echo '<h1>' . esc_html__( 'PR Vision — AI Visibility', 'pr-vision' ) . '</h1>';
$this->render_run_done_notice(); $this->render_run_done_notice();
// "Run now" button form. // "Run now" button form.
echo '<form method="post" action="' . esc_url( admin_url( 'admin-post.php' ) ) . '">'; echo '<form method="post" action="' . esc_url( admin_url( 'admin-post.php' ) ) . '">';
echo '<input type="hidden" name="action" value="pgm_run_now">'; echo '<input type="hidden" name="action" value="prv_run_now">';
wp_nonce_field( self::NONCE_ACTION, self::NONCE_FIELD ); wp_nonce_field( self::NONCE_ACTION, self::NONCE_FIELD );
submit_button( __( 'Run now', 'peptide-geo-monitor' ), 'secondary', 'pgm_run_now_btn', false ); submit_button( __( 'Run now', 'pr-vision' ), 'secondary', 'prv_run_now_btn', false );
echo '</form>'; echo '</form>';
$registry = PGM_Collector_Registry::instance(); $registry = PRV_Collector_Registry::instance();
foreach ( $registry->get_collectors() as $key => $collector ) { foreach ( $registry->get_collectors() as $key => $collector ) {
$panel = $registry->get_panel( $key ); $panel = $registry->get_panel( $key );
@@ -180,19 +186,18 @@ class PGM_Admin_Page {
*/ */
private function render_run_done_notice(): void { private function render_run_done_notice(): void {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended // phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( empty( $_GET['pgm_run_done'] ) ) { if ( empty( $_GET['prv_run_done'] ) ) {
return; return;
} }
// phpcs:ignore WordPress.Security.NonceVerification.Recommended $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
$probed = (int) ( $_GET['pgm_probed'] ?? 0 ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
// phpcs:ignore WordPress.Security.NonceVerification.Recommended $skipped = isset( $_GET['prv_skipped'] ) ? absint( wp_unslash( $_GET['prv_skipped'] ) ) : 0;
$skipped = (int) ( $_GET['pgm_skipped'] ?? 0 );
echo '<div class="notice notice-success is-dismissible"><p>'; echo '<div class="notice notice-success is-dismissible"><p>';
echo esc_html( echo esc_html(
sprintf( sprintf(
/* translators: 1: probed count, 2: skipped count */ /* translators: 1: probed count, 2: skipped count */
__( 'Run complete: %1$d probes collected, %2$d skipped (budget or error).', 'peptide-geo-monitor' ), __( 'Run complete: %1$d probes collected, %2$d skipped (budget or error).', 'pr-vision' ),
$probed, $probed,
$skipped $skipped
) )

View File

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

View File

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

View File

@@ -1,22 +1,28 @@
<?php <?php
/**
* Configuration getters and default-seeding for PR Vision.
*
* @package PrVision
*/
declare(strict_types=1); declare(strict_types=1);
/** /**
* Configuration helper: seed defaults and retrieve plugin settings. * Configuration helper: seed defaults and retrieve plugin settings.
* *
* All mutable settings live in wp_options under the pgm_ prefix. This class * 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 * provides typed getters (no magic values in call sites) and seeds the initial
* defaults on first activation. * defaults on first activation.
* *
* Who triggers: PGM_Activator::activate() for seeding; every component that * Who triggers: PRV_Activator::activate() for seeding; every component that
* needs a setting reads through here. * needs a setting reads through here.
* Dependencies: get_option(), update_option(). * Dependencies: get_option(), update_option().
* *
* @see CONTEXT.md — Domain glossary: peptides, prompt intents, models. * @see CONTEXT.md — Domain glossary: peptides, prompt intents, models.
* @see ARCHITECTURE.md — §Config (options + defaults). * @see ARCHITECTURE.md — §Config (options + defaults).
* @package PeptideGeoMonitor * @package PrVision
*/ */
class PGM_Config { class PRV_Config {
/** /**
* Seed the default option values on first activation. * Seed the default option values on first activation.
@@ -28,10 +34,10 @@ class PGM_Config {
* @return void * @return void
*/ */
public static function seed_defaults(): void { public static function seed_defaults(): void {
add_option( 'pgm_monthly_budget_usd', PGM_DEFAULT_MONTHLY_BUDGET_USD ); add_option( 'prv_monthly_budget_usd', PRV_DEFAULT_MONTHLY_BUDGET_USD );
add_option( 'pgm_peptides', self::default_peptides() ); add_option( 'prv_peptides', self::default_peptides() );
add_option( 'pgm_prompt_intents', self::default_prompt_intents() ); add_option( 'prv_prompt_intents', self::default_prompt_intents() );
add_option( 'pgm_models', self::default_models() ); add_option( 'prv_models', self::default_models() );
} }
/** /**
@@ -40,8 +46,8 @@ class PGM_Config {
* @return float Positive float; falls back to the plugin default. * @return float Positive float; falls back to the plugin default.
*/ */
public static function get_monthly_budget_usd(): float { public static function get_monthly_budget_usd(): float {
$val = (float) get_option( 'pgm_monthly_budget_usd', PGM_DEFAULT_MONTHLY_BUDGET_USD ); $val = (float) get_option( 'prv_monthly_budget_usd', PRV_DEFAULT_MONTHLY_BUDGET_USD );
return $val > 0.0 ? $val : PGM_DEFAULT_MONTHLY_BUDGET_USD; return $val > 0.0 ? $val : PRV_DEFAULT_MONTHLY_BUDGET_USD;
} }
/** /**
@@ -52,7 +58,7 @@ class PGM_Config {
* @return array<int, array{slug: string, label: string}> * @return array<int, array{slug: string, label: string}>
*/ */
public static function get_peptides(): array { public static function get_peptides(): array {
$raw = get_option( 'pgm_peptides', self::default_peptides() ); $raw = get_option( 'prv_peptides', self::default_peptides() );
return is_array( $raw ) ? $raw : self::default_peptides(); return is_array( $raw ) ? $raw : self::default_peptides();
} }
@@ -64,7 +70,7 @@ class PGM_Config {
* @return array<int, string> * @return array<int, string>
*/ */
public static function get_prompt_intents(): array { public static function get_prompt_intents(): array {
$raw = get_option( 'pgm_prompt_intents', self::default_prompt_intents() ); $raw = get_option( 'prv_prompt_intents', self::default_prompt_intents() );
return is_array( $raw ) ? $raw : self::default_prompt_intents(); return is_array( $raw ) ? $raw : self::default_prompt_intents();
} }
@@ -74,7 +80,7 @@ class PGM_Config {
* @return array<int, string> * @return array<int, string>
*/ */
public static function get_models(): array { public static function get_models(): array {
$raw = get_option( 'pgm_models', self::default_models() ); $raw = get_option( 'prv_models', self::default_models() );
return is_array( $raw ) ? $raw : self::default_models(); return is_array( $raw ) ? $raw : self::default_models();
} }
@@ -85,18 +91,54 @@ class PGM_Config {
*/ */
private static function default_peptides(): array { private static function default_peptides(): array {
return array( return array(
array( 'slug' => 'bpc-157', 'label' => 'BPC-157' ), array(
array( 'slug' => 'tb-500', 'label' => 'TB-500' ), 'slug' => 'bpc-157',
array( 'slug' => 'mk-677', 'label' => 'MK-677' ), 'label' => 'BPC-157',
array( 'slug' => 'cjc-1295', 'label' => 'CJC-1295' ), ),
array( 'slug' => 'ghrp-6', 'label' => 'GHRP-6' ), array(
array( 'slug' => 'ipamorelin', 'label' => 'Ipamorelin' ), 'slug' => 'tb-500',
array( 'slug' => 'semaglutide', 'label' => 'Semaglutide' ), 'label' => 'TB-500',
array( 'slug' => 'tirzepatide', 'label' => 'Tirzepatide' ), ),
array( 'slug' => 'selank', 'label' => 'Selank' ), array(
array( 'slug' => 'semax', 'label' => 'Semax' ), 'slug' => 'mk-677',
array( 'slug' => 'nad-plus', 'label' => 'NAD+' ), 'label' => 'MK-677',
array( 'slug' => 'aod-9604', 'label' => 'AOD-9604' ), ),
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',
),
); );
} }

View File

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

View File

@@ -1,42 +1,48 @@
<?php <?php
/**
* WP-Cron management for the weekly AI-visibility probe.
*
* @package PrVision
*/
declare(strict_types=1); declare(strict_types=1);
/** /**
* WP-Cron management for the weekly AI-visibility probe. * WP-Cron management for the weekly AI-visibility probe.
* *
* Registers the pgm_weekly_probe hook, schedules/clears the event, and * Registers the prv_weekly_probe hook, schedules/clears the event, and
* dispatches to PGM_Probe_Runner on each tick. * dispatches to PRV_Probe_Runner on each tick.
* *
* Who triggers: PGM_Plugin::init() (hook registration), PGM_Activator * Who triggers: PRV_Plugin::init() (hook registration), PRV_Activator
* (schedule), PGM_Deactivator (clear). * (schedule), PRV_Deactivator (clear).
* Dependencies: PGM_Probe_Runner. * Dependencies: PRV_Probe_Runner.
* *
* @see class-pgm-probe-runner.php — Runner invoked on each cron tick. * @see class-prv-probe-runner.php — Runner invoked on each cron tick.
* @see class-pgm-activator.php — Calls schedule_weekly() on activation. * @see class-prv-activator.php — Calls schedule_weekly() on activation.
* @package PeptideGeoMonitor * @package PrVision
*/ */
class PGM_Cron { class PRV_Cron {
/** /**
* Register WordPress action hooks. * Register WordPress action hooks.
* *
* Side effects: Adds action for PGM_CRON_HOOK. * Side effects: Adds action for PRV_CRON_HOOK.
* *
* @return void * @return void
*/ */
public function register_hooks(): void { public function register_hooks(): void {
add_action( PGM_CRON_HOOK, array( $this, 'handle_cron_tick' ) ); add_action( PRV_CRON_HOOK, array( $this, 'handle_cron_tick' ) );
} }
/** /**
* Callback fired by WP-Cron on the weekly schedule. * Callback fired by WP-Cron on the weekly schedule.
* *
* Side effects: Instantiates and runs PGM_Probe_Runner. * Side effects: Instantiates and runs PRV_Probe_Runner.
* *
* @return void * @return void
*/ */
public function handle_cron_tick(): void { public function handle_cron_tick(): void {
$runner = new PGM_Probe_Runner(); $runner = new PRV_Probe_Runner();
$runner->run(); $runner->run();
} }
@@ -50,8 +56,8 @@ class PGM_Cron {
* @return void * @return void
*/ */
public static function schedule_weekly(): void { public static function schedule_weekly(): void {
if ( ! wp_next_scheduled( PGM_CRON_HOOK ) ) { if ( ! wp_next_scheduled( PRV_CRON_HOOK ) ) {
wp_schedule_event( time(), 'weekly', PGM_CRON_HOOK ); wp_schedule_event( time(), 'weekly', PRV_CRON_HOOK );
} }
} }
@@ -65,9 +71,9 @@ class PGM_Cron {
* @return void * @return void
*/ */
public static function clear_schedule(): void { public static function clear_schedule(): void {
$timestamp = wp_next_scheduled( PGM_CRON_HOOK ); $timestamp = wp_next_scheduled( PRV_CRON_HOOK );
if ( false !== $timestamp ) { if ( false !== $timestamp ) {
wp_unschedule_event( $timestamp, PGM_CRON_HOOK ); wp_unschedule_event( $timestamp, PRV_CRON_HOOK );
} }
} }
@@ -77,6 +83,6 @@ class PGM_Cron {
* @return bool * @return bool
*/ */
public static function is_scheduled(): bool { public static function is_scheduled(): bool {
return false !== wp_next_scheduled( PGM_CRON_HOOK ); return false !== wp_next_scheduled( PRV_CRON_HOOK );
} }
} }

View File

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

View File

@@ -1,20 +1,26 @@
<?php <?php
/**
* Main orchestrator for the PR Vision plugin.
*
* @package PrVision
*/
declare(strict_types=1); declare(strict_types=1);
/** /**
* Main orchestrator for the Peptide GEO Monitor plugin. * Main orchestrator for the PR Vision plugin.
* *
* Boots sub-systems in the correct order, hooks into WP, and keeps * 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 * the top-level file under the 300-line limit by delegating all logic
* to specialist classes. * to specialist classes.
* *
* Who triggers: plugins_loaded action (peptide-geo-monitor.php). * Who triggers: plugins_loaded action (pr-vision.php).
* Dependencies: PGM_Cron, PGM_Admin_Page, PGM_Collector_Registry. * Dependencies: PRV_Cron, PRV_Admin_Page, PRV_Collector_Registry.
* *
* @see ARCHITECTURE.md — Boot sequence diagram. * @see ARCHITECTURE.md — Boot sequence diagram.
* @package PeptideGeoMonitor * @package PrVision
*/ */
class PGM_Plugin { class PRV_Plugin {
/** /**
* Wire up all WordPress hooks. * Wire up all WordPress hooks.
@@ -25,17 +31,17 @@ class PGM_Plugin {
* @return void * @return void
*/ */
public function init(): void { public function init(): void {
$cron = new PGM_Cron(); $cron = new PRV_Cron();
$cron->register_hooks(); $cron->register_hooks();
if ( is_admin() ) { if ( is_admin() ) {
$page = new PGM_Admin_Page(); $page = new PRV_Admin_Page();
$page->register_hooks(); $page->register_hooks();
} }
// Register the v1 AI-visibility collector + panel. // Register the v1 AI-visibility collector + panel.
$registry = PGM_Collector_Registry::instance(); $registry = PRV_Collector_Registry::instance();
$registry->register_collector( new PGM_Ai_Visibility_Collector() ); $registry->register_collector( new PRV_Ai_Visibility_Collector() );
$registry->register_panel( 'ai_visibility', new PGM_Ai_Visibility_Panel() ); $registry->register_panel( 'ai_visibility', new PRV_Ai_Visibility_Panel() );
} }
} }

View File

@@ -1,28 +1,36 @@
<?php <?php
/**
* Immutable value object returned by every probe provider.
*
* @package PrVision
*/
declare(strict_types=1); declare(strict_types=1);
/** /**
* Value object: the result of a single LLM probe call. * 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. * 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. * Dependencies: None.
* *
* @see interface-pgm-probe-provider.php — Provider interface that returns this. * @see interface-prv-probe-provider.php — Provider interface that returns this.
* @see class-pgm-probe-runner.php — Consumes the result for storage. * @see class-prv-probe-runner.php — Consumes the result for storage.
* @see CONTEXT.md — "probe result", "cited", "our_position". * @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. * Constructor.
* @param string[] $source_domains Domains extracted from the provider's citations. *
* @param bool $cited Whether peptiderepo.com appears in source_domains. * @param string $raw_excerpt First ~500 chars of the LLM's response.
* @param int|null $our_position 1-based position of peptiderepo.com in sources, or null. * @param string[] $source_domains Domains extracted from the provider's citations.
* @param float $cost_usd Estimated call cost in USD. * @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( public function __construct(
private readonly string $raw_excerpt, private readonly string $raw_excerpt,
@@ -33,6 +41,8 @@ class PGM_Probe_Result {
) {} ) {}
/** /**
* Get the raw LLM response excerpt.
*
* @return string * @return string
*/ */
public function get_raw_excerpt(): string { public function get_raw_excerpt(): string {
@@ -40,6 +50,8 @@ class PGM_Probe_Result {
} }
/** /**
* Get the extracted source domains.
*
* @return string[] * @return string[]
*/ */
public function get_source_domains(): array { public function get_source_domains(): array {
@@ -47,6 +59,8 @@ class PGM_Probe_Result {
} }
/** /**
* Whether peptiderepo.com was cited.
*
* @return bool * @return bool
*/ */
public function is_cited(): 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. * @return int|null 1-based position or null when not cited.
*/ */
public function get_our_position(): ?int { 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. * @return float Estimated cost in USD.
*/ */
public function get_cost_usd(): float { public function get_cost_usd(): float {

View File

@@ -0,0 +1,197 @@
<?php
/**
* Orchestrates a full probe run across peptides, intents, and models.
*
* @package PrVision
*/
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. Dispatches to the correct provider (resolved from PRV_Config::get_models()).
* 3. Writes a row to the prv_ai_visibility table.
* 4. Settles the actual cost against the ledger row.
*
* Who triggers: PRV_Cron::handle_cron_tick() and PRV_Admin_Page (Run now action).
* Dependencies: PRV_Config, PRV_Cost_Ledger, PRV_Table_Manager, all providers.
*
* @see class-prv-cron.php — Calls run() on the weekly schedule.
* @see class-prv-cost-ledger.php — Budget cap enforcement.
* @see class-prv-perplexity-provider.php
* @see class-prv-openrouter-provider.php
* @see ARCHITECTURE.md — §Probe run flow.
* @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.
*
* 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 = PRV_Config::get_peptides();
$intents = PRV_Config::get_prompt_intents();
$models = PRV_Config::get_models();
$counts = array(
'probed' => 0,
'skipped_budget' => 0,
'skipped_error' => 0,
);
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;
}
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'];
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'];
}
}
}
return $counts;
}
/**
* Persist a single probe result to the database.
*
* @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.
*
* @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 {
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, '.', '' ),
),
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 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

@@ -1,26 +1,32 @@
<?php <?php
/**
* Database table creation and schema versioning for PR Vision.
*
* @package PrVision
*/
declare(strict_types=1); declare(strict_types=1);
/** /**
* Database table creation and schema versioning for PGM. * Database table creation and schema versioning for PR Vision.
* *
* Creates {prefix}pgm_ai_visibility via dbDelta (idempotent). Tracks the * Creates {prefix}prv_ai_visibility via dbDelta (idempotent). Tracks the
* schema version in the pgm_schema_version option so future migrations can * schema version in the prv_schema_version option so future migrations can
* detect when an upgrade is needed without destructive ALTER TABLE. * detect when an upgrade is needed without destructive ALTER TABLE.
* *
* Who triggers: PGM_Activator::activate() on plugin activation. * Who triggers: PRV_Activator::activate() on plugin activation.
* Dependencies: $wpdb global, dbDelta() (requires upgrade.php). * Dependencies: $wpdb global, dbDelta() (requires upgrade.php).
* *
* @see class-pgm-activator.php — Calls create_table() on activation. * @see class-prv-activator.php — Calls create_table() on activation.
* @see ARCHITECTURE.md — §Storage. * @see ARCHITECTURE.md — §Storage.
* @package PeptideGeoMonitor * @package PrVision
*/ */
class PGM_Table_Manager { class PRV_Table_Manager {
/** /**
* Name of the custom table (without $wpdb->prefix). * Name of the custom table (without $wpdb->prefix).
*/ */
const TABLE_BASE = 'pgm_ai_visibility'; const TABLE_BASE = 'prv_ai_visibility';
/** /**
* Create or upgrade the AI-visibility table. * Create or upgrade the AI-visibility table.
@@ -28,7 +34,7 @@ class PGM_Table_Manager {
* Uses dbDelta to create or non-destructively alter the table. * Uses dbDelta to create or non-destructively alter the table.
* Records the schema version in an option afterwards. * Records the schema version in an option afterwards.
* *
* Side effects: Writes to the database; sets pgm_schema_version option. * Side effects: Writes to the database; sets prv_schema_version option.
* *
* @return void * @return void
*/ */
@@ -63,7 +69,7 @@ class PGM_Table_Manager {
dbDelta( $sql ); dbDelta( $sql );
update_option( 'pgm_schema_version', PGM_SCHEMA_VERSION ); update_option( 'prv_schema_version', PRV_SCHEMA_VERSION );
} }
/** /**
@@ -88,7 +94,7 @@ class PGM_Table_Manager {
public static function drop_table(): void { public static function drop_table(): void {
global $wpdb; global $wpdb;
$table = $wpdb->prefix . self::TABLE_BASE; $table = $wpdb->prefix . self::TABLE_BASE;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared // 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}" ); $wpdb->query( "DROP TABLE IF EXISTS {$table}" );
} }
} }

View File

@@ -1,8 +1,14 @@
<?php <?php
/**
* AI-visibility dashboard panel renderer.
*
* @package PrVision
*/
declare(strict_types=1); declare(strict_types=1);
/** /**
* AI-visibility dashboard panel — renders the GEO Monitor admin page. * AI-visibility dashboard panel — renders the PR Vision admin page.
* *
* Renders four sections: * Renders four sections:
* 1. Proxy-metric disclaimer note. * 1. Proxy-metric disclaimer note.
@@ -10,31 +16,31 @@ declare(strict_types=1);
* 3. Per-peptide standings table (cited, position, top competitor domains). * 3. Per-peptide standings table (cited, position, top competitor domains).
* 4. MTD cost vs cap + last-run time. * 4. MTD cost vs cap + last-run time.
* *
* All output is escaped. Chart.js is enqueued via PGM_Admin_Page. * All output is escaped. Chart.js is enqueued via PRV_Admin_Page.
* *
* Who triggers: PGM_Admin_Page::render_page() via PGM_Collector_Registry. * Who triggers: PRV_Admin_Page::render_page() via PRV_Collector_Registry.
* Dependencies: PGM_Ai_Visibility_Collector (data format contract). * Dependencies: PRV_Ai_Visibility_Collector (data format contract).
* *
* @see interface-pgm-dashboard-panel.php — Interface this implements. * @see interface-prv-dashboard-panel.php — Interface this implements.
* @see class-pgm-ai-visibility-collector.php — Produces the $data array. * @see class-prv-ai-visibility-collector.php — Produces the $data array.
* @see class-pgm-admin-page.php — Enqueues Chart.js; calls render(). * @see class-prv-admin-page.php — Enqueues Chart.js; calls render().
* @package PeptideGeoMonitor * @package PrVision
*/ */
class PGM_Ai_Visibility_Panel implements PGM_Dashboard_Panel { class PRV_Ai_Visibility_Panel implements PRV_Dashboard_Panel {
/** /**
* {@inheritDoc} * {@inheritDoc}
* *
* @param array<string, mixed> $data Payload from PGM_Ai_Visibility_Collector. * @param array<string, mixed> $data Payload from PRV_Ai_Visibility_Collector.
* *
* @return void * @return void
*/ */
public function render( array $data ): void { public function render( array $data ): void {
$trendline = is_array( $data['trendline'] ?? null ) ? $data['trendline'] : array(); $trendline = is_array( $data['trendline'] ?? null ) ? $data['trendline'] : array();
$standings = is_array( $data['standings'] ?? null ) ? $data['standings'] : 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' ); $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; $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; $cap = isset( $data['monthly_cap_usd'] ) ? (float) $data['monthly_cap_usd'] : PRV_DEFAULT_MONTHLY_BUDGET_USD;
$this->render_proxy_note(); $this->render_proxy_note();
$this->render_meta_bar( $last_run_at, $mtd_cost, $cap ); $this->render_meta_bar( $last_run_at, $mtd_cost, $cap );
@@ -46,7 +52,7 @@ class PGM_Ai_Visibility_Panel implements PGM_Dashboard_Panel {
* {@inheritDoc} * {@inheritDoc}
*/ */
public function get_title(): string { public function get_title(): string {
return __( 'AI Visibility', 'peptide-geo-monitor' ); return __( 'AI Visibility', 'pr-vision' );
} }
/** /**
@@ -55,9 +61,9 @@ class PGM_Ai_Visibility_Panel implements PGM_Dashboard_Panel {
* @return void * @return void
*/ */
private function render_proxy_note(): void { private function render_proxy_note(): void {
echo '<div class="notice notice-info pgm-proxy-note"><p>'; echo '<div class="notice notice-info prv-proxy-note"><p>';
echo '<strong>' . esc_html__( 'Note:', 'peptide-geo-monitor' ) . '</strong> '; 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).', 'peptide-geo-monitor' ); 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>'; echo '</p></div>';
} }
@@ -72,40 +78,42 @@ class PGM_Ai_Visibility_Panel implements PGM_Dashboard_Panel {
*/ */
private function render_meta_bar( string $last_run_at, float $mtd_cost, float $cap ): 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; $pct = $cap > 0 ? min( 100.0, round( $mtd_cost / $cap * 100, 1 ) ) : 0;
echo '<div class="pgm-meta-bar">'; echo '<div class="prv-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="prv-meta-item"><strong>' . esc_html__( 'Last run:', 'pr-vision' ) . '</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 '<span class="prv-meta-item"><strong>' . esc_html__( 'MTD cost:', 'pr-vision' ) . '</strong> $' . esc_html( number_format( $mtd_cost, 4 ) ) . ' / $' . esc_html( number_format( $cap, 2 ) ) . ' (' . esc_html( $pct ) . '% of cap)</span>';
echo '</div>'; echo '</div>';
} }
/** /**
* Render the Chart.js visibility score trendline. * Render the Chart.js visibility score trendline.
* *
* @param array<int, array{run_id: string, captured_at: string, score: float}> $trendline * @param array<int, array{run_id: string, captured_at: string, score: float}> $trendline Trendline data points.
* *
* @return void * @return void
*/ */
private function render_trendline( array $trendline ): void { private function render_trendline( array $trendline ): void {
echo '<div class="pgm-card"><h2>' . esc_html__( 'Visibility Score — Trend', 'peptide-geo-monitor' ) . '</h2>'; echo '<div class="prv-card"><h2>' . esc_html__( 'Visibility Score — Trend', 'pr-vision' ) . '</h2>';
if ( empty( $trendline ) ) { 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>'; echo '<p>' . esc_html__( 'No probe runs recorded yet. Click "Run now" to collect the first data point.', 'pr-vision' ) . '</p></div>';
return; return;
} }
$labels = array(); $labels = array();
$scores = array(); $scores = array();
foreach ( $trendline as $point ) { foreach ( $trendline as $point ) {
$labels[] = esc_js( date( 'M j', strtotime( $point['captured_at'] ) ) ); $labels[] = esc_js( gmdate( 'M j', (int) strtotime( $point['captured_at'] ) ) );
$scores[] = (float) $point['score']; $scores[] = (float) $point['score'];
} }
$labels_json = wp_json_encode( $labels ); $labels_json = wp_json_encode( $labels );
$scores_json = wp_json_encode( $scores ); $scores_json = wp_json_encode( $scores );
echo '<canvas id="pgm-trendline-chart" height="120"></canvas>'; echo '<canvas id="prv-trendline-chart" height="120"></canvas>';
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo '<script>document.addEventListener("DOMContentLoaded",function(){'; echo '<script>document.addEventListener("DOMContentLoaded",function(){';
echo 'var ctx=document.getElementById("pgm-trendline-chart").getContext("2d");'; echo 'var ctx=document.getElementById("prv-trendline-chart").getContext("2d");';
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo 'new Chart(ctx,{type:"line",data:{labels:' . $labels_json . ',datasets:[{label:"Visibility Score",data:' . $scores_json . ',borderColor:"#2271b1",tension:0.3,fill:false}]},options:{scales:{y:{min:0,max:1,title:{display:true,text:"Score"}}},plugins:{legend:{display:false}}}});'; echo '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 '});</script>';
echo '</div>'; echo '</div>';
@@ -114,24 +122,24 @@ class PGM_Ai_Visibility_Panel implements PGM_Dashboard_Panel {
/** /**
* Render the per-peptide current standings table. * 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 * @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 * @return void
*/ */
private function render_standings( array $standings ): void { private function render_standings( array $standings ): void {
echo '<div class="pgm-card"><h2>' . esc_html__( 'Current Standings', 'peptide-geo-monitor' ) . '</h2>'; echo '<div class="prv-card"><h2>' . esc_html__( 'Current Standings', 'pr-vision' ) . '</h2>';
if ( empty( $standings ) ) { if ( empty( $standings ) ) {
echo '<p>' . esc_html__( 'No standings data yet.', 'peptide-geo-monitor' ) . '</p></div>'; echo '<p>' . esc_html__( 'No standings data yet.', 'pr-vision' ) . '</p></div>';
return; return;
} }
echo '<table class="wp-list-table widefat fixed striped pgm-standings">'; echo '<table class="wp-list-table widefat fixed striped prv-standings">';
echo '<thead><tr>'; echo '<thead><tr>';
echo '<th>' . esc_html__( 'Peptide', 'peptide-geo-monitor' ) . '</th>'; echo '<th>' . esc_html__( 'Peptide', 'pr-vision' ) . '</th>';
echo '<th>' . esc_html__( 'Cited?', 'peptide-geo-monitor' ) . '</th>'; echo '<th>' . esc_html__( 'Cited?', 'pr-vision' ) . '</th>';
echo '<th>' . esc_html__( 'Our Position', 'peptide-geo-monitor' ) . '</th>'; echo '<th>' . esc_html__( 'Our Position', 'pr-vision' ) . '</th>';
echo '<th>' . esc_html__( 'Top Competing Domains', 'peptide-geo-monitor' ) . '</th>'; echo '<th>' . esc_html__( 'Top Competing Domains', 'pr-vision' ) . '</th>';
echo '</tr></thead><tbody>'; echo '</tr></thead><tbody>';
foreach ( $standings as $row ) { foreach ( $standings as $row ) {

View File

@@ -1,22 +1,28 @@
<?php <?php
/**
* Contract for all PR Vision dashboard panels.
*
* @package PrVision
*/
declare(strict_types=1); 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. * Each panel renders one data category within the PR Vision admin page.
* v1 ships only PGM_Ai_Visibility_Panel. Future panels (keyword rankings, * v1 ships only PRV_Ai_Visibility_Panel. Future panels (keyword rankings,
* schema coverage…) implement this and register with PGM_Collector_Registry. * 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. * Dependencies: None — pure interface.
* *
* @see class-pgm-ai-visibility-panel.php — v1 implementation. * @see class-prv-ai-visibility-panel.php — v1 implementation.
* @see class-pgm-collector-registry.php — Registration hub. * @see class-prv-collector-registry.php — Registration hub.
* @see ARCHITECTURE.md — §Collector/Panel seam. * @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. * Render the panel HTML into the current output buffer.

View File

@@ -1,4 +1,10 @@
<?php <?php
/**
* Detects whether peptiderepo.com appears in LLM source citations.
*
* @package PrVision
*/
declare(strict_types=1); declare(strict_types=1);
/** /**
@@ -8,13 +14,13 @@ declare(strict_types=1);
* without duplication. Also parses raw citation arrays/URLs into a clean * without duplication. Also parses raw citation arrays/URLs into a clean
* domain list. * domain list.
* *
* Who triggers: PGM_Perplexity_Provider, PGM_OpenRouter_Provider. * Who triggers: PRV_Perplexity_Provider, PRV_OpenRouter_Provider.
* Dependencies: None (pure PHP). * Dependencies: None (pure PHP).
* *
* @see CONTEXT.md — "cited", "our_position", "source_domains". * @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. * 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 ); $host = (string) wp_parse_url( $url, PHP_URL_HOST );
if ( '' !== $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(). * @param string[] $domains Parsed domain list from parse_domains().
* *
* @return bool * @return bool
*/ */
public function is_cited( array $domains ): 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. * Returns null when the target domain is not found.
* *
@@ -71,7 +78,7 @@ class PGM_Citation_Detector {
* @return int|null * @return int|null
*/ */
public function get_our_position( array $domains ): ?int { 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 ) { if ( false === $pos ) {
return null; return null;
} }

View File

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

View File

@@ -1,4 +1,10 @@
<?php <?php
/**
* Generic OpenRouter provider for GPT-search and Gemini-search models.
*
* @package PrVision
*/
declare(strict_types=1); declare(strict_types=1);
/** /**
@@ -6,7 +12,7 @@ declare(strict_types=1);
* *
* One class handles any OpenRouter-routed model. The model identifier is set * One class handles any OpenRouter-routed model. The model identifier is set
* at construction time; the runner instantiates one provider per model from * at construction time; the runner instantiates one provider per model from
* PGM_Config::get_models(). * PRV_Config::get_models().
* *
* GPT-search (openai/gpt-4o-search-preview) and Gemini-search * GPT-search (openai/gpt-4o-search-preview) and Gemini-search
* (google/gemini-2.0-flash-001) do not return a structured citations array * (google/gemini-2.0-flash-001) do not return a structured citations array
@@ -14,18 +20,18 @@ declare(strict_types=1);
* text or from any 'annotations' key the model may include. * text or from any 'annotations' key the model may include.
* *
* Required WP-config constant: * Required WP-config constant:
* PGM_OPENROUTER_API_KEY — OpenRouter key (sk-or-…) * PRV_OPENROUTER_API_KEY — OpenRouter key (sk-or-…)
* *
* Who triggers: PGM_Probe_Runner via PGM_Probe_Provider interface. * Who triggers: PRV_Probe_Runner via PRV_Probe_Provider interface.
* Dependencies: PGM_Gateway_Client, PGM_Citation_Detector, PGM_Probe_Result. * Dependencies: PRV_Gateway_Client, PRV_Citation_Detector, PRV_Probe_Result.
* *
* @see interface-pgm-probe-provider.php — Interface this implements. * @see interface-prv-probe-provider.php — Interface this implements.
* @see class-pgm-gateway-client.php — Shared HTTP + retry logic. * @see class-prv-gateway-client.php — Shared HTTP + retry logic.
* @see class-pgm-citation-detector.php — Domain extraction + detection. * @see class-prv-citation-detector.php — Domain extraction + detection.
* @see ARCHITECTURE.md — §Provider implementations. * @see ARCHITECTURE.md — §Provider implementations.
* @package PeptideGeoMonitor * @package PrVision
*/ */
class PGM_OpenRouter_Provider implements PGM_Probe_Provider { class PRV_OpenRouter_Provider implements PRV_Probe_Provider {
/** /**
* Estimated cost per probe call in USD (conservative cross-model default). * Estimated cost per probe call in USD (conservative cross-model default).
@@ -33,45 +39,57 @@ class PGM_OpenRouter_Provider implements PGM_Probe_Provider {
const ESTIMATED_COST_PER_PROBE = 0.003; const ESTIMATED_COST_PER_PROBE = 0.003;
/** /**
* @var string OpenRouter model identifier (e.g. "openai/gpt-4o-search-preview"). * OpenRouter model identifier (e.g. "openai/gpt-4o-search-preview").
*
* @var string
*/ */
private string $model; private string $model;
/** /**
* @var PGM_Gateway_Client * HTTP gateway client.
*
* @var PRV_Gateway_Client
*/ */
private PGM_Gateway_Client $gateway; private PRV_Gateway_Client $gateway;
/** /**
* @var PGM_Citation_Detector * Citation domain detector.
*
* @var PRV_Citation_Detector
*/ */
private PGM_Citation_Detector $detector; private PRV_Citation_Detector $detector;
/** /**
* @param string $model OpenRouter model identifier. * Constructor.
* @param PGM_Gateway_Client|null $gateway Injected for testing. *
* @param PGM_Citation_Detector|null $detector Injected for testing. * @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, ?PGM_Gateway_Client $gateway = null, ?PGM_Citation_Detector $detector = null ) { public function __construct( string $model, ?PRV_Gateway_Client $gateway = null, ?PRV_Citation_Detector $detector = null ) {
$this->model = $model; $this->model = $model;
$this->gateway = $gateway ?? new PGM_Gateway_Client(); $this->gateway = $gateway ?? new PRV_Gateway_Client();
$this->detector = $detector ?? new PGM_Citation_Detector(); $this->detector = $detector ?? new PRV_Citation_Detector();
} }
/** /**
* {@inheritDoc} * {@inheritDoc}
* *
* @param string $query * @param string $query The search query to probe.
* @return PGM_Probe_Result *
* @return PRV_Probe_Result
* @throws \RuntimeException On permanent API failure. * @throws \RuntimeException On permanent API failure.
*/ */
public function probe( string $query ): PGM_Probe_Result { public function probe( string $query ): PRV_Probe_Result {
$api_key = $this->resolve_api_key(); $api_key = $this->resolve_api_key();
$body = array( $body = array(
'model' => $this->model, 'model' => $this->model,
'messages' => array( 'messages' => array(
array( 'role' => 'user', 'content' => $query ), array(
'role' => 'user',
'content' => $query,
),
), ),
'max_tokens' => 512, 'max_tokens' => 512,
); );
@@ -82,7 +100,7 @@ class PGM_OpenRouter_Provider implements PGM_Probe_Provider {
} }
/** /**
* Parse the OpenRouter response into a PGM_Probe_Result. * Parse the OpenRouter response into a PRV_Probe_Result.
* *
* Attempts to extract citations from: * Attempts to extract citations from:
* 1. `annotations` array (some models include structured source references). * 1. `annotations` array (some models include structured source references).
@@ -90,9 +108,9 @@ class PGM_OpenRouter_Provider implements PGM_Probe_Provider {
* *
* @param array<string, mixed> $data Decoded API response. * @param array<string, mixed> $data Decoded API response.
* *
* @return PGM_Probe_Result * @return PRV_Probe_Result
*/ */
public function parse_response( array $data ): PGM_Probe_Result { public function parse_response( array $data ): PRV_Probe_Result {
$content = ''; $content = '';
if ( isset( $data['choices'][0]['message']['content'] ) ) { if ( isset( $data['choices'][0]['message']['content'] ) ) {
$content = (string) $data['choices'][0]['message']['content']; $content = (string) $data['choices'][0]['message']['content'];
@@ -126,7 +144,7 @@ class PGM_OpenRouter_Provider implements PGM_Probe_Provider {
$cost_usd = (float) $data['usage']['total_tokens'] * 0.000002; $cost_usd = (float) $data['usage']['total_tokens'] * 0.000002;
} }
return new PGM_Probe_Result( $raw_excerpt, $domains, $cited, $our_position, $cost_usd ); return new PRV_Probe_Result( $raw_excerpt, $domains, $cited, $our_position, $cost_usd );
} }
/** /**
@@ -140,7 +158,7 @@ class PGM_OpenRouter_Provider implements PGM_Probe_Provider {
* {@inheritDoc} * {@inheritDoc}
*/ */
public function is_configured(): bool { public function is_configured(): bool {
return defined( 'PGM_OPENROUTER_API_KEY' ) && '' !== PGM_OPENROUTER_API_KEY; return defined( 'PRV_OPENROUTER_API_KEY' ) && '' !== PRV_OPENROUTER_API_KEY;
} }
/** /**
@@ -150,11 +168,10 @@ class PGM_OpenRouter_Provider implements PGM_Probe_Provider {
* @throws \RuntimeException When the constant is not defined or empty. * @throws \RuntimeException When the constant is not defined or empty.
*/ */
private function resolve_api_key(): string { private function resolve_api_key(): string {
if ( ! defined( 'PGM_OPENROUTER_API_KEY' ) || '' === PGM_OPENROUTER_API_KEY ) { if ( ! defined( 'PRV_OPENROUTER_API_KEY' ) || '' === PRV_OPENROUTER_API_KEY ) {
throw new \RuntimeException( // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception message, not HTML output
__( 'PGM_OPENROUTER_API_KEY constant is not defined. Add it to wp-config.php.', 'peptide-geo-monitor' ) throw new \RuntimeException( __( 'PRV_OPENROUTER_API_KEY constant is not defined. Add it to wp-config.php.', 'pr-vision' ) );
);
} }
return PGM_OPENROUTER_API_KEY; return PRV_OPENROUTER_API_KEY;
} }
} }

View File

@@ -1,4 +1,10 @@
<?php <?php
/**
* Perplexity sonar provider — primary citation signal.
*
* @package PrVision
*/
declare(strict_types=1); declare(strict_types=1);
/** /**
@@ -10,19 +16,19 @@ declare(strict_types=1);
* available (real web retrieval vs. training-time knowledge). * available (real web retrieval vs. training-time knowledge).
* *
* Required WP-config constant: * Required WP-config constant:
* PGM_OPENROUTER_API_KEY — OpenRouter key (sk-or-…) * PRV_OPENROUTER_API_KEY — OpenRouter key (sk-or-…)
* *
* Who triggers: PGM_Probe_Runner via the PGM_Probe_Provider interface. * Who triggers: PRV_Probe_Runner via the PRV_Probe_Provider interface.
* Dependencies: PGM_Gateway_Client, PGM_Citation_Detector, PGM_Probe_Result. * Dependencies: PRV_Gateway_Client, PRV_Citation_Detector, PRV_Probe_Result.
* *
* @see interface-pgm-probe-provider.php — Interface this implements. * @see interface-prv-probe-provider.php — Interface this implements.
* @see class-pgm-gateway-client.php — Shared HTTP + retry logic. * @see class-prv-gateway-client.php — Shared HTTP + retry logic.
* @see class-pgm-citation-detector.php — Domain extraction + detection. * @see class-prv-citation-detector.php — Domain extraction + detection.
* @see ARCHITECTURE.md — §Provider implementations. * @see ARCHITECTURE.md — §Provider implementations.
* @see CONTEXT.md — "Perplexity sonar", "citations". * @see CONTEXT.md — "Perplexity sonar", "citations".
* @package PeptideGeoMonitor * @package PrVision
*/ */
class PGM_Perplexity_Provider implements PGM_Probe_Provider { class PRV_Perplexity_Provider implements PRV_Probe_Provider {
/** /**
* Model identifier passed to OpenRouter for Perplexity routing. * Model identifier passed to OpenRouter for Perplexity routing.
@@ -36,22 +42,28 @@ class PGM_Perplexity_Provider implements PGM_Probe_Provider {
const ESTIMATED_COST_PER_PROBE = 0.005; const ESTIMATED_COST_PER_PROBE = 0.005;
/** /**
* @var PGM_Gateway_Client * HTTP gateway client.
*
* @var PRV_Gateway_Client
*/ */
private PGM_Gateway_Client $gateway; private PRV_Gateway_Client $gateway;
/** /**
* @var PGM_Citation_Detector * Citation domain detector.
*
* @var PRV_Citation_Detector
*/ */
private PGM_Citation_Detector $detector; private PRV_Citation_Detector $detector;
/** /**
* @param PGM_Gateway_Client|null $gateway Injected for testing; auto-created otherwise. * Constructor.
* @param PGM_Citation_Detector|null $detector Injected for testing; auto-created otherwise. *
* @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( ?PGM_Gateway_Client $gateway = null, ?PGM_Citation_Detector $detector = null ) { public function __construct( ?PRV_Gateway_Client $gateway = null, ?PRV_Citation_Detector $detector = null ) {
$this->gateway = $gateway ?? new PGM_Gateway_Client(); $this->gateway = $gateway ?? new PRV_Gateway_Client();
$this->detector = $detector ?? new PGM_Citation_Detector(); $this->detector = $detector ?? new PRV_Citation_Detector();
} }
/** /**
@@ -60,18 +72,21 @@ class PGM_Perplexity_Provider implements PGM_Probe_Provider {
* Sends the query as a user message to perplexity/sonar via OpenRouter. * Sends the query as a user message to perplexity/sonar via OpenRouter.
* Parses the citations[] array from the OpenRouter response envelope. * Parses the citations[] array from the OpenRouter response envelope.
* *
* @param string $query * @param string $query The search query to probe.
* *
* @return PGM_Probe_Result * @return PRV_Probe_Result
* @throws \RuntimeException On permanent API failure. * @throws \RuntimeException On permanent API failure.
*/ */
public function probe( string $query ): PGM_Probe_Result { public function probe( string $query ): PRV_Probe_Result {
$api_key = $this->resolve_api_key(); $api_key = $this->resolve_api_key();
$body = array( $body = array(
'model' => self::MODEL, 'model' => self::MODEL,
'messages' => array( 'messages' => array(
array( 'role' => 'user', 'content' => $query ), array(
'role' => 'user',
'content' => $query,
),
), ),
'max_tokens' => 512, 'max_tokens' => 512,
); );
@@ -82,15 +97,15 @@ class PGM_Perplexity_Provider implements PGM_Probe_Provider {
} }
/** /**
* Parse the OpenRouter/Perplexity response into a PGM_Probe_Result. * Parse the OpenRouter/Perplexity response into a PRV_Probe_Result.
* *
* Side effects: None. * Side effects: None.
* *
* @param array<string, mixed> $data Decoded API response. * @param array<string, mixed> $data Decoded API response.
* *
* @return PGM_Probe_Result * @return PRV_Probe_Result
*/ */
public function parse_response( array $data ): PGM_Probe_Result { public function parse_response( array $data ): PRV_Probe_Result {
// Extract text excerpt from the first choice. // Extract text excerpt from the first choice.
$content = ''; $content = '';
if ( isset( $data['choices'][0]['message']['content'] ) ) { if ( isset( $data['choices'][0]['message']['content'] ) ) {
@@ -115,7 +130,7 @@ class PGM_Perplexity_Provider implements PGM_Probe_Provider {
$cost_usd = (float) $data['usage']['total_tokens'] * 0.000001; $cost_usd = (float) $data['usage']['total_tokens'] * 0.000001;
} }
return new PGM_Probe_Result( $raw_excerpt, $domains, $cited, $our_position, $cost_usd ); return new PRV_Probe_Result( $raw_excerpt, $domains, $cited, $our_position, $cost_usd );
} }
/** /**
@@ -129,21 +144,20 @@ class PGM_Perplexity_Provider implements PGM_Probe_Provider {
* {@inheritDoc} * {@inheritDoc}
*/ */
public function is_configured(): bool { public function is_configured(): bool {
return defined( 'PGM_OPENROUTER_API_KEY' ) && '' !== PGM_OPENROUTER_API_KEY; return defined( 'PRV_OPENROUTER_API_KEY' ) && '' !== PRV_OPENROUTER_API_KEY;
} }
/** /**
* Retrieve the OpenRouter API key from wp-config constants. * Retrieve the OpenRouter API key from wp-config constants.
* *
* @return string * @return string The API key.
* @throws \RuntimeException When the constant is not defined or empty. * @throws \RuntimeException When the constant is not defined or empty.
*/ */
private function resolve_api_key(): string { private function resolve_api_key(): string {
if ( ! defined( 'PGM_OPENROUTER_API_KEY' ) || '' === PGM_OPENROUTER_API_KEY ) { if ( ! defined( 'PRV_OPENROUTER_API_KEY' ) || '' === PRV_OPENROUTER_API_KEY ) {
throw new \RuntimeException( // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception message, not HTML output
__( 'PGM_OPENROUTER_API_KEY constant is not defined. Add it to wp-config.php.', 'peptide-geo-monitor' ) throw new \RuntimeException( __( 'PRV_OPENROUTER_API_KEY constant is not defined. Add it to wp-config.php.', 'pr-vision' ) );
);
} }
return PGM_OPENROUTER_API_KEY; return PRV_OPENROUTER_API_KEY;
} }
} }

View File

@@ -1,4 +1,10 @@
<?php <?php
/**
* Contract for all LLM probe providers.
*
* @package PrVision
*/
declare(strict_types=1); declare(strict_types=1);
/** /**
@@ -8,34 +14,34 @@ declare(strict_types=1);
* OpenRouter GPT-search, OpenRouter Gemini-search, …). Swapping providers * OpenRouter GPT-search, OpenRouter Gemini-search, …). Swapping providers
* requires only a new class — the runner, ledger, and storage are agnostic. * 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. * Dependencies: None — pure interface.
* *
* @see class-pgm-perplexity-provider.php — Perplexity sonar implementation. * @see class-prv-perplexity-provider.php — Perplexity sonar implementation.
* @see class-pgm-openrouter-provider.php — OpenRouter generic implementation. * @see class-prv-openrouter-provider.php — OpenRouter generic implementation.
* @see class-pgm-probe-runner.php — Dispatches to providers via this interface. * @see class-prv-probe-runner.php — Dispatches to providers via this interface.
* @see CONTEXT.md — "provider", "probe". * @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. * Send a search/retrieval query and return a structured probe result.
* *
* Implementations MUST: * Implementations MUST:
* - Extract cited source domains from the model's response. * - 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). * - Record our 1-based position (null when not cited).
* - Return the call's estimated USD cost. * - Return the call's estimated USD cost.
* - Throw \RuntimeException on unrecoverable failure (caller logs + skips). * - Throw \RuntimeException on unrecoverable failure (caller logs + skips).
* *
* @param string $query The natural-language query to send. * @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. * @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"). * Get a short human-readable name for this provider (e.g. "Perplexity sonar").

View File

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

View File

@@ -1,18 +1,19 @@
<?php <?php
/** /**
* Plugin Name: Peptide GEO Monitor * Plugin Name: PR Vision
* Plugin URI: https://peptiderepo.com * 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. * 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 * Version: 0.1.1
* Author: peptiderepo * Author: peptiderepo
* Author URI: https://peptiderepo.com * Author URI: https://peptiderepo.com
* License: GPL-2.0-or-later * License: GPL-2.0-or-later
* Text Domain: peptide-geo-monitor * Text Domain: pr-vision
* Requires PHP: 8.1 * Requires PHP: 8.1
* *
* @see ARCHITECTURE.md — Full data flow and file tree. * @see ARCHITECTURE.md — Full data flow and file tree.
* @see CONVENTIONS.md — Naming patterns and extension guide. * @see CONVENTIONS.md — Naming patterns and extension guide.
* @see CONTEXT.md — Domain glossary incl. the visibility score formula. * @see CONTEXT.md — Domain glossary incl. the visibility score formula.
* @package PrVision
*/ */
declare(strict_types=1); declare(strict_types=1);
@@ -23,46 +24,46 @@ if ( ! defined( 'ABSPATH' ) ) {
/* ── Constants ────────────────────────────────────────────────────────── */ /* ── Constants ────────────────────────────────────────────────────────── */
define( 'PGM_VERSION', '0.1.0' ); define( 'PRV_VERSION', '0.1.1' );
define( 'PGM_PLUGIN_FILE', __FILE__ ); define( 'PRV_PLUGIN_FILE', __FILE__ );
define( 'PGM_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); define( 'PRV_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
define( 'PGM_PLUGIN_URL', plugin_dir_url( __FILE__ ) ); define( 'PRV_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
define( 'PGM_SCHEMA_VERSION', 1 ); define( 'PRV_SCHEMA_VERSION', 1 );
/** @var int Maximum HTTP retries for LLM API calls. */ /** Maximum HTTP retries for LLM API calls. @var int */
define( 'PGM_MAX_RETRIES', 3 ); define( 'PRV_MAX_RETRIES', 3 );
/** @var int API request timeout in seconds. */ /** API request timeout in seconds. @var int */
define( 'PGM_API_TIMEOUT_SECONDS', 60 ); define( 'PRV_API_TIMEOUT_SECONDS', 60 );
/** @var int Base backoff delay (seconds) between retries. */ /** Base backoff delay (seconds) between retries. @var int */
define( 'PGM_RETRY_BASE_DELAY_SECONDS', 2 ); define( 'PRV_RETRY_BASE_DELAY_SECONDS', 2 );
/** @var float Default monthly budget cap in USD. */ /** Default monthly budget cap in USD. @var float */
define( 'PGM_DEFAULT_MONTHLY_BUDGET_USD', 5.0 ); define( 'PRV_DEFAULT_MONTHLY_BUDGET_USD', 5.0 );
/** @var string WP-Cron hook name for the weekly probe run. */ /** WP-Cron hook name for the weekly probe run. @var string */
define( 'PGM_CRON_HOOK', 'pgm_weekly_probe' ); define( 'PRV_CRON_HOOK', 'prv_weekly_probe' );
/** @var string The site we are tracking citations for. */ /** The site we are tracking citations for. @var string */
define( 'PGM_TARGET_DOMAIN', 'peptiderepo.com' ); define( 'PRV_TARGET_DOMAIN', 'peptiderepo.com' );
/* ── Autoloader ───────────────────────────────────────────────────────── */ /* ── Autoloader ───────────────────────────────────────────────────────── */
require_once PGM_PLUGIN_DIR . 'includes/core/class-pgm-autoloader.php'; require_once PRV_PLUGIN_DIR . 'includes/core/class-prv-autoloader.php';
PGM_Autoloader::register(); PRV_Autoloader::register();
/* ── Activation / Deactivation ────────────────────────────────────────── */ /* ── Activation / Deactivation ────────────────────────────────────────── */
register_activation_hook( __FILE__, array( 'PGM_Activator', 'activate' ) ); register_activation_hook( __FILE__, array( 'PRV_Activator', 'activate' ) );
register_deactivation_hook( __FILE__, array( 'PGM_Deactivator', 'deactivate' ) ); register_deactivation_hook( __FILE__, array( 'PRV_Deactivator', 'deactivate' ) );
/* ── Boot ──────────────────────────────────────────────────────────────── */ /* ── Boot ──────────────────────────────────────────────────────────────── */
add_action( add_action(
'plugins_loaded', 'plugins_loaded',
static function (): void { static function (): void {
$plugin = new PGM_Plugin(); $plugin = new PRV_Plugin();
$plugin->init(); $plugin->init();
} }
); );

View File

@@ -5,7 +5,7 @@
* *
* Pattern mirrors peptide-repo-core's bootstrap (flat PHP, no PHPUnit). * Pattern mirrors peptide-repo-core's bootstrap (flat PHP, no PHPUnit).
* *
* @package PeptideGeoMonitor * @package PrVision
*/ */
declare(strict_types=1); declare(strict_types=1);
@@ -16,21 +16,21 @@ if ( ! defined( 'ABSPATH' ) ) {
define( 'ABSPATH', __DIR__ . '/' ); define( 'ABSPATH', __DIR__ . '/' );
} }
define( 'PGM_VERSION', '0.1.0' ); define( 'PRV_VERSION', '0.1.1' );
define( 'PGM_PLUGIN_FILE', __DIR__ . '/../peptide-geo-monitor.php' ); define( 'PRV_PLUGIN_FILE', __DIR__ . '/../pr-vision.php' );
define( 'PGM_PLUGIN_DIR', realpath( __DIR__ . '/..' ) . '/' ); define( 'PRV_PLUGIN_DIR', realpath( __DIR__ . '/..' ) . '/' );
define( 'PGM_PLUGIN_URL', 'http://example.test/wp-content/plugins/peptide-geo-monitor/' ); define( 'PRV_PLUGIN_URL', 'http://example.test/wp-content/plugins/pr-vision/' );
define( 'PGM_SCHEMA_VERSION', 1 ); define( 'PRV_SCHEMA_VERSION', 1 );
define( 'PGM_MAX_RETRIES', 3 ); define( 'PRV_MAX_RETRIES', 3 );
define( 'PGM_API_TIMEOUT_SECONDS', 60 ); define( 'PRV_API_TIMEOUT_SECONDS', 60 );
define( 'PGM_RETRY_BASE_DELAY_SECONDS', 2 ); define( 'PRV_RETRY_BASE_DELAY_SECONDS', 2 );
define( 'PGM_DEFAULT_MONTHLY_BUDGET_USD', 5.0 ); define( 'PRV_DEFAULT_MONTHLY_BUDGET_USD', 5.0 );
define( 'PGM_CRON_HOOK', 'pgm_weekly_probe' ); define( 'PRV_CRON_HOOK', 'prv_weekly_probe' );
define( 'PGM_TARGET_DOMAIN', 'peptiderepo.com' ); define( 'PRV_TARGET_DOMAIN', 'peptiderepo.com' );
/* ── Global test state ─────────────────────────────────────────────────── */ /* ── Global test state ─────────────────────────────────────────────────── */
$GLOBALS['pgm_test_state'] = [ $GLOBALS['prv_test_state'] = [
'options' => [], 'options' => [],
'actions' => [], 'actions' => [],
'wpdb_insert_id' => 1, 'wpdb_insert_id' => 1,
@@ -40,8 +40,8 @@ $GLOBALS['pgm_test_state'] = [
'remote_posts' => [], 'remote_posts' => [],
]; ];
function pgm_test_reset(): void { function prv_test_reset(): void {
$GLOBALS['pgm_test_state'] = [ $GLOBALS['prv_test_state'] = [
'options' => [], 'options' => [],
'actions' => [], 'actions' => [],
'wpdb_insert_id' => 1, 'wpdb_insert_id' => 1,
@@ -50,29 +50,29 @@ function pgm_test_reset(): void {
'cron_events' => [], 'cron_events' => [],
'remote_posts' => [], 'remote_posts' => [],
]; ];
PGM_Collector_Registry::reset_for_testing(); PRV_Collector_Registry::reset_for_testing();
} }
/* ── WordPress function stubs ──────────────────────────────────────────── */ /* ── WordPress function stubs ──────────────────────────────────────────── */
function get_option( string $name, $default = false ) { 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 { function update_option( string $name, $value ): bool {
$GLOBALS['pgm_test_state']['options'][ $name ] = $value; $GLOBALS['prv_test_state']['options'][ $name ] = $value;
return true; return true;
} }
function add_option( string $name, $value = '' ): bool { function add_option( string $name, $value = '' ): bool {
if ( ! isset( $GLOBALS['pgm_test_state']['options'][ $name ] ) ) { if ( ! isset( $GLOBALS['prv_test_state']['options'][ $name ] ) ) {
$GLOBALS['pgm_test_state']['options'][ $name ] = $value; $GLOBALS['prv_test_state']['options'][ $name ] = $value;
} }
return true; return true;
} }
function add_action( string $hook, $callback, int $priority = 10, int $accepted_args = 1 ): bool { function add_action( string $hook, $callback, int $priority = 10, int $accepted_args = 1 ): bool {
$GLOBALS['pgm_test_state']['actions'][] = compact( 'hook', 'callback', 'priority' ); $GLOBALS['prv_test_state']['actions'][] = compact( 'hook', 'callback', 'priority' );
return true; return true;
} }
@@ -83,19 +83,19 @@ function remove_action( string $hook, $callback, int $priority = 10 ): bool {
function add_menu_page( ...$args ): void {} function add_menu_page( ...$args ): void {}
function wp_schedule_event( int $timestamp, string $recurrence, string $hook ): 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;
} }
function wp_next_scheduled( string $hook ) { function wp_next_scheduled( string $hook ) {
return $GLOBALS['pgm_test_state']['cron_events'][ $hook ] ?? false; return $GLOBALS['prv_test_state']['cron_events'][ $hook ] ?? false;
} }
function wp_unschedule_event( int $timestamp, string $hook ): void { 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 { 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 wp_rand( int $min = 0, int $max = 0 ): int { function wp_rand( int $min = 0, int $max = 0 ): int {
@@ -180,7 +180,7 @@ function is_wp_error( $thing ): bool {
function wp_remote_post( string $url, array $args = array() ) { function wp_remote_post( string $url, array $args = array() ) {
// Return from test queue if set, else a generic success. // Return from test queue if set, else a generic success.
$queue = &$GLOBALS['pgm_test_state']['remote_posts']; $queue = &$GLOBALS['prv_test_state']['remote_posts'];
if ( ! empty( $queue ) ) { if ( ! empty( $queue ) ) {
return array_shift( $queue ); return array_shift( $queue );
} }
@@ -220,17 +220,17 @@ class stdClass_wpdb {
} }
public function query( string $sql ): bool { return true; } public function query( string $sql ): bool { return true; }
public function insert( string $table, array $data, array $format = [] ): int { public function insert( string $table, array $data, array $format = [] ): int {
$this->insert_id = $GLOBALS['pgm_test_state']['wpdb_insert_id']++; $this->insert_id = $GLOBALS['prv_test_state']['wpdb_insert_id']++;
return 1; return 1;
} }
public function update( string $table, array $data, array $where, array $data_format = [], array $where_format = [] ): int { public function update( string $table, array $data, array $where, array $data_format = [], array $where_format = [] ): int {
return 1; return 1;
} }
public function get_results( string $query, string $output = 'OBJECT' ): array { 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'] : []; return is_array( $GLOBALS['prv_test_state']['wpdb_results'] ) ? $GLOBALS['prv_test_state']['wpdb_results'] : [];
} }
public function get_var( string $query ): mixed { public function get_var( string $query ): mixed {
return $GLOBALS['pgm_test_state']['wpdb_var']; return $GLOBALS['prv_test_state']['wpdb_var'];
} }
} }
@@ -240,35 +240,35 @@ function dbDelta( string $sql ): array { return []; }
/* ── Test assertion helpers ────────────────────────────────────────────── */ /* ── 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 ) { if ( $condition ) {
$GLOBALS['pgm_test_report']['pass']++; $GLOBALS['prv_test_report']['pass']++;
echo " PASS: {$label}\n"; echo " PASS: {$label}\n";
} else { } else {
$GLOBALS['pgm_test_report']['fail']++; $GLOBALS['prv_test_report']['fail']++;
$GLOBALS['pgm_test_report']['failures'][] = $label; $GLOBALS['prv_test_report']['failures'][] = $label;
echo " FAIL: {$label}\n"; 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 ); $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 { try {
$fn(); $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 ) { } 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 { function prv_test_summary(): int {
$r = $GLOBALS['pgm_test_report']; $r = $GLOBALS['prv_test_report'];
echo "\n---\n"; echo "\n---\n";
echo "Totals: {$r['pass']} passed, {$r['fail']} failed\n"; echo "Totals: {$r['pass']} passed, {$r['fail']} failed\n";
if ( $r['fail'] > 0 ) { if ( $r['fail'] > 0 ) {
@@ -283,8 +283,8 @@ function pgm_test_summary(): int {
/* ── Load autoloader + all plugin classes ──────────────────────────────── */ /* ── Load autoloader + all plugin classes ──────────────────────────────── */
require_once __DIR__ . '/../includes/core/class-pgm-autoloader.php'; require_once __DIR__ . '/../includes/core/class-prv-autoloader.php';
PGM_Autoloader::register(); PRV_Autoloader::register();
require_once __DIR__ . '/../includes/providers/interface-pgm-probe-provider.php'; require_once __DIR__ . '/../includes/providers/interface-prv-probe-provider.php';
require_once __DIR__ . '/../includes/collector/interface-pgm-data-collector.php'; require_once __DIR__ . '/../includes/collector/interface-prv-data-collector.php';
require_once __DIR__ . '/../includes/panel/interface-pgm-dashboard-panel.php'; require_once __DIR__ . '/../includes/panel/interface-prv-dashboard-panel.php';

View File

@@ -1,74 +1,74 @@
<?php <?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); declare(strict_types=1);
require_once __DIR__ . '/../bootstrap.php'; require_once __DIR__ . '/../bootstrap.php';
echo "=== PGM Citation Detector Tests ===\n"; echo "=== PRV Citation Detector Tests ===\n";
// ── Helpers ────────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────────
$detector = new PGM_Citation_Detector(); $detector = new PRV_Citation_Detector();
// ── Test: parse plain URL strings ──────────────────────────────────────── // ── Test: parse plain URL strings ────────────────────────────────────────
pgm_test_reset(); prv_test_reset();
$domains = $detector->parse_domains( [ $domains = $detector->parse_domains( [
'https://peptiderepo.com/bpc-157/', 'https://peptiderepo.com/bpc-157/',
'https://www.examine.com/supplements/bpc-157/', 'https://www.examine.com/supplements/bpc-157/',
'https://pubmed.ncbi.nlm.nih.gov/12345', 'https://pubmed.ncbi.nlm.nih.gov/12345',
] ); ] );
pgm_assert( in_array( 'peptiderepo.com', $domains, true ), 'parse_domains: peptiderepo.com extracted from URL' ); prv_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' ); prv_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.' ); prv_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_equals( 3, count( $domains ), 'parse_domains: exactly 3 unique domains' );
// ── Test: parse object-style citations (Perplexity format) ─────────────── // ── Test: parse object-style citations (Perplexity format) ───────────────
pgm_test_reset(); prv_test_reset();
$domains = $detector->parse_domains( [ $domains = $detector->parse_domains( [
[ 'url' => 'https://peptiderepo.com/tb-500/' ], [ 'url' => 'https://peptiderepo.com/tb-500/' ],
[ 'url' => 'https://examine.com/tb500' ], [ 'url' => 'https://examine.com/tb500' ],
[ 'url' => 'https://peptiderepo.com/bpc-157/' ], // duplicate domain [ 'url' => 'https://peptiderepo.com/bpc-157/' ], // duplicate domain
] ); ] );
pgm_assert_equals( 2, count( $domains ), 'parse_domains: object-style citations, duplicates removed' ); prv_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( in_array( 'peptiderepo.com', $domains, true ), 'parse_domains: object-style, peptiderepo.com present' );
// ── Test: parse empty input ────────────────────────────────────────────── // ── Test: parse empty input ──────────────────────────────────────────────
pgm_test_reset(); prv_test_reset();
$domains = $detector->parse_domains( [] ); $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 ───────────────────────────────────────────── // ── Test: is_cited detection ─────────────────────────────────────────────
pgm_test_reset(); prv_test_reset();
pgm_assert( $detector->is_cited( ['peptiderepo.com', 'examine.com'] ), 'is_cited: returns true when target present' ); prv_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' ); prv_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_assert( ! $detector->is_cited( [] ), 'is_cited: returns false for empty list' );
// ── Test: get_our_position ─────────────────────────────────────────────── // ── Test: get_our_position ───────────────────────────────────────────────
pgm_test_reset(); prv_test_reset();
$domains = ['examine.com', 'peptiderepo.com', 'pubmed.ncbi.nlm.nih.gov']; $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']; $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']; $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 ─────────────────────────────────── // ── Test: malformed / unexpected input ───────────────────────────────────
pgm_test_reset(); prv_test_reset();
$domains = $detector->parse_domains( [ $domains = $detector->parse_domains( [
'not-a-url', 'not-a-url',
42, 42,
@@ -76,6 +76,21 @@ $domains = $detector->parse_domains( [
['no_url_key' => 'foo'], ['no_url_key' => 'foo'],
['url' => ''], ['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

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

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

View File

@@ -1,34 +1,34 @@
<?php <?php
/** /**
* Tests for PGM_Probe_Runner: budget-cap abort, in-run caching, and run mechanics. * Tests for PRV_Probe_Runner: budget-cap abort and run mechanics.
* *
* 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. * and a mock ledger to verify runner behaviour without real API calls.
* *
* @package PeptideGeoMonitor * @package PrVision
*/ */
declare(strict_types=1); declare(strict_types=1);
require_once __DIR__ . '/../bootstrap.php'; require_once __DIR__ . '/../bootstrap.php';
echo "=== PGM Probe Runner Tests ===\n"; echo "=== PRV Probe Runner Tests ===\n";
// ── Define API key constants (required by providers) ────────────────────── // ── Define API key constants (required by providers) ──────────────────────
if ( ! defined( 'PGM_OPENROUTER_API_KEY' ) ) { if ( ! defined( 'PRV_OPENROUTER_API_KEY' ) ) {
define( 'PGM_OPENROUTER_API_KEY', 'sk-or-test-key' ); define( 'PRV_OPENROUTER_API_KEY', 'sk-or-test-key' );
} }
if ( ! defined( 'PGM_CF_ACCOUNT_ID' ) ) { if ( ! defined( 'PRV_CF_ACCOUNT_ID' ) ) {
define( 'PGM_CF_ACCOUNT_ID', '' ); define( 'PRV_CF_ACCOUNT_ID', '' );
} }
if ( ! defined( 'PGM_CF_GATEWAY_ID' ) ) { if ( ! defined( 'PRV_CF_GATEWAY_ID' ) ) {
define( 'PGM_CF_GATEWAY_ID', '' ); define( 'PRV_CF_GATEWAY_ID', '' );
} }
// ── Mock ledger: tracks afford calls, simulates exhaustion ──────────────── // ── 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 $afford_count = 0;
public int $no_afford_count = 0; public int $no_afford_count = 0;
public int $exhaust_after = PHP_INT_MAX; public int $exhaust_after = PHP_INT_MAX;
@@ -57,124 +57,124 @@ $cited_body = json_encode([
// ── Test: basic run completes — 1×1×1 (peptide × intent × model) ───────── // ── Test: basic run completes — 1×1×1 (peptide × intent × model) ─────────
pgm_test_reset(); prv_test_reset();
$GLOBALS['pgm_test_state']['options']['pgm_peptides'] = [['slug'=>'bpc-157','label'=>'BPC-157']]; $GLOBALS['prv_test_state']['options']['prv_peptides'] = [['slug'=>'bpc-157','label'=>'BPC-157']];
$GLOBALS['pgm_test_state']['options']['pgm_prompt_intents'] = ['what is {peptide}']; $GLOBALS['prv_test_state']['options']['prv_prompt_intents'] = ['what is {peptide}'];
$GLOBALS['pgm_test_state']['options']['pgm_models'] = ['perplexity/sonar']; $GLOBALS['prv_test_state']['options']['prv_models'] = ['perplexity/sonar'];
$GLOBALS['pgm_test_state']['remote_posts'] = [ $GLOBALS['prv_test_state']['remote_posts'] = [
['response' => ['code' => 200], 'body' => $cited_body], ['response' => ['code' => 200], 'body' => $cited_body],
]; ];
$ledger1 = new PGM_Mock_Ledger(); $ledger1 = new PRV_Mock_Ledger();
$runner1 = new PGM_Probe_Runner( $ledger1 ); $runner1 = new PRV_Probe_Runner( $ledger1 );
$counts1 = $runner1->run(); $counts1 = $runner1->run();
pgm_assert_equals( 1, $counts1['probed'], 'basic run: 1 probed' ); prv_assert_equals( 1, $counts1['probed'], 'basic run: 1 probed' );
pgm_assert_equals( 0, $counts1['skipped_budget'], 'basic run: 0 budget skips' ); prv_assert_equals( 0, $counts1['skipped_budget'], 'basic run: 0 budget skips' );
pgm_assert_equals( 0, $counts1['skipped_error'], 'basic run: 0 error skips' ); prv_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, $ledger1->afford_count, 'basic run: 1 can_afford check' );
// ── Test: 2×1×1 run completes all probes when budget sufficient ─────────── // ── Test: 2×1×1 run completes all probes when budget sufficient ───────────
pgm_test_reset(); prv_test_reset();
$GLOBALS['pgm_test_state']['options']['pgm_peptides'] = [ $GLOBALS['prv_test_state']['options']['prv_peptides'] = [
['slug'=>'bpc-157','label'=>'BPC-157'], ['slug'=>'bpc-157','label'=>'BPC-157'],
['slug'=>'tb-500','label'=>'TB-500'], ['slug'=>'tb-500','label'=>'TB-500'],
]; ];
$GLOBALS['pgm_test_state']['options']['pgm_prompt_intents'] = ['what is {peptide}']; $GLOBALS['prv_test_state']['options']['prv_prompt_intents'] = ['what is {peptide}'];
$GLOBALS['pgm_test_state']['options']['pgm_models'] = ['perplexity/sonar']; $GLOBALS['prv_test_state']['options']['prv_models'] = ['perplexity/sonar'];
$GLOBALS['pgm_test_state']['remote_posts'] = [ $GLOBALS['prv_test_state']['remote_posts'] = [
['response' => ['code' => 200], 'body' => $cited_body], ['response' => ['code' => 200], 'body' => $cited_body],
['response' => ['code' => 200], 'body' => $cited_body], ['response' => ['code' => 200], 'body' => $cited_body],
]; ];
$ledger2 = new PGM_Mock_Ledger(); $ledger2 = new PRV_Mock_Ledger();
$runner2 = new PGM_Probe_Runner( $ledger2 ); $runner2 = new PRV_Probe_Runner( $ledger2 );
$counts2 = $runner2->run(); $counts2 = $runner2->run();
pgm_assert_equals( 2, $counts2['probed'], '2×1×1 run: 2 probes completed' ); prv_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, $ledger2->afford_count, '2×1×1 run: 2 can_afford checks' );
// ── Test: budget cap abort after N probes ──────────────────────────────── // ── Test: budget cap abort after N probes ────────────────────────────────
pgm_test_reset(); prv_test_reset();
$GLOBALS['pgm_test_state']['options']['pgm_peptides'] = [ $GLOBALS['prv_test_state']['options']['prv_peptides'] = [
['slug'=>'bpc-157','label'=>'BPC-157'], ['slug'=>'bpc-157','label'=>'BPC-157'],
['slug'=>'tb-500','label'=>'TB-500'], ['slug'=>'tb-500','label'=>'TB-500'],
['slug'=>'mk-677','label'=>'MK-677'], ['slug'=>'mk-677','label'=>'MK-677'],
]; ];
$GLOBALS['pgm_test_state']['options']['pgm_prompt_intents'] = ['what is {peptide}']; $GLOBALS['prv_test_state']['options']['prv_prompt_intents'] = ['what is {peptide}'];
$GLOBALS['pgm_test_state']['options']['pgm_models'] = ['perplexity/sonar']; $GLOBALS['prv_test_state']['options']['prv_models'] = ['perplexity/sonar'];
// Queue 3 responses but cap budget at 1. // Queue 3 responses but cap budget at 1.
$GLOBALS['pgm_test_state']['remote_posts'] = [ $GLOBALS['prv_test_state']['remote_posts'] = [
['response' => ['code' => 200], 'body' => $cited_body], ['response' => ['code' => 200], 'body' => $cited_body],
['response' => ['code' => 200], 'body' => $cited_body], ['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 = new PRV_Mock_Ledger();
$exhausting_ledger->exhaust_after = 1; // Budget exhausted after 1st can_afford check. $exhausting_ledger->exhaust_after = 1; // Budget exhausted after 1st can_afford check.
$runner3 = new PGM_Probe_Runner( $exhausting_ledger ); $runner3 = new PRV_Probe_Runner( $exhausting_ledger );
$counts3 = $runner3->run(); $counts3 = $runner3->run();
pgm_assert_equals( 1, $counts3['probed'], 'budget abort: 1 probe completed before cap' ); prv_assert_equals( 1, $counts3['probed'], 'budget abort: 1 probe completed before cap' );
pgm_assert( $counts3['skipped_budget'] >= 2, 'budget abort: ≥2 probes skipped (budget)' ); prv_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( 0, $counts3['skipped_error'], 'budget abort: 0 error skips' );
// ── Test: HTTP error counts as skipped_error ───────────────────────────── // ── Test: HTTP error counts as skipped_error ─────────────────────────────
pgm_test_reset(); prv_test_reset();
$GLOBALS['pgm_test_state']['options']['pgm_peptides'] = [['slug'=>'bpc-157','label'=>'BPC-157']]; $GLOBALS['prv_test_state']['options']['prv_peptides'] = [['slug'=>'bpc-157','label'=>'BPC-157']];
$GLOBALS['pgm_test_state']['options']['pgm_prompt_intents'] = ['what is {peptide}']; $GLOBALS['prv_test_state']['options']['prv_prompt_intents'] = ['what is {peptide}'];
$GLOBALS['pgm_test_state']['options']['pgm_models'] = ['perplexity/sonar']; $GLOBALS['prv_test_state']['options']['prv_models'] = ['perplexity/sonar'];
// All retries fail with 500. // All retries fail with 500.
$GLOBALS['pgm_test_state']['remote_posts'] = [ $GLOBALS['prv_test_state']['remote_posts'] = [
['response' => ['code' => 500], 'body' => 'Error'], ['response' => ['code' => 500], 'body' => 'Error'],
['response' => ['code' => 500], 'body' => 'Error'], ['response' => ['code' => 500], 'body' => 'Error'],
['response' => ['code' => 500], 'body' => 'Error'], ['response' => ['code' => 500], 'body' => 'Error'],
]; ];
$ledger4 = new PGM_Mock_Ledger(); $ledger4 = new PRV_Mock_Ledger();
$runner4 = new PGM_Probe_Runner( $ledger4 ); $runner4 = new PRV_Probe_Runner( $ledger4 );
$counts4 = $runner4->run(); $counts4 = $runner4->run();
pgm_assert_equals( 0, $counts4['probed'], 'HTTP 500: 0 probes' ); prv_assert_equals( 0, $counts4['probed'], 'HTTP 500: 0 probes' );
pgm_assert( $counts4['skipped_error'] >= 1, 'HTTP 500: ≥1 error skip recorded' ); prv_assert( $counts4['skipped_error'] >= 1, 'HTTP 500: ≥1 error skip recorded' );
// ── Test: WP_Error from wp_remote_post counts as skipped_error ────────── // ── Test: WP_Error from wp_remote_post counts as skipped_error ──────────
pgm_test_reset(); prv_test_reset();
$GLOBALS['pgm_test_state']['options']['pgm_peptides'] = [['slug'=>'bpc-157','label'=>'BPC-157']]; $GLOBALS['prv_test_state']['options']['prv_peptides'] = [['slug'=>'bpc-157','label'=>'BPC-157']];
$GLOBALS['pgm_test_state']['options']['pgm_prompt_intents'] = ['what is {peptide}']; $GLOBALS['prv_test_state']['options']['prv_prompt_intents'] = ['what is {peptide}'];
$GLOBALS['pgm_test_state']['options']['pgm_models'] = ['perplexity/sonar']; $GLOBALS['prv_test_state']['options']['prv_models'] = ['perplexity/sonar'];
// Return WP_Error for all retries. // Return WP_Error for all retries.
$wp_error = new WP_Error( 'http_request_failed', 'cURL error 28: Operation timed out' ); $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(); $ledger5 = new PRV_Mock_Ledger();
$runner5 = new PGM_Probe_Runner( $ledger5 ); $runner5 = new PRV_Probe_Runner( $ledger5 );
$counts5 = $runner5->run(); $counts5 = $runner5->run();
pgm_assert_equals( 0, $counts5['probed'], 'WP_Error: 0 probes completed' ); prv_assert_equals( 0, $counts5['probed'], 'WP_Error: 0 probes completed' );
pgm_assert( $counts5['skipped_error'] >= 1, 'WP_Error: ≥1 error skip recorded' ); prv_assert( $counts5['skipped_error'] >= 1, 'WP_Error: ≥1 error skip recorded' );
// ── Test: run returns correct keys in counts array ─────────────────────── // ── Test: run returns correct keys in counts array ───────────────────────
pgm_test_reset(); prv_test_reset();
$GLOBALS['pgm_test_state']['options']['pgm_peptides'] = [['slug'=>'bpc-157','label'=>'BPC-157']]; $GLOBALS['prv_test_state']['options']['prv_peptides'] = [['slug'=>'bpc-157','label'=>'BPC-157']];
$GLOBALS['pgm_test_state']['options']['pgm_prompt_intents'] = ['what is {peptide}']; $GLOBALS['prv_test_state']['options']['prv_prompt_intents'] = ['what is {peptide}'];
$GLOBALS['pgm_test_state']['options']['pgm_models'] = ['perplexity/sonar']; $GLOBALS['prv_test_state']['options']['prv_models'] = ['perplexity/sonar'];
$GLOBALS['pgm_test_state']['remote_posts'] = [ $GLOBALS['prv_test_state']['remote_posts'] = [
['response' => ['code' => 200], 'body' => $cited_body], ['response' => ['code' => 200], 'body' => $cited_body],
]; ];
$ledger6 = new PGM_Mock_Ledger(); $ledger6 = new PRV_Mock_Ledger();
$runner6 = new PGM_Probe_Runner( $ledger6 ); $runner6 = new PRV_Probe_Runner( $ledger6 );
$counts6 = $runner6->run(); $counts6 = $runner6->run();
pgm_assert( array_key_exists( 'probed', $counts6 ), 'run: returns probed key' ); prv_assert( array_key_exists( 'probed', $counts6 ), 'run: returns probed key' );
pgm_assert( array_key_exists( 'skipped_budget', $counts6 ), 'run: returns skipped_budget key' ); prv_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( 'skipped_error', $counts6 ), 'run: returns skipped_error key' );
exit( pgm_test_summary() ); exit( prv_test_summary() );

View File

@@ -2,25 +2,25 @@
/** /**
* Tests for provider response parsing. * Tests for provider response parsing.
* *
* Exercises PGM_Perplexity_Provider::parse_response() and * Exercises PRV_Perplexity_Provider::parse_response() and
* PGM_OpenRouter_Provider::parse_response() with mock HTTP responses, * PRV_OpenRouter_Provider::parse_response() with mock HTTP responses,
* verifying domain extraction, citation detection, and cost calculation. * verifying domain extraction, citation detection, and cost calculation.
* *
* @package PeptideGeoMonitor * @package PrVision
*/ */
declare(strict_types=1); declare(strict_types=1);
require_once __DIR__ . '/../bootstrap.php'; 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 // Test: cited via citations array
pgm_test_reset(); prv_test_reset();
$data = [ $data = [
'choices' => [[ 'message' => [ 'content' => 'BPC-157 is a peptide. See peptiderepo.com' ] ]], 'choices' => [[ 'message' => [ 'content' => 'BPC-157 is a peptide. See peptiderepo.com' ] ]],
'citations' => [ 'citations' => [
@@ -30,34 +30,34 @@ $data = [
'usage' => [ 'total_tokens' => 500 ], 'usage' => [ 'total_tokens' => 500 ],
]; ];
$result = $provider->parse_response( $data ); $result = $provider->parse_response( $data );
pgm_assert( $result->is_cited(), 'Perplexity: cited=true when peptiderepo.com in citations' ); prv_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' ); prv_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' ); prv_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_equals( 500 * 0.000001, $result->get_cost_usd(), 'Perplexity: cost from token usage' );
// Test: not cited, no citations // Test: not cited, no citations
pgm_test_reset(); prv_test_reset();
$data_empty = [ $data_empty = [
'choices' => [[ 'message' => [ 'content' => 'BPC-157 info here.' ] ]], 'choices' => [[ 'message' => [ 'content' => 'BPC-157 info here.' ] ]],
'citations' => [], 'citations' => [],
'usage' => [ 'total_tokens' => 200 ], 'usage' => [ 'total_tokens' => 200 ],
]; ];
$result_empty = $provider->parse_response( $data_empty ); $result_empty = $provider->parse_response( $data_empty );
pgm_assert( ! $result_empty->is_cited(), 'Perplexity: cited=false with empty citations' ); prv_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_equals( null, $result_empty->get_our_position(), 'Perplexity: position=null when not cited' );
// Test: excerpt truncated to 500 chars // Test: excerpt truncated to 500 chars
pgm_test_reset(); prv_test_reset();
$long_content = str_repeat( 'a', 600 ); $long_content = str_repeat( 'a', 600 );
$data_long = [ $data_long = [
'choices' => [[ 'message' => [ 'content' => $long_content ] ]], 'choices' => [[ 'message' => [ 'content' => $long_content ] ]],
'citations' => [], 'citations' => [],
]; ];
$result_long = $provider->parse_response( $data_long ); $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 // Test: object-style citations
pgm_test_reset(); prv_test_reset();
$data_obj = [ $data_obj = [
'choices' => [[ 'message' => [ 'content' => 'answer' ] ]], 'choices' => [[ 'message' => [ 'content' => 'answer' ] ]],
'citations' => [ 'citations' => [
@@ -66,26 +66,26 @@ $data_obj = [
], ],
]; ];
$result_obj = $provider->parse_response( $data_obj ); $result_obj = $provider->parse_response( $data_obj );
pgm_assert( $result_obj->is_cited(), 'Perplexity: object-style citations — cited=true' ); prv_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_equals( 1, $result_obj->get_our_position(), 'Perplexity: object-style — position=1' );
// Test: fallback cost when no usage data // Test: fallback cost when no usage data
pgm_test_reset(); prv_test_reset();
$data_no_usage = [ $data_no_usage = [
'choices' => [[ 'message' => [ 'content' => 'answer' ] ]], 'choices' => [[ 'message' => [ 'content' => 'answer' ] ]],
'citations' => [], 'citations' => [],
]; ];
$result_no_usage = $provider->parse_response( $data_no_usage ); $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"; 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) // Test: annotations path (structured citations)
pgm_test_reset(); prv_test_reset();
$data_ann = [ $data_ann = [
'choices' => [[ 'choices' => [[
'message' => [ 'message' => [
@@ -99,11 +99,11 @@ $data_ann = [
'usage' => [ 'total_tokens' => 300 ], 'usage' => [ 'total_tokens' => 300 ],
]; ];
$result_ann = $or_provider->parse_response( $data_ann ); $result_ann = $or_provider->parse_response( $data_ann );
pgm_assert( $result_ann->is_cited(), 'OpenRouter: annotations path — cited=true' ); prv_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_equals( 1, $result_ann->get_our_position(), 'OpenRouter: annotations path — position=1' );
// Test: regex URL extraction fallback // Test: regex URL extraction fallback
pgm_test_reset(); prv_test_reset();
$data_regex = [ $data_regex = [
'choices' => [[ 'choices' => [[
'message' => [ 'message' => [
@@ -113,23 +113,23 @@ $data_regex = [
'usage' => [ 'total_tokens' => 150 ], 'usage' => [ 'total_tokens' => 150 ],
]; ];
$result_regex = $or_provider->parse_response( $data_regex ); $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 // Test: not cited
pgm_test_reset(); prv_test_reset();
$data_no_cite = [ $data_no_cite = [
'choices' => [[ 'message' => [ 'content' => 'Some response without our domain.' ] ]], 'choices' => [[ 'message' => [ 'content' => 'Some response without our domain.' ] ]],
]; ];
$result_no_cite = $or_provider->parse_response( $data_no_cite ); $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 // Test: cost calculation from token usage
pgm_test_reset(); prv_test_reset();
$data_cost = [ $data_cost = [
'choices' => [[ 'message' => [ 'content' => 'test' ] ]], 'choices' => [[ 'message' => [ 'content' => 'test' ] ]],
'usage' => [ 'total_tokens' => 1000 ], 'usage' => [ 'total_tokens' => 1000 ],
]; ];
$result_cost = $or_provider->parse_response( $data_cost ); $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

@@ -1,28 +1,28 @@
<?php <?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): * Score formula (CONTEXT.md):
* base_score = cited_probes / total_probes * base_score = cited_probes / total_probes
* position_bonus = Σ(1/our_position for cited) / total_probes * position_bonus = Σ(1/our_position for cited) / total_probes
* visibility_score = round((base_score + position_bonus) / 2, 4) * visibility_score = round((base_score + position_bonus) / 2, 4)
* *
* @package PeptideGeoMonitor * @package PrVision
*/ */
declare(strict_types=1); declare(strict_types=1);
require_once __DIR__ . '/../bootstrap.php'; 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). // 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 ────────────────────────────────────────── // ── Test: zero total → score 0 ──────────────────────────────────────────
pgm_test_reset(); prv_test_reset();
pgm_assert_equals( 0.0, $collector->compute_score( 0, 0, 0.0 ), 'score: zero total returns 0.0' ); prv_assert_equals( 0.0, $collector->compute_score( 0, 0, 0.0 ), 'score: zero total returns 0.0' );
// ── Test: all cited, position 1 ───────────────────────────────────────── // ── 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 // base_score = 10/10 = 1.0
// position_bonus = (1+1+1+…)/10 = 10/10 = 1.0 // position_bonus = (1+1+1+…)/10 = 10/10 = 1.0
// score = (1.0 + 1.0) / 2 = 1.0 // score = (1.0 + 1.0) / 2 = 1.0
pgm_test_reset(); prv_test_reset();
pgm_assert_equals( 1.0, $collector->compute_score( 10, 10, 10.0 ), 'score: all cited at position 1 → 1.0' ); prv_assert_equals( 1.0, $collector->compute_score( 10, 10, 10.0 ), 'score: all cited at position 1 → 1.0' );
// ── Test: none cited ──────────────────────────────────────────────────── // ── Test: none cited ────────────────────────────────────────────────────
// 10 probes, 0 cited, position_sum=0 // 10 probes, 0 cited, position_sum=0
// base_score = 0, position_bonus = 0 → score = 0 // base_score = 0, position_bonus = 0 → score = 0
pgm_test_reset(); prv_test_reset();
pgm_assert_equals( 0.0, $collector->compute_score( 0, 10, 0.0 ), 'score: none cited → 0.0' ); prv_assert_equals( 0.0, $collector->compute_score( 0, 10, 0.0 ), 'score: none cited → 0.0' );
// ── Test: half cited at position 2 ────────────────────────────────────── // ── 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 // base_score = 5/10 = 0.5
// position_bonus = 2.5/10 = 0.25 // position_bonus = 2.5/10 = 0.25
// score = (0.5 + 0.25) / 2 = 0.375 // score = (0.5 + 0.25) / 2 = 0.375
pgm_test_reset(); prv_test_reset();
$score = $collector->compute_score( 5, 10, 2.5 ); $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 ───────────────────────────── // ── 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 // base_score = 1.0
// position_bonus = 0.3333 // position_bonus = 0.3333
// score = (1.0 + 0.3333) / 2 ≈ 0.6667 (rounded to 4 decimals) // 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 ); $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] ─────────────────────────────────────── // ── Test: score is bounded [0, 1] ───────────────────────────────────────
// Extreme: position_sum could theoretically exceed total but that's logically // Extreme: position_sum could theoretically exceed total but that's logically
// impossible with 1-based positions (position_sum ≤ cited_count ≤ total). // impossible with 1-based positions (position_sum ≤ cited_count ≤ total).
// Verify edge case: 1 probe, 1 cited, position 1 // Verify edge case: 1 probe, 1 cited, position 1
pgm_test_reset(); prv_test_reset();
pgm_assert( $collector->compute_score( 1, 1, 1.0 ) <= 1.0, 'score: never exceeds 1.0' ); prv_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_assert( $collector->compute_score( 0, 10, 0.0 ) >= 0.0, 'score: never below 0.0' );
// ── Test: precision — 4 decimal places ────────────────────────────────── // ── Test: precision — 4 decimal places ──────────────────────────────────
pgm_test_reset(); prv_test_reset();
$score = $collector->compute_score( 3, 7, 1.5 ); $score = $collector->compute_score( 3, 7, 1.5 );
// base = 3/7 ≈ 0.4286, bonus = 1.5/7 ≈ 0.2143, avg ≈ 0.3214 // 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 ); $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

@@ -2,31 +2,31 @@
/** /**
* Tests for uninstall purge: table drop + options deletion. * Tests for uninstall purge: table drop + options deletion.
* *
* Exercises PGM_Table_Manager::drop_table() and verifies the uninstall * Exercises PRV_Table_Manager::drop_table() and verifies the uninstall
* script logic (simulated because it requires WP_UNINSTALL_PLUGIN). * script logic (simulated because it requires WP_UNINSTALL_PLUGIN).
* *
* @package PeptideGeoMonitor * @package PrVision
*/ */
declare(strict_types=1); declare(strict_types=1);
require_once __DIR__ . '/../bootstrap.php'; require_once __DIR__ . '/../bootstrap.php';
echo "=== PGM Uninstall Purge Tests ===\n"; echo "=== PRV Uninstall Purge Tests ===\n";
// ── Mock $wpdb to track DROP calls ────────────────────────────────────── // ── Mock $wpdb to track DROP calls ──────────────────────────────────────
$GLOBALS['pgm_test_state']['drop_calls'] = []; $GLOBALS['prv_test_state']['drop_calls'] = [];
// Extend the stub to record DROP statements. // Extend the stub to record DROP statements.
global $wpdb; global $wpdb;
$wpdb = new class extends stdClass_wpdb { $wpdb = new class extends stdClass_wpdb {
public function query( string $sql ): bool { public function query( string $sql ): bool {
if ( str_contains( strtoupper( $sql ), 'DROP TABLE' ) ) { if ( str_contains( strtoupper( $sql ), 'DROP TABLE' ) ) {
$GLOBALS['pgm_test_state']['drop_calls'][] = $sql; $GLOBALS['prv_test_state']['drop_calls'][] = $sql;
} }
if ( str_contains( strtoupper( $sql ), 'DELETE FROM' ) && str_contains( $sql, "pgm\\_" ) ) { if ( str_contains( strtoupper( $sql ), 'DELETE FROM' ) && str_contains( $sql, "prv\\_" ) ) {
$GLOBALS['pgm_test_state']['options_deleted'] = true; $GLOBALS['prv_test_state']['options_deleted'] = true;
} }
return true; return true;
} }
@@ -34,68 +34,68 @@ $wpdb = new class extends stdClass_wpdb {
// ── Test: drop_table issues DROP TABLE IF EXISTS ───────────────────────── // ── Test: drop_table issues DROP TABLE IF EXISTS ─────────────────────────
pgm_test_reset(); prv_test_reset();
$GLOBALS['pgm_test_state']['drop_calls'] = []; $GLOBALS['prv_test_state']['drop_calls'] = [];
PGM_Table_Manager::drop_table(); PRV_Table_Manager::drop_table();
pgm_assert( ! empty( $GLOBALS['pgm_test_state']['drop_calls'] ), 'drop_table: DROP TABLE query executed' ); prv_assert( ! empty( $GLOBALS['prv_test_state']['drop_calls'] ), 'drop_table: DROP TABLE query executed' );
$drop_sql = $GLOBALS['pgm_test_state']['drop_calls'][0] ?? ''; $drop_sql = $GLOBALS['prv_test_state']['drop_calls'][0] ?? '';
pgm_assert( str_contains( strtoupper( $drop_sql ), 'DROP TABLE IF EXISTS' ), 'drop_table: uses DROP TABLE IF EXISTS' ); prv_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' ); prv_assert( str_contains( $drop_sql, 'prv_ai_visibility' ), 'drop_table: targets prv_ai_visibility table' );
// ── Test: create_table sets the schema_version option ──────────────────── // ── Test: create_table sets the schema_version option ────────────────────
pgm_test_reset(); prv_test_reset();
PGM_Table_Manager::create_table(); PRV_Table_Manager::create_table();
$schema_ver = get_option( 'pgm_schema_version' ); $schema_ver = get_option( 'prv_schema_version' );
pgm_assert_equals( PGM_SCHEMA_VERSION, $schema_ver, 'create_table: pgm_schema_version option set after create' ); prv_assert_equals( PRV_SCHEMA_VERSION, $schema_ver, 'create_table: prv_schema_version option set after create' );
// ── Test: get_table_name returns prefixed name ─────────────────────────── // ── Test: get_table_name returns prefixed name ───────────────────────────
pgm_test_reset(); prv_test_reset();
$table = PGM_Table_Manager::get_table_name(); $table = PRV_Table_Manager::get_table_name();
pgm_assert_equals( 'wp_pgm_ai_visibility', $table, 'get_table_name: returns wp_ prefixed table name' ); prv_assert_equals( 'wp_prv_ai_visibility', $table, 'get_table_name: returns wp_ prefixed table name' );
// ── Test: seed_defaults writes expected option keys ────────────────────── // ── Test: seed_defaults writes expected option keys ──────────────────────
pgm_test_reset(); prv_test_reset();
PGM_Config::seed_defaults(); PRV_Config::seed_defaults();
pgm_assert( isset( $GLOBALS['pgm_test_state']['options']['pgm_monthly_budget_usd'] ), 'seed_defaults: pgm_monthly_budget_usd set' ); prv_assert( isset( $GLOBALS['prv_test_state']['options']['prv_monthly_budget_usd'] ), 'seed_defaults: prv_monthly_budget_usd set' );
pgm_assert( isset( $GLOBALS['pgm_test_state']['options']['pgm_peptides'] ), 'seed_defaults: pgm_peptides set' ); prv_assert( isset( $GLOBALS['prv_test_state']['options']['prv_peptides'] ), 'seed_defaults: prv_peptides set' );
pgm_assert( isset( $GLOBALS['pgm_test_state']['options']['pgm_prompt_intents'] ), 'seed_defaults: pgm_prompt_intents set' ); prv_assert( isset( $GLOBALS['prv_test_state']['options']['prv_prompt_intents'] ), 'seed_defaults: prv_prompt_intents set' );
pgm_assert( isset( $GLOBALS['pgm_test_state']['options']['pgm_models'] ), 'seed_defaults: pgm_models set' ); prv_assert( isset( $GLOBALS['prv_test_state']['options']['prv_models'] ), 'seed_defaults: prv_models set' );
// ── Test: seed_defaults does not overwrite existing values ─────────────── // ── Test: seed_defaults does not overwrite existing values ───────────────
pgm_test_reset(); prv_test_reset();
$GLOBALS['pgm_test_state']['options']['pgm_monthly_budget_usd'] = 99.0; $GLOBALS['prv_test_state']['options']['prv_monthly_budget_usd'] = 99.0;
PGM_Config::seed_defaults(); PRV_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' ); prv_assert_equals( 99.0, $GLOBALS['prv_test_state']['options']['prv_monthly_budget_usd'], 'seed_defaults: does not overwrite existing prv_monthly_budget_usd' );
// ── Test: default budget is PGM_DEFAULT_MONTHLY_BUDGET_USD ────────────── // ── Test: default budget is PRV_DEFAULT_MONTHLY_BUDGET_USD ──────────────
pgm_test_reset(); prv_test_reset();
PGM_Config::seed_defaults(); PRV_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)' ); prv_assert_equals( PRV_DEFAULT_MONTHLY_BUDGET_USD, $GLOBALS['prv_test_state']['options']['prv_monthly_budget_usd'], 'seed_defaults: default budget = PRV_DEFAULT_MONTHLY_BUDGET_USD (5.0)' );
// ── Test: default peptide count is 12 ─────────────────────────────────── // ── Test: default peptide count is 12 ───────────────────────────────────
pgm_test_reset(); prv_test_reset();
$peptides = PGM_Config::get_peptides(); $peptides = PRV_Config::get_peptides();
pgm_assert_equals( 12, count( $peptides ), 'get_peptides: default seed has 12 peptides' ); prv_assert_equals( 12, count( $peptides ), 'get_peptides: default seed has 12 peptides' );
// ── Test: default prompt intent count is 3 ────────────────────────────── // ── Test: default prompt intent count is 3 ──────────────────────────────
pgm_test_reset(); prv_test_reset();
$intents = PGM_Config::get_prompt_intents(); $intents = PRV_Config::get_prompt_intents();
pgm_assert_equals( 3, count( $intents ), 'get_prompt_intents: default seed has 3 intents' ); prv_assert_equals( 3, count( $intents ), 'get_prompt_intents: default seed has 3 intents' );
// ── Test: default model count is 3 ────────────────────────────────────── // ── Test: default model count is 3 ──────────────────────────────────────
pgm_test_reset(); prv_test_reset();
$models = PGM_Config::get_models(); $models = PRV_Config::get_models();
pgm_assert_equals( 3, count( $models ), 'get_models: default seed has 3 models' ); prv_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' ); prv_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,14 @@
<?php <?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 * Drops the custom prv_ai_visibility table and deletes every wp_options
* row whose name starts with "pgm_". WP-Cron events are already cleared * row whose name starts with "prv_". WP-Cron events are already cleared
* by PGM_Deactivator on deactivation; this runs after that. * by PRV_Deactivator on deactivation; this runs after that.
* *
* @see ARCHITECTURE.md — §Uninstall specification. * @see ARCHITECTURE.md — §Uninstall specification.
* @see class-pgm-deactivator.php — Clears cron on deactivation. * @see class-prv-deactivator.php — Clears cron on deactivation.
* @package PrVision
*/ */
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) { if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
@@ -18,17 +19,17 @@ global $wpdb;
/* ── 1. Drop the custom table ─────────────────────────────────────── */ /* ── 1. Drop the custom table ─────────────────────────────────────── */
$table = $wpdb->prefix . 'pgm_ai_visibility'; $table = $wpdb->prefix . 'prv_ai_visibility';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared // 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}" ); $wpdb->query( "DROP TABLE IF EXISTS {$table}" );
/* ── 2. Delete all pgm_ prefixed options ──────────────────────────── */ /* ── 2. Delete all prv_ prefixed options ──────────────────────────── */
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->query( $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. Clear any remaining scheduled cron events ─────────────────── */
wp_clear_scheduled_hook( 'pgm_weekly_probe' ); wp_clear_scheduled_hook( 'prv_weekly_probe' );