PR Vision v0.2.0: admin/settings interface + correctness fixes

Requirements 1-8 + all non-negotiables:

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

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

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

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

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

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

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

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

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

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

Schema: config_version INT column added via dbDelta (idempotent).
Uninstall: purges all v0.2 prv_* options + transients.
This commit is contained in:
peptiderepo
2026-06-14 06:43:54 +00:00
parent 401077fe45
commit 8274d54a5e
26 changed files with 2758 additions and 435 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,22 +8,25 @@
declare(strict_types=1);
/**
* Orchestrates a full probe run: peptides × intents × models.
* Orchestrates a full probe run: peptides x intents x models.
*
* For each combination the runner:
* 1. Checks the monthly budget cap (aborts gracefully when hit).
* 2. Dispatches to the correct provider (resolved from PRV_Config::get_models()).
* 3. Writes a row to the prv_ai_visibility table.
* 4. Settles the actual cost against the ledger row.
* 1. Acquires the run-lock (fails fast if already locked).
* 2. Checks the monthly budget cap (aborts gracefully when hit).
* 3. Dispatches to the correct provider (resolved from PRV_Model_Registry).
* 4. Writes a row to the prv_ai_visibility table with config_version stamp.
* 5. Settles the actual cost against the ledger row.
* 6. After the run, updates per-model health and releases the lock.
*
* Who triggers: PRV_Cron::handle_cron_tick() and PRV_Admin_Page (Run now action).
* Dependencies: PRV_Config, PRV_Cost_Ledger, PRV_Table_Manager, all providers.
* Who triggers: PRV_Cron::handle_cron_tick() and PRV_Settings_Page (Run now).
* Dependencies: PRV_Config, PRV_Cost_Ledger, PRV_Table_Manager,
* PRV_Run_Lock, PRV_Model_Registry, PRV_Config_Version, all providers.
*
* @see class-prv-cron.php — Calls run() on the weekly schedule.
* @see class-prv-cost-ledger.php — Budget cap enforcement.
* @see class-prv-perplexity-provider.php
* @see class-prv-openrouter-provider.php
* @see ARCHITECTURE.md — §Probe run flow.
* @see class-prv-cron.php -- Calls run() on the weekly schedule.
* @see class-prv-cost-ledger.php -- Budget cap enforcement.
* @see class-prv-run-lock.php -- Concurrency guard.
* @see class-prv-model-registry.php -- Per-model health updates.
* @see ARCHITECTURE.md -- Section Probe run flow.
* @package PrVision
*/
class PRV_Probe_Runner {
@@ -47,27 +50,66 @@ class PRV_Probe_Runner {
/**
* Execute the full probe run.
*
* Iterates peptides × prompt intents × models. Aborts at the budget cap
* without throwing — partial results are kept. Each successful probe is
* Acquires a run-lock so the cron and "Run now" cannot collide.
* Iterates peptides x prompt intents x models. Aborts at the budget cap
* without throwing -- partial results are kept. Each successful probe is
* persisted immediately so a mid-run failure doesn't lose data.
*
* Side effects: HTTP calls to LLM APIs, database writes.
* Side effects: HTTP calls to LLM APIs, database writes, option writes (health).
*
* @return array{probed: int, skipped_budget: int, skipped_error: int}
* Summary counts for the admin log.
* @return array{probed: int, skipped_budget: int, skipped_error: int, truncated: bool, run_id: string}
* Summary counts plus truncation flag for the admin display.
*/
public function run(): array {
$run_id = $this->generate_run_id();
$peptides = PRV_Config::get_peptides();
$intents = PRV_Config::get_prompt_intents();
$models = PRV_Config::get_models();
$counts = array(
'probed' => 0,
'skipped_budget' => 0,
'skipped_error' => 0,
'truncated' => false,
'run_id' => '',
);
if ( ! PRV_Run_Lock::acquire() ) {
$counts['skipped_error'] = -1; // Sentinel: lock busy.
return $counts;
}
try {
$counts = $this->execute_run();
} finally {
PRV_Run_Lock::release();
}
return $counts;
}
/**
* Core run logic (called inside the lock).
*
* @return array{probed: int, skipped_budget: int, skipped_error: int, truncated: bool, run_id: string}
*/
private function execute_run(): array {
$run_id = $this->generate_run_id();
$config_ver = PRV_Config_Version::get_active_version();
$peptides = PRV_Config::get_peptides();
$intents = PRV_Config::get_prompt_intents();
$models = PRV_Config::get_models();
$model_outcomes = array(); // Per-slug: {probed, errors}.
foreach ( $models as $slug ) {
$model_outcomes[ $slug ] = array( 'probed' => 0, 'errors' => 0 );
}
$counts = array(
'probed' => 0,
'skipped_budget' => 0,
'skipped_error' => 0,
'truncated' => false,
'run_id' => $run_id,
);
$budget_hit = false;
foreach ( $peptides as $peptide ) {
foreach ( $intents as $intent_tpl ) {
$query = str_replace( '{peptide}', $peptide['label'], $intent_tpl );
@@ -77,6 +119,9 @@ class PRV_Probe_Runner {
if ( null === $provider || ! $provider->is_configured() ) {
++$counts['skipped_error'];
if ( isset( $model_outcomes[ $model ] ) ) {
++$model_outcomes[ $model ]['errors'];
}
continue;
}
@@ -84,6 +129,7 @@ class PRV_Probe_Runner {
$estimated = $this->get_estimated_cost( $model );
if ( ! $this->ledger->can_afford( $estimated ) ) {
++$counts['skipped_budget'];
$budget_hit = true;
continue;
}
@@ -93,34 +139,70 @@ class PRV_Probe_Runner {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( sprintf( 'PRV: probe failed [%s / %s]: %s', $model, $peptide['slug'], $e->getMessage() ) );
++$counts['skipped_error'];
if ( isset( $model_outcomes[ $model ] ) ) {
++$model_outcomes[ $model ]['errors'];
}
continue;
}
$row_id = $this->persist_result( $run_id, $peptide, $model, $intent_tpl, $result );
$row_id = $this->persist_result( $run_id, $peptide, $model, $intent_tpl, $result, $config_ver );
if ( $row_id > 0 ) {
$this->ledger->update_row_cost( $row_id, $result->get_cost_usd() );
}
++$counts['probed'];
if ( isset( $model_outcomes[ $model ] ) ) {
++$model_outcomes[ $model ]['probed'];
}
}
}
}
if ( $budget_hit && $counts['probed'] > 0 ) {
$counts['truncated'] = true;
}
// Record truncation state for dashboard display.
if ( $counts['truncated'] ) {
update_option( 'prv_last_run_truncated', 1 );
update_option( 'prv_last_run_truncated_at', current_time( 'mysql', true ) );
} else {
update_option( 'prv_last_run_truncated', 0 );
}
// Persist run summary for dashboard.
update_option( 'prv_last_run_at', current_time( 'mysql', true ) );
update_option( 'prv_last_run_counts', $counts );
// Update per-model run-health.
PRV_Model_Registry::update_health( $run_id, $model_outcomes );
// Update API-key status (derive from whether probes succeeded).
$this->update_api_key_status( $counts, $model_outcomes );
return $counts;
}
/**
* Persist a single probe result to the database.
* Persist a single probe result to the database with config_version stamp.
*
* @param string $run_id UUID for this run.
* @param array<string,string> $peptide Peptide config with slug and label.
* @param string $model Model identifier.
* @param string $intent_tpl Raw intent template string.
* @param PRV_Probe_Result $result Probe result.
* @param int $config_ver Config version at run time.
*
* @return int Inserted row ID, or 0 on failure.
*/
private function persist_result( string $run_id, array $peptide, string $model, string $intent_tpl, PRV_Probe_Result $result ): int {
private function persist_result(
string $run_id,
array $peptide,
string $model,
string $intent_tpl,
PRV_Probe_Result $result,
int $config_ver
): int {
global $wpdb;
$table = PRV_Table_Manager::get_table_name();
@@ -140,13 +222,31 @@ class PRV_Probe_Runner {
'source_domains' => wp_json_encode( $result->get_source_domains() ),
'raw_excerpt' => $result->get_raw_excerpt(),
'cost_usd' => number_format( $result->get_cost_usd(), 8, '.', '' ),
'config_version' => $config_ver,
),
array( '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%s', '%s', '%s' )
array( '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%s', '%s', '%s', '%d' )
);
return (int) $wpdb->insert_id;
}
/**
* Derive + persist the API-key status from run outcomes.
*
* @param array<string, mixed> $counts Run counts.
* @param array<string, array{probed: int, errors: int}> $model_outcomes Model outcomes.
*
* @return void
*/
private function update_api_key_status( array $counts, array $model_outcomes ): void {
if ( $counts['probed'] > 0 ) {
update_option( 'prv_api_key_status', 'ok' );
} elseif ( $counts['skipped_error'] > 0 && 0 === $counts['probed'] ) {
update_option( 'prv_api_key_status', 'failed' );
}
update_option( 'prv_api_key_last_check', current_time( 'mysql', true ) );
}
/**
* Instantiate the correct provider class for a model string.
*

View File

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

View File

@@ -0,0 +1,291 @@
<?php
/**
* PR Vision admin settings page: model manager + config surface.
*
* @package PrVision
*/
declare(strict_types=1);
/**
* PR Vision Settings admin page.
*
* Registers "PR Vision > Settings" sub-menu, handles POST actions (save config,
* model CRUD, Run now) and the test-model AJAX hook. Rendering is delegated
* to PRV_Settings_Renderer; test AJAX to PRV_Model_Test_Ajax.
*
* All actions require: current_user_can('manage_options') + valid nonce.
*
* Who triggers: PRV_Plugin::init() -- is_admin() guard.
* Dependencies: PRV_Model_Registry, PRV_Config, PRV_Cron, PRV_Run_Lock,
* PRV_Config_Version, PRV_Settings_Renderer, PRV_Model_Test_Ajax.
*
* @see class-prv-settings-renderer.php -- Renders the HTML.
* @see class-prv-model-test-ajax.php -- AJAX test handler.
* @see class-prv-model-registry.php -- CRUD for prv_models.
* @see class-prv-config-version.php -- Config-change versioning.
* @see class-prv-cron.php -- Reschedule on cadence change.
* @package PrVision
*/
class PRV_Settings_Page {
/** Admin menu slug for the settings page. */
const MENU_SLUG = 'pr-vision-settings';
/** Nonce action for settings save. */
const NONCE_SAVE = 'prv_settings_save';
/** Nonce action for model CRUD. */
const NONCE_MODEL = 'prv_model_action';
/** Nonce action for Run now on settings page. */
const NONCE_RUN = 'prv_settings_run_now';
/** Nonce action for Test model AJAX. */
const NONCE_TEST = 'prv_model_test';
/**
* Register WordPress admin hooks.
*
* Side effects: Adds admin_menu, admin_post, and wp_ajax actions.
*
* @return void
*/
public function register_hooks(): void {
add_action( 'admin_menu', array( $this, 'add_settings_menu' ) );
add_action( 'admin_post_prv_settings_save', array( $this, 'handle_save' ) );
add_action( 'admin_post_prv_model_add', array( $this, 'handle_model_add' ) );
add_action( 'admin_post_prv_model_update', array( $this, 'handle_model_update' ) );
add_action( 'admin_post_prv_model_remove', array( $this, 'handle_model_remove' ) );
add_action( 'admin_post_prv_settings_run_now', array( $this, 'handle_run_now' ) );
add_action( 'wp_ajax_prv_test_model', array( $this, 'handle_test_model' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
}
/**
* Register the Settings sub-menu under PR Vision.
*
* Side effects: Adds a WP admin sub-menu entry.
*
* @return void
*/
public function add_settings_menu(): void {
add_submenu_page(
'pr-vision',
__( 'PR Vision Settings', 'pr-vision' ),
__( 'Settings', 'pr-vision' ),
'manage_options',
self::MENU_SLUG,
array( $this, 'render_page' )
);
}
/**
* Enqueue settings page Chart.js.
*
* @param string $hook Current admin page hook.
*
* @return void
*/
public function enqueue_assets( string $hook ): void {
if ( false === strpos( $hook, self::MENU_SLUG ) ) {
return;
}
wp_enqueue_script(
'prv-chartjs',
'https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js',
array(),
'4.4.3',
true
);
}
/**
* Handle the settings save POST action.
*
* Validates, sanitizes, saves peptides/intents/cadence/budget. Bumps
* config-version if scoring-relevant fields changed. Reschedules cron
* if cadence changed.
*
* Side effects: Updates wp_options, may reschedule cron, redirects.
*
* @return void
*/
public function handle_save(): void {
$this->require_admin_nonce( self::NONCE_SAVE );
$old_cadence = PRV_Config::get_cadence();
$budget = isset( $_POST['prv_monthly_budget_usd'] )
? max( 0.01, (float) sanitize_text_field( wp_unslash( (string) $_POST['prv_monthly_budget_usd'] ) ) )
: PRV_DEFAULT_MONTHLY_BUDGET_USD;
update_option( 'prv_monthly_budget_usd', $budget );
$cadence_raw = isset( $_POST['prv_cadence'] )
? sanitize_text_field( wp_unslash( (string) $_POST['prv_cadence'] ) )
: 'weekly';
$cadence = in_array( $cadence_raw, array( 'weekly', 'daily', 'twicedaily' ), true ) ? $cadence_raw : 'weekly';
update_option( PRV_Config::CADENCE_KEY, $cadence );
if ( ! empty( $_POST['prv_peptides_json'] ) ) {
$raw_json = sanitize_textarea_field( wp_unslash( (string) $_POST['prv_peptides_json'] ) );
$decoded = json_decode( $raw_json, true );
if ( is_array( $decoded ) ) {
$clean = array();
foreach ( $decoded as $p ) {
if ( isset( $p['slug'], $p['label'] ) ) {
$clean[] = array(
'slug' => sanitize_text_field( (string) $p['slug'] ),
'label' => sanitize_text_field( (string) $p['label'] ),
);
}
}
update_option( 'prv_peptides', $clean );
}
}
if ( isset( $_POST['prv_prompt_intents_text'] ) ) {
$raw_intents = sanitize_textarea_field( wp_unslash( (string) $_POST['prv_prompt_intents_text'] ) );
$intent_lines = array_filter( array_map( 'trim', explode( "\n", $raw_intents ) ) );
update_option( 'prv_prompt_intents', array_values( $intent_lines ) );
}
PRV_Config_Version::bump_version_if_changed();
if ( $cadence !== $old_cadence ) {
PRV_Cron::reschedule( $cadence );
}
wp_safe_redirect( add_query_arg( array( 'page' => self::MENU_SLUG, 'prv_saved' => 1 ), admin_url( 'admin.php' ) ) );
exit;
}
/**
* Handle Add model POST.
*
* Side effects: Adds to prv_models; may bump config-version; redirects.
*
* @return void
*/
public function handle_model_add(): void {
$this->require_admin_nonce( self::NONCE_MODEL );
$slug = sanitize_text_field( wp_unslash( (string) ( $_POST['prv_model_slug'] ?? '' ) ) );
$provider = sanitize_text_field( wp_unslash( (string) ( $_POST['prv_model_provider'] ?? 'openrouter' ) ) );
$note = sanitize_text_field( wp_unslash( (string) ( $_POST['prv_model_note'] ?? '' ) ) );
$enabled = ! empty( $_POST['prv_model_enabled'] );
if ( '' !== $slug ) {
PRV_Model_Registry::add( $slug, $provider, $enabled, $note );
PRV_Config_Version::bump_version_if_changed();
}
wp_safe_redirect( add_query_arg( array( 'page' => self::MENU_SLUG, 'prv_model_added' => 1 ), admin_url( 'admin.php' ) ) );
exit;
}
/**
* Handle Update model POST.
*
* Side effects: Updates prv_models; may bump config-version; redirects.
*
* @return void
*/
public function handle_model_update(): void {
$this->require_admin_nonce( self::NONCE_MODEL );
$id = sanitize_text_field( wp_unslash( (string) ( $_POST['prv_model_id'] ?? '' ) ) );
$slug = sanitize_text_field( wp_unslash( (string) ( $_POST['prv_model_slug'] ?? '' ) ) );
$enabled = ! empty( $_POST['prv_model_enabled'] );
$note = sanitize_text_field( wp_unslash( (string) ( $_POST['prv_model_note'] ?? '' ) ) );
if ( '' !== $id && '' !== $slug ) {
PRV_Model_Registry::update( $id, array( 'slug' => $slug, 'enabled' => $enabled, 'note' => $note ) );
PRV_Config_Version::bump_version_if_changed();
}
wp_safe_redirect( add_query_arg( array( 'page' => self::MENU_SLUG, 'prv_model_updated' => 1 ), admin_url( 'admin.php' ) ) );
exit;
}
/**
* Handle Remove model POST.
*
* Side effects: Removes from prv_models; may bump config-version; redirects.
*
* @return void
*/
public function handle_model_remove(): void {
$this->require_admin_nonce( self::NONCE_MODEL );
$id = sanitize_text_field( wp_unslash( (string) ( $_POST['prv_model_id'] ?? '' ) ) );
if ( '' !== $id ) {
PRV_Model_Registry::remove( $id );
PRV_Config_Version::bump_version_if_changed();
}
wp_safe_redirect( add_query_arg( array( 'page' => self::MENU_SLUG, 'prv_model_removed' => 1 ), admin_url( 'admin.php' ) ) );
exit;
}
/**
* Handle Run now POST from the settings page.
*
* Refuses if a run is already in progress.
*
* Side effects: May trigger probe run; redirects.
*
* @return void
*/
public function handle_run_now(): void {
$this->require_admin_nonce( self::NONCE_RUN );
if ( PRV_Run_Lock::is_locked() ) {
wp_safe_redirect( add_query_arg( array( 'page' => self::MENU_SLUG, 'prv_run_locked' => 1 ), admin_url( 'admin.php' ) ) );
exit;
}
$runner = new PRV_Probe_Runner();
$counts = $runner->run();
$args = array(
'page' => self::MENU_SLUG,
'prv_run_done' => 1,
'prv_probed' => (int) $counts['probed'],
'prv_skipped' => (int) ( $counts['skipped_budget'] + max( 0, $counts['skipped_error'] ) ),
'prv_truncated' => $counts['truncated'] ? 1 : 0,
);
wp_safe_redirect( add_query_arg( $args, admin_url( 'admin.php' ) ) );
exit;
}
/**
* Dispatch the AJAX test-model request to PRV_Model_Test_Ajax.
*
* Side effects: Exits via PRV_Model_Test_Ajax::handle().
*
* @return void
*/
public function handle_test_model(): void {
( new PRV_Model_Test_Ajax() )->handle();
}
/**
* Render the settings page.
*
* Side effects: Outputs HTML via PRV_Settings_Renderer.
*
* @return void
*/
public function render_page(): void {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'Insufficient permissions.', 'pr-vision' ) );
}
( new PRV_Settings_Renderer() )->render();
}
/**
* Verify admin capability and nonce; wp_die on failure.
*
* @param string $action Nonce action.
*
* @return void
*/
private function require_admin_nonce( string $action ): void {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'Insufficient permissions.', 'pr-vision' ), 403 );
}
$nonce = isset( $_POST['prv_nonce'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['prv_nonce'] ) ) : '';
if ( ! wp_verify_nonce( $nonce, $action ) ) {
wp_die( esc_html__( 'Security check failed.', 'pr-vision' ), 403 );
}
}
}

View File

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

View File

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

View File

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