Files
pr-vision/ARCHITECTURE.md
peptiderepo 56c276405a split: extract PRV_Settings_Controller from settings-page (300-line fix)
class-prv-settings-page.php was 346 lines — the only file over the
300-line cap. Extract the four POST handlers (handle_save,
handle_model_add, handle_model_update, handle_model_remove) into a
new PRV_Settings_Controller class (class-prv-settings-controller.php).

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

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

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

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

Version: 0.2.0
2026-06-14 07:34:12 +00:00

213 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Architecture — PR Vision
**Version:** 0.2.0 | **Last updated:** 2026-06-14
---
## 1. Overview
`pr-vision` is an internal WordPress plugin that runs configurable server-side LLM probes to measure whether peptiderepo.com appears in AI citations across core peptide queries. It stores a time-series in a custom table and renders an admin dashboard (trendline + standings) and a full Settings page (model manager + probe config).
**v1 scope:** AI-visibility only. The collector/panel seam is present so future data categories can be added as new classes.
**v2 additions:** Model manager CRUD (no deploy needed), config-version tracking, run-lock, projected cost, API-key three-state, dark "Assay" Settings UI.
---
## 2. File tree
```
pr-vision/
├── pr-vision.php # Plugin boot: constants, autoloader, hooks
├── uninstall.php # Full data teardown: DROP TABLE + DELETE prv_* options + transients
├── composer.json # Dev deps (PHPCS only)
├── phpcs.xml.dist # PHPCS config (WordPress standard)
├── includes/
│ ├── core/
│ │ ├── class-prv-autoloader.php # SPL autoloader: PRV_* -> includes/**
│ │ ├── class-prv-plugin.php # Orchestrator: boots sub-systems + upgrader
│ │ ├── class-prv-upgrader.php # [v0.2] Runs all migrations on every plugins_loaded
│ │ ├── class-prv-activator.php # Activation: table + defaults + cron
│ │ ├── class-prv-deactivator.php # Deactivation: clear cron
│ │ ├── class-prv-table-manager.php # dbDelta create/drop + config_version column (v0.2)
│ │ ├── class-prv-config.php # Typed getters + seed_defaults() + get_projected_cost()
│ │ ├── class-prv-model-registry.php # [v0.2] prv_models CRUD + v1->v2 migration + health
│ │ ├── class-prv-config-version.php # [v0.2] Config-version stamps + bump on change
│ │ ├── class-prv-run-lock.php # [v0.2] Transient-based run-lock (cron + Run now)
│ │ ├── class-prv-cron.php # Probe cron: schedule/reschedule/clear; skip if locked
│ │ ├── class-prv-cost-ledger.php # MTD cost + hard monthly cap enforcement
│ │ ├── class-prv-probe-result.php # Immutable value object from a probe call
│ │ ├── class-prv-probe-runner.php # Orchestrates run: lock + peptide*intent*model loop
│ │ ├── class-prv-collector-registry.php # Singleton registry for collectors + panels
│ │ ├── class-prv-admin-page.php # Dashboard page: menu, Run now, rendering
│ │ ├── class-prv-settings-page.php # [v0.2] Settings page: menu, Run-now, AJAX dispatch; delegates POST to PRV_Settings_Controller
│ │ ├── class-prv-settings-controller.php # [v0.2] POST handler impl: save config, model CRUD (split from settings-page)
│ │ ├── class-prv-settings-renderer.php # [v0.2] Settings HTML renderer (dark Assay theme)
│ │ ├── class-prv-model-manager-table.php # [v0.2] Model manager table + Add form renderer
│ │ └── class-prv-model-test-ajax.php # [v0.2] Rate-limited AJAX handler for Test button
│ ├── providers/
│ │ ├── interface-prv-probe-provider.php # probe(query): PRV_Probe_Result contract
│ │ ├── class-prv-gateway-client.php # Cloudflare AI Gateway HTTP + retry
│ │ ├── class-prv-citation-detector.php # Domain extraction + cite detection
│ │ ├── class-prv-perplexity-provider.php # Perplexity sonar via OpenRouter/gateway
│ │ └── class-prv-openrouter-provider.php # Generic OpenRouter (GPT-search, Gemini)
│ ├── collector/
│ │ ├── interface-prv-data-collector.php # collect(): array seam
│ │ └── class-prv-ai-visibility-collector.php # AI-visibility data from DB
│ └── panel/
│ ├── interface-prv-dashboard-panel.php # render(data): void seam
│ └── class-prv-ai-visibility-panel.php # Renders trendline + standings
├── tests/
│ ├── bootstrap.php # WP-stub bootstrap (no PHPUnit)
│ └── unit/
│ ├── test-citation-detector.php # Domain parsing + cite detection
│ ├── test-score-calc.php # Visibility score formula
│ ├── test-cost-ledger.php # MTD cost + budget cap
│ ├── test-provider-parsing.php # Mock-HTTP provider response parsing
│ ├── test-cron.php # Schedule/clear + activation/deactivation
│ ├── test-probe-runner.php # Run mechanics + budget abort + lock + health
│ ├── test-uninstall.php # Table drop + option purge (incl. v0.2 keys)
│ ├── test-model-registry.php # [v0.2] Migration, CRUD, run-health
│ ├── test-run-lock.php # [v0.2] Acquire/release/sentinel
│ ├── test-config-version.php # [v0.2] Hash, bump, version stamp
│ ├── test-projected-cost.php # [v0.2] probe_count, over_cap, truncation flag
│ └── test-cron-reschedule.php # [v0.2] Reschedule, idempotent, tick guard
└── .github/workflows/ci.yml # PHP lint matrix + PHPCS + 300-line check
```
---
## 3. Data flow
```
WP-Cron (scheduled cadence) ─────────────────────────────────────┐
Admin "Run now" (Settings POST + nonce) ──────────────────────────┤
↓
PRV_Run_Lock::acquire() ← refuse if busy
↓
PRV_Probe_Runner::run()
│
┌───────────────────────────────────────┤
│ for each peptide × intent × model │
│ │
│ PRV_Cost_Ledger::can_afford() │
│ └─ ABORT gracefully if at cap │
│ │
│ PRV_Probe_Provider::probe(query) │
│ └─ PRV_Gateway_Client │
│ └─ Cloudflare AI Gateway │
│ └─ OpenRouter / LLM │
│ │
│ PRV_Citation_Detector │
│ │
│ $wpdb->insert(prv_ai_visibility) │
│ └─ config_version column stamped │
│ PRV_Cost_Ledger::update_row_cost() │
└───────────────────────────────────────┘
│
PRV_Model_Registry::update_health()
PRV_Run_Lock::release()
│
Admin page load ──────────────────────────────────────────────────┤
↓
PRV_Admin_Page::render_page()
│
PRV_Collector_Registry
│
PRV_Ai_Visibility_Collector::collect()
PRV_Ai_Visibility_Panel::render()
│
Browser (Chart.js)
(config-change annotations via config_versions option)
```
---
## 4. Database
**Table:** `{prefix}prv_ai_visibility`
| Column | Type | Notes |
|--------|------|-------|
| id | BIGINT UNSIGNED PK | Auto-increment |
| run_id | VARCHAR(36) | UUID per run |
| captured_at | DATETIME | UTC |
| peptide_slug | VARCHAR(200) | e.g. "bpc-157" |
| peptide_label | VARCHAR(200) | e.g. "BPC-157" |
| model | VARCHAR(200) | e.g. "perplexity/sonar" |
| prompt_intent | VARCHAR(200) | Template string |
| cited | TINYINT(1) | 1 = peptiderepo.com in sources |
| our_position | INT NULL | 1-based position; NULL if not cited |
| source_domains | LONGTEXT | JSON array of domains |
| raw_excerpt | LONGTEXT | First 500 chars of LLM response |
| cost_usd | DECIMAL(12,8) | Actual call cost |
| config_version | INT NULL | [v0.2] Config version at run time; NULL for legacy rows |
Schema version: `prv_schema_version` = 2.
**Key options (v0.2.0)**
| Option | Type | Notes |
|--------|------|-------|
| prv_models | array | v2 rich-object model list; migrated from flat-string v1 |
| prv_models_schema_version | int | 2 when migration complete |
| prv_config_versions | array | All config-version records |
| prv_active_config_version | int | Currently active version number |
| prv_cadence | string | 'weekly'\|'daily'\|'twicedaily' |
| prv_api_key_status | string | 'ok'\|'failed'\|'unknown' |
| prv_api_key_last_check | string | Datetime of last status update |
| prv_last_run_at | string | Datetime of last run |
| prv_last_run_counts | array | {probed, skipped_budget, skipped_error, truncated, run_id} |
| prv_last_run_truncated | int | 1 when last run hit the budget cap mid-way |
| prv_last_run_truncated_at | string | Datetime of last truncation event |
| _transient_prv_run_lock | int | Holds Unix timestamp while a run is in progress (TTL 3600s) |
---
## 5. External API integrations
### Cloudflare AI Gateway
All LLM calls route through:
```
https://gateway.ai.cloudflare.com/v1/{PRV_CF_ACCOUNT_ID}/{PRV_CF_GATEWAY_ID}/openrouter
```
Falls back to direct OpenRouter when the constants are absent.
### Providers (v0.1 + v0.2 UI-managed)
| Provider | Model | Citation source |
|----------|-------|-----------------|
| PRV_Perplexity_Provider | `perplexity/sonar` | `citations[]` array |
| PRV_OpenRouter_Provider | any OpenRouter slug | annotations or inline URL regex |
Models are now managed via the Settings UI rather than hardcoded. The `prv_models` option is the source of truth; `PRV_Config::get_models()` delegates to `PRV_Model_Registry::get_enabled_slugs()`.
---
## 6. Collector / Panel seam
Same as v0.1.0 -- see CONTEXT.md for the extension guide.
---
## 7. Security
- Secrets read from `wp-config.php` constants at runtime -- never stored in DB.
- API key status shown in 3 states; key never displayed.
- All admin actions: `manage_options` check + nonce verification.
- All DB values escaped with `$wpdb->prepare()`.
- All HTML output escaped at render.
- AJAX `prv_test_model`: capability + nonce + 30s rate-limit transient.
---
## 8. Key decisions
| Decision | Rationale |
|----------|-----------|
| `prv_models_schema_version` for migration gate | Cheap option check; migration is idempotent and safe to call on every plugins_loaded without performance cost. |
| Config-version as hash + version-number | Hash detects changes; integer version is stable foreign key for run rows and chart annotations. Orphaned rows (NULL config_version or old version) are excluded from current-config scoring. |
| Run-lock as transient | WP transients work across all environments; TTL guards against stale locks from crashed runs; no separate table needed. |
| Projected cost from probe_count × fixed estimate | Conservative overestimate; avoids the need for per-model price config in v1; operator sees a safe upper bound. |
| Three-state API key (not two) | A defined-but-revoked key showed false-green in v1 (QA finding M4). The third state "defined, last run failed" closes that gap without ever displaying the key. |
| Sticky save-bar warning (not just card banner) | Card banner scrolls off; save-bar is always in viewport on a page with a tall model table (QA finding M2). |