PR Vision v0.2.0: admin/settings interface + correctness fixes (#3)
Model manager (UI-swappable models) + versioned/idempotent prv_models migration; metric comparability (config-version stamping); run-lock; projected cost + truncation state; per-model run-health; cadence reschedule; API-key 3-state; design/a11y fixes (dark v2). QA: APPROVE-WITH-P2-NOTES — qa-reviews/pr-vision/2026-06-14-ee4371d9163f992f60e09c7e4ac6cf8cd1d3c5eb.md Agent-Session: pr-vision admin v0.2.0 merge
This commit is contained in:
@@ -12,10 +12,10 @@ declare(strict_types=1);
|
||||
*
|
||||
* Converts PRV_* class/interface names to file paths under includes/.
|
||||
* Naming conventions:
|
||||
* PRV_Foo_Bar (class) → class-prv-foo-bar.php
|
||||
* PRV_Foo_Bar (interface) → interface-prv-foo-bar.php (tried second)
|
||||
* PRV_Foo_Bar (class) -- class-prv-foo-bar.php
|
||||
* PRV_Foo_Bar (interface) -- interface-prv-foo-bar.php (tried second)
|
||||
*
|
||||
* Who triggers: Plugin boot (pr-vision.php) — called once before
|
||||
* Who triggers: Plugin boot (pr-vision.php) -- called once before
|
||||
* any PRV_* class or interface is instantiated.
|
||||
* Dependencies: None (pure PHP).
|
||||
*
|
||||
|
||||
177
includes/core/class-prv-config-version.php
Normal file
177
includes/core/class-prv-config-version.php
Normal file
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
/**
|
||||
* Scoring-config versioning: stamps each run with the model*peptide*intent set.
|
||||
*
|
||||
* @package PrVision
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Tracks the scored config (models x peptides x intents) as a version record.
|
||||
*
|
||||
* Whenever the scoring-relevant config changes, a new config-version is stamped.
|
||||
* Every run is linked to the active config-version at run time. The dashboard
|
||||
* trendline uses this to draw break annotations at config changes, and the
|
||||
* score query excludes rows from orphaned models/peptides outside the current
|
||||
* config-version.
|
||||
*
|
||||
* Option layout:
|
||||
* prv_config_versions: array of {version: int, hash: string, timestamp: int,
|
||||
* models: string[], peptides: string[], intents: string[]}
|
||||
* prv_active_config_version: int (the current version number)
|
||||
*
|
||||
* Who triggers: PRV_Upgrader::run() for initial seed; PRV_Settings_Page when
|
||||
* scoring-relevant settings are saved.
|
||||
* Dependencies: PRV_Model_Registry, PRV_Config.
|
||||
*
|
||||
* @see class-prv-upgrader.php -- Calls maybe_seed_initial_version().
|
||||
* @see class-prv-settings-page.php -- Calls bump_version_if_changed() on save.
|
||||
* @see class-prv-probe-runner.php -- Reads get_active_version() for run stamp.
|
||||
* @package PrVision
|
||||
*/
|
||||
class PRV_Config_Version {
|
||||
|
||||
/**
|
||||
* Option key for the version history array.
|
||||
*/
|
||||
const VERSIONS_KEY = 'prv_config_versions';
|
||||
|
||||
/**
|
||||
* Option key for the currently active version number.
|
||||
*/
|
||||
const ACTIVE_KEY = 'prv_active_config_version';
|
||||
|
||||
/**
|
||||
* Seed the initial version record if none exists yet.
|
||||
*
|
||||
* Safe to call repeatedly -- no-op when a version already exists.
|
||||
*
|
||||
* Side effects: May write prv_config_versions and prv_active_config_version.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function maybe_seed_initial_version(): void {
|
||||
$versions = get_option( self::VERSIONS_KEY, array() );
|
||||
if ( ! empty( $versions ) ) {
|
||||
return;
|
||||
}
|
||||
self::create_version_record();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the config hash from the current model/peptide/intent set.
|
||||
*
|
||||
* @return string MD5 of the sorted, serialised config components.
|
||||
*/
|
||||
public static function compute_hash(): string {
|
||||
$models = PRV_Model_Registry::get_enabled_slugs();
|
||||
$peptides = array_column( PRV_Config::get_peptides(), 'slug' );
|
||||
$intents = PRV_Config::get_prompt_intents();
|
||||
|
||||
sort( $models );
|
||||
sort( $peptides );
|
||||
sort( $intents );
|
||||
|
||||
return md5( serialize( array( $models, $peptides, $intents ) ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
|
||||
}
|
||||
|
||||
/**
|
||||
* Bump to a new config version if the scoring config has changed.
|
||||
*
|
||||
* Call this after saving any scoring-relevant setting. Returns the new
|
||||
* version number if a bump occurred, null otherwise.
|
||||
*
|
||||
* Side effects: May write prv_config_versions and prv_active_config_version.
|
||||
*
|
||||
* @return int|null New version number if bumped, null if unchanged.
|
||||
*/
|
||||
public static function bump_version_if_changed(): ?int {
|
||||
$new_hash = self::compute_hash();
|
||||
$versions = get_option( self::VERSIONS_KEY, array() );
|
||||
$active = (int) get_option( self::ACTIVE_KEY, 0 );
|
||||
|
||||
foreach ( $versions as $v ) {
|
||||
if ( isset( $v['version'] ) && (int) $v['version'] === $active && isset( $v['hash'] ) && $v['hash'] === $new_hash ) {
|
||||
return null; // Config unchanged.
|
||||
}
|
||||
}
|
||||
|
||||
return self::create_version_record();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the active config version number.
|
||||
*
|
||||
* @return int Version number (1-based); 0 if not yet seeded.
|
||||
*/
|
||||
public static function get_active_version(): int {
|
||||
return (int) get_option( self::ACTIVE_KEY, 0 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all version records, newest first.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public static function get_all_versions(): array {
|
||||
$versions = get_option( self::VERSIONS_KEY, array() );
|
||||
if ( ! is_array( $versions ) ) {
|
||||
return array();
|
||||
}
|
||||
$sorted = $versions;
|
||||
usort( $sorted, static fn( $a, $b ) => (int) ( $b['version'] ?? 0 ) - (int) ( $a['version'] ?? 0 ) );
|
||||
return $sorted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given hash differs from the current active config.
|
||||
*
|
||||
* Used by the Settings page to warn before saving a scoring-relevant change.
|
||||
*
|
||||
* @param string $proposed_hash Hash to check.
|
||||
*
|
||||
* @return bool True when the config would change.
|
||||
*/
|
||||
public static function would_change( string $proposed_hash ): bool {
|
||||
$versions = get_option( self::VERSIONS_KEY, array() );
|
||||
$active = (int) get_option( self::ACTIVE_KEY, 0 );
|
||||
foreach ( $versions as $v ) {
|
||||
if ( isset( $v['version'] ) && (int) $v['version'] === $active ) {
|
||||
return isset( $v['hash'] ) && $v['hash'] !== $proposed_hash;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new version record and activate it.
|
||||
*
|
||||
* Side effects: Updates prv_config_versions and prv_active_config_version.
|
||||
*
|
||||
* @return int The new version number.
|
||||
*/
|
||||
private static function create_version_record(): int {
|
||||
$versions = get_option( self::VERSIONS_KEY, array() );
|
||||
if ( ! is_array( $versions ) ) {
|
||||
$versions = array();
|
||||
}
|
||||
$new_version = count( $versions ) + 1;
|
||||
$models = PRV_Model_Registry::get_enabled_slugs();
|
||||
$peptides = array_column( PRV_Config::get_peptides(), 'slug' );
|
||||
$intents = PRV_Config::get_prompt_intents();
|
||||
|
||||
$versions[] = array(
|
||||
'version' => $new_version,
|
||||
'hash' => self::compute_hash(),
|
||||
'timestamp' => time(),
|
||||
'models' => $models,
|
||||
'peptides' => $peptides,
|
||||
'intents' => $intents,
|
||||
);
|
||||
|
||||
update_option( self::VERSIONS_KEY, $versions );
|
||||
update_option( self::ACTIVE_KEY, $new_version );
|
||||
return $new_version;
|
||||
}
|
||||
}
|
||||
@@ -12,18 +12,26 @@ declare(strict_types=1);
|
||||
*
|
||||
* All mutable settings live in wp_options under the prv_ prefix. This class
|
||||
* provides typed getters (no magic values in call sites) and seeds the initial
|
||||
* defaults on first activation.
|
||||
* defaults on first activation. Models are delegated to PRV_Model_Registry
|
||||
* which owns the v2 rich-object format; get_models() returns the flat slug
|
||||
* array for backward compat with PRV_Probe_Runner.
|
||||
*
|
||||
* Who triggers: PRV_Activator::activate() for seeding; every component that
|
||||
* needs a setting reads through here.
|
||||
* Dependencies: get_option(), update_option().
|
||||
* Dependencies: get_option(), update_option(), PRV_Model_Registry.
|
||||
*
|
||||
* @see CONTEXT.md — Domain glossary: peptides, prompt intents, models.
|
||||
* @see ARCHITECTURE.md — §Config (options + defaults).
|
||||
* @see CONTEXT.md -- Domain glossary: peptides, prompt intents, models.
|
||||
* @see ARCHITECTURE.md -- Section Config (options + defaults).
|
||||
* @see class-prv-model-registry.php -- Owns prv_models v2 format.
|
||||
* @package PrVision
|
||||
*/
|
||||
class PRV_Config {
|
||||
|
||||
/**
|
||||
* Cadence option key.
|
||||
*/
|
||||
const CADENCE_KEY = 'prv_cadence';
|
||||
|
||||
/**
|
||||
* Seed the default option values on first activation.
|
||||
*
|
||||
@@ -37,7 +45,9 @@ class PRV_Config {
|
||||
add_option( 'prv_monthly_budget_usd', PRV_DEFAULT_MONTHLY_BUDGET_USD );
|
||||
add_option( 'prv_peptides', self::default_peptides() );
|
||||
add_option( 'prv_prompt_intents', self::default_prompt_intents() );
|
||||
add_option( 'prv_models', self::default_models() );
|
||||
add_option( self::CADENCE_KEY, 'weekly' );
|
||||
// Note: prv_models seeded by PRV_Model_Registry::run_migration_v2() via
|
||||
// PRV_Upgrader::run() -- not here -- so the v2 schema is always in place.
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,13 +85,56 @@ class PRV_Config {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the model identifiers to probe with.
|
||||
* Get the enabled model slugs (flat array, backward compat).
|
||||
*
|
||||
* Delegates to PRV_Model_Registry which owns the v2 model format.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function get_models(): array {
|
||||
$raw = get_option( 'prv_models', self::default_models() );
|
||||
return is_array( $raw ) ? $raw : self::default_models();
|
||||
return PRV_Model_Registry::get_enabled_slugs();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the probe cadence identifier.
|
||||
*
|
||||
* @return string WP-Cron recurrence name (e.g. 'weekly', 'daily').
|
||||
*/
|
||||
public static function get_cadence(): string {
|
||||
$cadence = (string) get_option( self::CADENCE_KEY, 'weekly' );
|
||||
return in_array( $cadence, array( 'weekly', 'daily', 'twicedaily' ), true ) ? $cadence : 'weekly';
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the projected per-run and per-month cost.
|
||||
*
|
||||
* Formula: enabled_models x peptides x intents x estimated_cost_per_probe x cadence_runs_per_month.
|
||||
* Uses conservative high-end estimates to never understate cost.
|
||||
*
|
||||
* @return array{per_run_usd: float, per_month_usd: float, probe_count: int, over_cap: bool}
|
||||
*/
|
||||
public static function get_projected_cost(): array {
|
||||
$models = PRV_Model_Registry::get_all();
|
||||
$enabled_count = count( array_filter( $models, static fn( $m ) => ! empty( $m['enabled'] ) ) );
|
||||
$peptide_count = count( self::get_peptides() );
|
||||
$intent_count = count( self::get_prompt_intents() );
|
||||
$probe_count = $enabled_count * $peptide_count * $intent_count;
|
||||
|
||||
// Conservative per-probe estimate (use the higher Perplexity estimate).
|
||||
$per_probe = 0.005;
|
||||
$per_run = round( $probe_count * $per_probe, 4 );
|
||||
|
||||
$cadence = self::get_cadence();
|
||||
$runs_per_month = 'weekly' === $cadence ? 4 : ( 'daily' === $cadence ? 30 : 8 );
|
||||
$per_month = round( $per_run * $runs_per_month, 4 );
|
||||
$cap = self::get_monthly_budget_usd();
|
||||
|
||||
return array(
|
||||
'per_run_usd' => $per_run,
|
||||
'per_month_usd' => $per_month,
|
||||
'probe_count' => $probe_count,
|
||||
'over_cap' => $per_month > $cap,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -143,7 +196,7 @@ class PRV_Config {
|
||||
}
|
||||
|
||||
/**
|
||||
* Default prompt intent templates — three query styles per peptide.
|
||||
* Default prompt intent templates -- three query styles per peptide.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
@@ -154,18 +207,4 @@ class PRV_Config {
|
||||
'{peptide} reconstitution guide',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Default models: Perplexity sonar (primary citation signal) + two
|
||||
* OpenRouter-routed search-capable models for breadth.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private static function default_models(): array {
|
||||
return array(
|
||||
'perplexity/sonar',
|
||||
'openai/gpt-4o-search-preview',
|
||||
'google/gemini-2.0-flash-001',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,17 +8,21 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* WP-Cron management for the weekly AI-visibility probe.
|
||||
* WP-Cron management for the AI-visibility probe.
|
||||
*
|
||||
* Registers the prv_weekly_probe hook, schedules/clears the event, and
|
||||
* dispatches to PRV_Probe_Runner on each tick.
|
||||
* dispatches to PRV_Probe_Runner on each tick. Cadence changes clear the
|
||||
* existing schedule and reschedule at the new recurrence so next-run
|
||||
* timestamp reflects the actual cadence, not just the saved option.
|
||||
*
|
||||
* Who triggers: PRV_Plugin::init() (hook registration), PRV_Activator
|
||||
* (schedule), PRV_Deactivator (clear).
|
||||
* Dependencies: PRV_Probe_Runner.
|
||||
* (schedule), PRV_Deactivator (clear), PRV_Settings_Page
|
||||
* (reschedule on cadence change).
|
||||
* Dependencies: PRV_Probe_Runner, PRV_Run_Lock.
|
||||
*
|
||||
* @see class-prv-probe-runner.php — Runner invoked on each cron tick.
|
||||
* @see class-prv-activator.php — Calls schedule_weekly() on activation.
|
||||
* @see class-prv-probe-runner.php -- Runner invoked on each cron tick.
|
||||
* @see class-prv-activator.php -- Calls schedule() on activation.
|
||||
* @see class-prv-settings-page.php -- Calls reschedule() on cadence change.
|
||||
* @package PrVision
|
||||
*/
|
||||
class PRV_Cron {
|
||||
@@ -35,34 +39,85 @@ class PRV_Cron {
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback fired by WP-Cron on the weekly schedule.
|
||||
* Callback fired by WP-Cron on the scheduled cadence.
|
||||
*
|
||||
* Respects the run-lock; if already running (e.g. "Run now" is active)
|
||||
* the cron tick is skipped silently without double-spending.
|
||||
*
|
||||
* Side effects: Instantiates and runs PRV_Probe_Runner.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function handle_cron_tick(): void {
|
||||
if ( PRV_Run_Lock::is_locked() ) {
|
||||
// Another run is in progress -- skip this tick.
|
||||
return;
|
||||
}
|
||||
$runner = new PRV_Probe_Runner();
|
||||
$runner->run();
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule the weekly cron event if not already scheduled.
|
||||
* Schedule the probe cron event using the configured cadence.
|
||||
*
|
||||
* Safe to call on re-activation — no-op when already scheduled.
|
||||
* Safe to call on re-activation -- no-op when already scheduled at the
|
||||
* correct recurrence; clears and reschedules when recurrence differs.
|
||||
*
|
||||
* Side effects: Adds a WP-Cron event.
|
||||
* Side effects: Adds (or replaces) a WP-Cron event.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function schedule(): void {
|
||||
$cadence = PRV_Config::get_cadence();
|
||||
$next = wp_next_scheduled( PRV_CRON_HOOK );
|
||||
|
||||
if ( false !== $next ) {
|
||||
// Check if the current recurrence matches.
|
||||
$events = _get_cron_array();
|
||||
foreach ( $events as $timestamp => $hooks ) {
|
||||
if ( isset( $hooks[ PRV_CRON_HOOK ] ) ) {
|
||||
foreach ( $hooks[ PRV_CRON_HOOK ] as $event ) {
|
||||
if ( isset( $event['schedule'] ) && $event['schedule'] === $cadence ) {
|
||||
return; // Already scheduled at the right cadence.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Wrong cadence -- clear and reschedule.
|
||||
self::clear_schedule();
|
||||
}
|
||||
|
||||
wp_schedule_event( time(), $cadence, PRV_CRON_HOOK );
|
||||
}
|
||||
|
||||
/**
|
||||
* Reschedule the cron at a new cadence.
|
||||
*
|
||||
* Clears the existing event and creates a new one immediately.
|
||||
* Called by PRV_Settings_Page when the cadence option changes.
|
||||
*
|
||||
* Side effects: Removes existing cron event; adds new one.
|
||||
*
|
||||
* @param string $cadence New WP-Cron recurrence identifier.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function reschedule( string $cadence ): void {
|
||||
self::clear_schedule();
|
||||
wp_schedule_event( time(), $cadence, PRV_CRON_HOOK );
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule using the legacy 'weekly' recurrence (activation compat).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function schedule_weekly(): void {
|
||||
if ( ! wp_next_scheduled( PRV_CRON_HOOK ) ) {
|
||||
wp_schedule_event( time(), 'weekly', PRV_CRON_HOOK );
|
||||
}
|
||||
self::schedule();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the weekly cron schedule.
|
||||
* Remove the cron schedule.
|
||||
*
|
||||
* Called on deactivation. Does not purge data.
|
||||
*
|
||||
@@ -78,11 +133,20 @@ class PRV_Cron {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the weekly event is currently scheduled.
|
||||
* Check whether the probe event is currently scheduled.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_scheduled(): bool {
|
||||
return false !== wp_next_scheduled( PRV_CRON_HOOK );
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the timestamp of the next scheduled run.
|
||||
*
|
||||
* @return int|false Timestamp or false if not scheduled.
|
||||
*/
|
||||
public static function next_run_timestamp() {
|
||||
return wp_next_scheduled( PRV_CRON_HOOK );
|
||||
}
|
||||
}
|
||||
|
||||
190
includes/core/class-prv-model-manager-table.php
Normal file
190
includes/core/class-prv-model-manager-table.php
Normal file
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
/**
|
||||
* Model manager table renderer for PR Vision settings page.
|
||||
*
|
||||
* @package PrVision
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Renders the Model Manager table and Add-model form.
|
||||
*
|
||||
* The table shows all models (provider, slug, enabled toggle, run-health badge,
|
||||
* Test button, edit and remove controls). Retired rows are styled with full AA
|
||||
* contrast (no opacity collapse), a left-edge, diagonal hatch, strikethrough
|
||||
* name, and a disabled toggle. The Test button is a point-in-time check; run-
|
||||
* health is the passive per-run detector.
|
||||
*
|
||||
* Who triggers: PRV_Settings_Renderer::render_model_manager().
|
||||
* Dependencies: PRV_Model_Registry, PRV_Settings_Page (nonce constants).
|
||||
*
|
||||
* @see class-prv-settings-renderer.php -- Instantiates and calls render().
|
||||
* @see class-prv-model-registry.php -- Source of model data.
|
||||
* @package PrVision
|
||||
*/
|
||||
class PRV_Model_Manager_Table {
|
||||
|
||||
/**
|
||||
* Render the model table + add-model form.
|
||||
*
|
||||
* Side effects: Outputs HTML directly.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function render(): void {
|
||||
$models = PRV_Model_Registry::get_all();
|
||||
|
||||
echo '<table class="prv-table" id="prv-models-table">';
|
||||
echo '<thead><tr>';
|
||||
echo '<th aria-sort="none"><button>' . esc_html__( 'Provider', 'pr-vision' ) . ' <span aria-hidden="true">⇅</span></button></th>';
|
||||
echo '<th aria-sort="none"><button>' . esc_html__( 'Model slug', 'pr-vision' ) . ' <span aria-hidden="true">⇅</span></button></th>';
|
||||
echo '<th>' . esc_html__( 'Enabled', 'pr-vision' ) . '</th>';
|
||||
echo '<th aria-sort="none"><button>' . esc_html__( 'Run health', 'pr-vision' ) . ' <span aria-hidden="true">⇅</span></button></th>';
|
||||
echo '<th>' . esc_html__( 'Test (point-in-time)', 'pr-vision' ) . '</th>';
|
||||
echo '<th>' . esc_html__( 'Actions', 'pr-vision' ) . '</th>';
|
||||
echo '</tr></thead><tbody>';
|
||||
|
||||
foreach ( $models as $m ) {
|
||||
$this->render_model_row( $m );
|
||||
}
|
||||
|
||||
echo '</tbody></table>';
|
||||
echo '<div style="margin-top:16px">';
|
||||
$this->render_add_model_form();
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single model table row.
|
||||
*
|
||||
* @param array<string, mixed> $m Model object from PRV_Model_Registry.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function render_model_row( array $m ): void {
|
||||
$id = esc_attr( (string) ( $m['id'] ?? '' ) );
|
||||
$slug = esc_html( (string) ( $m['slug'] ?? '' ) );
|
||||
$prov = esc_html( (string) ( $m['provider'] ?? '' ) );
|
||||
$enabled = ! empty( $m['enabled'] );
|
||||
$health = (string) ( $m['health_status'] ?? 'unknown' );
|
||||
$retired = 'retired' === $health;
|
||||
$note = esc_html( (string) ( $m['note'] ?? '' ) );
|
||||
|
||||
$row_class = $retired ? 'prv-row-retired' : '';
|
||||
echo '<tr class="' . esc_attr( $row_class ) . '">';
|
||||
|
||||
// Provider.
|
||||
echo '<td>' . esc_html( $prov ) . '</td>';
|
||||
|
||||
// Slug (monospace).
|
||||
echo '<td><code style="background:var(--prv-surface3);padding:1px 5px;border-radius:3px;font-size:12px;' . ( $retired ? 'text-decoration:line-through;' : '' ) . '">' . esc_html( $slug ) . '</code>';
|
||||
if ( $note ) {
|
||||
echo '<br><span style="color:var(--prv-text-muted);font-size:11px">' . esc_html( $note ) . '</span>';
|
||||
}
|
||||
echo '</td>';
|
||||
|
||||
// Enabled toggle.
|
||||
echo '<td>';
|
||||
$form_name = 'prv_enable_' . $id;
|
||||
echo '<form method="post" action="' . esc_url( admin_url( 'admin-post.php' ) ) . '" style="display:inline">';
|
||||
echo '<input type="hidden" name="action" value="prv_model_update">';
|
||||
echo '<input type="hidden" name="prv_model_id" value="' . esc_attr( $id ) . '">';
|
||||
echo '<input type="hidden" name="prv_model_slug" value="' . esc_attr( (string) ( $m['slug'] ?? '' ) ) . '">';
|
||||
echo '<input type="hidden" name="prv_model_note" value="' . esc_attr( (string) ( $m['note'] ?? '' ) ) . '">';
|
||||
wp_nonce_field( PRV_Settings_Page::NONCE_MODEL, 'prv_nonce' );
|
||||
// Disabled toggle for retired models.
|
||||
$toggle_attrs = $retired ? ' disabled aria-disabled="true"' : '';
|
||||
$checked_attr = $enabled ? ' checked' : '';
|
||||
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $checked_attr and $toggle_attrs are hard-coded attribute strings; $enabled is a boolean cast.
|
||||
echo '<input type="checkbox" name="prv_model_enabled" onchange="this.form.submit()" ' . $checked_attr . $toggle_attrs . ' title="' . ( $enabled ? 'Disable' : 'Enable' ) . '">';
|
||||
echo '</form>';
|
||||
if ( $retired ) {
|
||||
echo '<span class="prv-badge prv-badge-retired" style="margin-left:6px">' . esc_html__( 'Retired', 'pr-vision' ) . '</span>';
|
||||
}
|
||||
echo '</td>';
|
||||
|
||||
// Run health.
|
||||
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- render_health_badge() returns pre-escaped HTML markup.
|
||||
echo '<td>' . $this->render_health_badge( $m ) . '</td>';
|
||||
|
||||
// Test button + chip.
|
||||
$chip_id = 'prv-test-chip-' . $id;
|
||||
echo '<td>';
|
||||
echo '<button type="button" class="prv-btn prv-btn-ghost prv-test-btn" data-model-id="' . esc_attr( $id ) . '">';
|
||||
echo esc_html__( 'Test', 'pr-vision' );
|
||||
echo '</button>';
|
||||
echo ' <span id="' . esc_attr( $chip_id ) . '" class="prv-badge prv-badge-unknown" role="status" aria-live="polite"></span>';
|
||||
echo '</td>';
|
||||
|
||||
// Actions: remove.
|
||||
echo '<td>';
|
||||
echo '<form method="post" action="' . esc_url( admin_url( 'admin-post.php' ) ) . '" style="display:inline" onsubmit="return confirm(\'Remove this model?\');">';
|
||||
echo '<input type="hidden" name="action" value="prv_model_remove">';
|
||||
echo '<input type="hidden" name="prv_model_id" value="' . esc_attr( $id ) . '">';
|
||||
wp_nonce_field( PRV_Settings_Page::NONCE_MODEL, 'prv_nonce' );
|
||||
echo '<button type="submit" class="prv-btn prv-btn-danger">' . esc_html__( 'Remove', 'pr-vision' ) . '</button>';
|
||||
echo '</form>';
|
||||
echo '</td>';
|
||||
|
||||
echo '</tr>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a health badge for a model.
|
||||
*
|
||||
* @param array<string, mixed> $m Model object.
|
||||
*
|
||||
* @return string HTML badge (pre-escaped).
|
||||
*/
|
||||
private function render_health_badge( array $m ): string {
|
||||
$status = (string) ( $m['health_status'] ?? 'unknown' );
|
||||
$probed = (int) ( $m['health_probed'] ?? 0 );
|
||||
$errors = (int) ( $m['health_errors'] ?? 0 );
|
||||
|
||||
switch ( $status ) {
|
||||
case 'healthy':
|
||||
return '<span class="prv-badge prv-badge-healthy">● Healthy (' . esc_html( (string) $probed ) . ' rows)</span>';
|
||||
case 'retired':
|
||||
return '<span class="prv-badge prv-badge-retired">● Retired? (0 rows · ' . esc_html( (string) $errors ) . ' errors)</span>';
|
||||
case 'disabled':
|
||||
return '<span class="prv-badge prv-badge-disabled">● Off · retired</span>';
|
||||
default:
|
||||
return '<span class="prv-badge prv-badge-unknown">● Unknown</span>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the Add model form below the table.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function render_add_model_form(): void {
|
||||
echo '<details style="border:1px solid var(--prv-border);border-radius:6px;padding:12px;background:var(--prv-surface2)">';
|
||||
echo '<summary style="cursor:pointer;color:var(--prv-teal);font-size:13px;font-weight:500">+ ' . esc_html__( 'Add model', 'pr-vision' ) . '</summary>';
|
||||
echo '<form method="post" action="' . esc_url( admin_url( 'admin-post.php' ) ) . '" style="margin-top:12px;display:grid;grid-template-columns:1fr 1fr;gap:10px">';
|
||||
echo '<input type="hidden" name="action" value="prv_model_add">';
|
||||
wp_nonce_field( PRV_Settings_Page::NONCE_MODEL, 'prv_nonce' );
|
||||
echo '<div>';
|
||||
echo '<label class="prv-label" for="prv_new_model_slug">' . esc_html__( 'Model slug', 'pr-vision' ) . '</label>';
|
||||
echo '<input class="prv-input" type="text" id="prv_new_model_slug" name="prv_model_slug" placeholder="openai/gpt-4o-search-preview" required>';
|
||||
echo '</div>';
|
||||
echo '<div>';
|
||||
echo '<label class="prv-label" for="prv_new_model_provider">' . esc_html__( 'Provider', 'pr-vision' ) . '</label>';
|
||||
echo '<select class="prv-input" id="prv_new_model_provider" name="prv_model_provider">';
|
||||
echo '<option value="openrouter">openrouter</option>';
|
||||
echo '<option value="perplexity">perplexity</option>';
|
||||
echo '</select>';
|
||||
echo '</div>';
|
||||
echo '<div style="grid-column:1/-1">';
|
||||
echo '<label class="prv-label" for="prv_new_model_note">' . esc_html__( 'Note (optional)', 'pr-vision' ) . '</label>';
|
||||
echo '<input class="prv-input" type="text" id="prv_new_model_note" name="prv_model_note" placeholder="Why this model?">';
|
||||
echo '</div>';
|
||||
echo '<div style="grid-column:1/-1;display:flex;align-items:center;gap:10px">';
|
||||
echo '<label><input type="checkbox" name="prv_model_enabled" value="1" checked> ' . esc_html__( 'Enabled', 'pr-vision' ) . '</label>';
|
||||
echo '<button type="submit" class="prv-btn prv-btn-primary">' . esc_html__( 'Add model', 'pr-vision' ) . '</button>';
|
||||
echo '</div>';
|
||||
echo '</form>';
|
||||
echo '</details>';
|
||||
}
|
||||
}
|
||||
299
includes/core/class-prv-model-registry.php
Normal file
299
includes/core/class-prv-model-registry.php
Normal file
@@ -0,0 +1,299 @@
|
||||
<?php
|
||||
/**
|
||||
* Model registry: versioned, idempotent migration + CRUD for prv_models.
|
||||
*
|
||||
* @package PrVision
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Manages prv_models: v0.1.x→v0.2.0 migration, CRUD, per-model run-health.
|
||||
*
|
||||
* V2 schema: [{id, provider, slug, enabled, note, health_status, health_probed,
|
||||
* health_errors, health_run_id}]. v1 legacy: flat string array.
|
||||
*
|
||||
* Who triggers: PRV_Upgrader::run() (upgrade path); PRV_Settings_Page (CRUD).
|
||||
* Dependencies: get_option(), update_option().
|
||||
*
|
||||
* @see class-prv-upgrader.php -- Calls run_migration_v2() on upgrade.
|
||||
* @see class-prv-settings-page.php -- CRUD consumers.
|
||||
* @see ARCHITECTURE.md -- Section Config (options + defaults).
|
||||
* @package PrVision
|
||||
*/
|
||||
class PRV_Model_Registry {
|
||||
|
||||
/**
|
||||
* Current schema version stored in prv_models_schema_version.
|
||||
*/
|
||||
const SCHEMA_VERSION = 2;
|
||||
|
||||
/**
|
||||
* Option key for the models array.
|
||||
*/
|
||||
const OPTION_KEY = 'prv_models';
|
||||
|
||||
/**
|
||||
* Option key for the models schema version.
|
||||
*/
|
||||
const VERSION_KEY = 'prv_models_schema_version';
|
||||
|
||||
/**
|
||||
* Run the migration from v1 (flat strings) to v2 (rich objects).
|
||||
*
|
||||
* Idempotent: checks prv_models_schema_version before acting; safe to call
|
||||
* on every plugins_loaded. Preserves the live model set -- existing model
|
||||
* slugs are carried forward with enabled=true and health_status=unknown.
|
||||
*
|
||||
* Side effects: May update prv_models and prv_models_schema_version options.
|
||||
*
|
||||
* @return bool True when a migration was performed, false when already current.
|
||||
*/
|
||||
public static function run_migration_v2(): bool {
|
||||
$current_version = (int) get_option( self::VERSION_KEY, 0 );
|
||||
if ( $current_version >= self::SCHEMA_VERSION ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$existing = get_option( self::OPTION_KEY, array() );
|
||||
$migrated = array();
|
||||
|
||||
if ( is_array( $existing ) && ! empty( $existing ) ) {
|
||||
foreach ( $existing as $item ) {
|
||||
if ( is_string( $item ) ) {
|
||||
// v1 flat string -- upgrade to rich object.
|
||||
$migrated[] = self::make_model_object( $item, '', true );
|
||||
} elseif ( is_array( $item ) && isset( $item['slug'] ) ) {
|
||||
// Already a v2 object -- pass through.
|
||||
$migrated[] = self::ensure_model_fields( $item );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ( empty( $migrated ) ) {
|
||||
$migrated = self::default_model_objects();
|
||||
}
|
||||
|
||||
update_option( self::OPTION_KEY, $migrated );
|
||||
update_option( self::VERSION_KEY, self::SCHEMA_VERSION );
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all models as rich-object arrays.
|
||||
*
|
||||
* @return array<int, array<string, mixed>> Model objects.
|
||||
*/
|
||||
public static function get_all(): array {
|
||||
$raw = get_option( self::OPTION_KEY, array() );
|
||||
if ( ! is_array( $raw ) || empty( $raw ) ) {
|
||||
return self::default_model_objects();
|
||||
}
|
||||
return array_values( array_filter( $raw, 'is_array' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get only enabled model slugs (for backward compat with PRV_Config).
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function get_enabled_slugs(): array {
|
||||
$models = self::get_all();
|
||||
$slugs = array();
|
||||
foreach ( $models as $m ) {
|
||||
if ( ! empty( $m['enabled'] ) ) {
|
||||
$slugs[] = (string) $m['slug'];
|
||||
}
|
||||
}
|
||||
return $slugs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a single model by id.
|
||||
*
|
||||
* @param string $id Model id.
|
||||
*
|
||||
* @return array<string, mixed>|null Null if not found.
|
||||
*/
|
||||
public static function find_by_id( string $id ): ?array {
|
||||
foreach ( self::get_all() as $m ) {
|
||||
if ( isset( $m['id'] ) && $m['id'] === $id ) {
|
||||
return $m;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new model.
|
||||
*
|
||||
* @param string $slug OpenRouter model slug.
|
||||
* @param string $provider Provider name (e.g. "openrouter", "perplexity").
|
||||
* @param bool $enabled Whether to enable on creation.
|
||||
* @param string $note Optional operator note.
|
||||
*
|
||||
* @return string New model id.
|
||||
*/
|
||||
public static function add( string $slug, string $provider, bool $enabled = true, string $note = '' ): string {
|
||||
$models = self::get_all();
|
||||
$obj = self::make_model_object( $slug, $provider, $enabled, $note );
|
||||
$models[] = $obj;
|
||||
update_option( self::OPTION_KEY, $models );
|
||||
return (string) $obj['id'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing model by id.
|
||||
*
|
||||
* @param string $id Model id.
|
||||
* @param array<string, mixed> $data Fields to update (slug, provider, enabled, note).
|
||||
*
|
||||
* @return bool True if found and updated.
|
||||
*/
|
||||
public static function update( string $id, array $data ): bool {
|
||||
$models = self::get_all();
|
||||
$updated = false;
|
||||
foreach ( $models as &$m ) {
|
||||
if ( isset( $m['id'] ) && $m['id'] === $id ) {
|
||||
foreach ( array( 'slug', 'provider', 'enabled', 'note' ) as $field ) {
|
||||
if ( array_key_exists( $field, $data ) ) {
|
||||
$m[ $field ] = $data[ $field ];
|
||||
}
|
||||
}
|
||||
$updated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
unset( $m );
|
||||
if ( $updated ) {
|
||||
update_option( self::OPTION_KEY, $models );
|
||||
}
|
||||
return $updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a model by id.
|
||||
*
|
||||
* @param string $id Model id.
|
||||
*
|
||||
* @return bool True if found and removed.
|
||||
*/
|
||||
public static function remove( string $id ): bool {
|
||||
$models = self::get_all();
|
||||
$filtered = array_values( array_filter( $models, static fn( $m ) => ( $m['id'] ?? '' ) !== $id ) );
|
||||
if ( count( $filtered ) === count( $models ) ) {
|
||||
return false;
|
||||
}
|
||||
update_option( self::OPTION_KEY, $filtered );
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update per-model run-health after a completed run.
|
||||
*
|
||||
* Called by PRV_Probe_Runner at the end of a run with per-slug outcome
|
||||
* counts. Sets health_status to 'healthy' or 'retired'.
|
||||
*
|
||||
* Side effects: Updates prv_models option.
|
||||
*
|
||||
* @param string $run_id UUID of completed run.
|
||||
* @param array<string, array{probed: int, errors: int}> $outcomes Per-slug outcome.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function update_health( string $run_id, array $outcomes ): void {
|
||||
$models = self::get_all();
|
||||
foreach ( $models as &$m ) {
|
||||
$slug = (string) ( $m['slug'] ?? '' );
|
||||
if ( empty( $m['enabled'] ) ) {
|
||||
$m['health_status'] = 'disabled';
|
||||
continue;
|
||||
}
|
||||
if ( ! isset( $outcomes[ $slug ] ) ) {
|
||||
continue;
|
||||
}
|
||||
$o = $outcomes[ $slug ];
|
||||
$m['health_probed'] = (int) $o['probed'];
|
||||
$m['health_errors'] = (int) $o['errors'];
|
||||
$m['health_run_id'] = $run_id;
|
||||
$m['health_status'] = ( 0 === $o['probed'] && $o['errors'] > 0 ) ? 'retired' : 'healthy';
|
||||
}
|
||||
unset( $m );
|
||||
update_option( self::OPTION_KEY, $models );
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a new model object with all required fields.
|
||||
*
|
||||
* @param string $slug Model slug.
|
||||
* @param string $provider Provider name.
|
||||
* @param bool $enabled Enabled flag.
|
||||
* @param string $note Operator note.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function make_model_object(
|
||||
string $slug,
|
||||
string $provider = '',
|
||||
bool $enabled = true,
|
||||
string $note = ''
|
||||
): array {
|
||||
if ( '' === $provider ) {
|
||||
$provider = ( 'perplexity/sonar' === $slug ) ? 'perplexity' : 'openrouter';
|
||||
}
|
||||
return array(
|
||||
'id' => self::generate_id(),
|
||||
'slug' => $slug,
|
||||
'provider' => $provider,
|
||||
'enabled' => $enabled,
|
||||
'note' => $note,
|
||||
'health_status' => 'unknown',
|
||||
'health_probed' => 0,
|
||||
'health_errors' => 0,
|
||||
'health_run_id' => null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure all v2 fields exist on an already-rich object (forward compat).
|
||||
*
|
||||
* @param array<string, mixed> $m Existing model array.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function ensure_model_fields( array $m ): array {
|
||||
$defaults = array(
|
||||
'id' => self::generate_id(),
|
||||
'provider' => 'openrouter',
|
||||
'enabled' => true,
|
||||
'note' => '',
|
||||
'health_status' => 'unknown',
|
||||
'health_probed' => 0,
|
||||
'health_errors' => 0,
|
||||
'health_run_id' => null,
|
||||
);
|
||||
return array_merge( $defaults, $m );
|
||||
}
|
||||
|
||||
/**
|
||||
* Default model objects matching v0.1.x defaults.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private static function default_model_objects(): array {
|
||||
return array(
|
||||
self::make_model_object( 'perplexity/sonar', 'perplexity', true, 'Primary citation signal -- real-web retrieval' ),
|
||||
self::make_model_object( 'openai/gpt-4o-search-preview', 'openrouter', true, 'GPT search breadth' ),
|
||||
self::make_model_object( 'google/gemini-2.0-flash-001', 'openrouter', true, 'Gemini breadth -- verify slug active before use' ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a short unique id for a model object.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private static function generate_id(): string {
|
||||
return sprintf( 'mdl_%s', substr( md5( uniqid( '', true ) ), 0, 12 ) );
|
||||
}
|
||||
}
|
||||
95
includes/core/class-prv-model-test-ajax.php
Normal file
95
includes/core/class-prv-model-test-ajax.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
/**
|
||||
* AJAX handler: Test model slug validity.
|
||||
*
|
||||
* @package PrVision
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* AJAX handler for the "Test" model button.
|
||||
*
|
||||
* Performs a single cheap probe to verify a model slug resolves at the
|
||||
* provider. Rate-limited to one test per slug per 30 seconds via transient.
|
||||
* This is a point-in-time check (not a monitor) -- the UI labels it as such.
|
||||
* Never displays the API key.
|
||||
*
|
||||
* Who triggers: wp_ajax_prv_test_model (registered by PRV_Settings_Page).
|
||||
* Dependencies: PRV_Model_Registry, PRV_Perplexity_Provider, PRV_OpenRouter_Provider.
|
||||
*
|
||||
* @see class-prv-settings-page.php -- Registers the wp_ajax_ hook.
|
||||
* @see class-prv-model-registry.php -- Provides model data by id.
|
||||
* @package PrVision
|
||||
*/
|
||||
class PRV_Model_Test_Ajax {
|
||||
|
||||
/**
|
||||
* Rate-limit window in seconds.
|
||||
*/
|
||||
const RATE_LIMIT_SECONDS = 30;
|
||||
|
||||
/**
|
||||
* Handle the prv_test_model AJAX request.
|
||||
*
|
||||
* Verifies capability + nonce, rate-limits, performs one probe, returns JSON.
|
||||
*
|
||||
* Side effects: HTTP call to LLM provider; sets a short-lived transient; exits.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function handle(): void {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( array( 'message' => 'Insufficient permissions.' ) );
|
||||
}
|
||||
|
||||
$nonce = isset( $_POST['prv_nonce'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['prv_nonce'] ) ) : '';
|
||||
if ( ! wp_verify_nonce( $nonce, PRV_Settings_Page::NONCE_TEST ) ) {
|
||||
wp_send_json_error( array( 'message' => 'Security check failed.' ) );
|
||||
}
|
||||
|
||||
$model_id = isset( $_POST['model_id'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['model_id'] ) ) : '';
|
||||
$model = PRV_Model_Registry::find_by_id( $model_id );
|
||||
|
||||
if ( null === $model ) {
|
||||
wp_send_json_error( array( 'message' => 'Model not found.' ) );
|
||||
}
|
||||
|
||||
$slug = (string) $model['slug'];
|
||||
$lock_key = 'prv_test_lock_' . md5( $slug );
|
||||
if ( false !== get_transient( $lock_key ) ) {
|
||||
wp_send_json_error( array( 'message' => 'Rate limited -- wait 30 s between tests.' ) );
|
||||
}
|
||||
set_transient( $lock_key, 1, self::RATE_LIMIT_SECONDS );
|
||||
|
||||
$start = microtime( true );
|
||||
$provider = ( 'perplexity/sonar' === $slug )
|
||||
? new PRV_Perplexity_Provider()
|
||||
: new PRV_OpenRouter_Provider( $slug );
|
||||
|
||||
if ( ! $provider->is_configured() ) {
|
||||
wp_send_json_error( array( 'message' => 'API key not configured (check wp-config.php).' ) );
|
||||
}
|
||||
|
||||
try {
|
||||
$provider->probe( 'what is BPC-157' );
|
||||
$ms = (int) round( ( microtime( true ) - $start ) * 1000 );
|
||||
wp_send_json_success(
|
||||
array(
|
||||
'resolved' => true,
|
||||
'latency_ms' => $ms,
|
||||
'slug' => $slug,
|
||||
'message' => sprintf( 'Resolved · %d ms', $ms ),
|
||||
)
|
||||
);
|
||||
} catch ( \Exception $e ) {
|
||||
wp_send_json_error(
|
||||
array(
|
||||
'resolved' => false,
|
||||
'slug' => $slug,
|
||||
'message' => 'Not resolved -- provider returned error (slug retired?)',
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,14 +10,16 @@ declare(strict_types=1);
|
||||
/**
|
||||
* Main orchestrator for the PR Vision plugin.
|
||||
*
|
||||
* Boots sub-systems in the correct order, hooks into WP, and keeps
|
||||
* the top-level file under the 300-line limit by delegating all logic
|
||||
* to specialist classes.
|
||||
* Boots sub-systems in the correct order, runs pending data migrations,
|
||||
* hooks into WP, and keeps the top-level file under the 300-line limit by
|
||||
* delegating all logic to specialist classes.
|
||||
*
|
||||
* Who triggers: plugins_loaded action (pr-vision.php).
|
||||
* Dependencies: PRV_Cron, PRV_Admin_Page, PRV_Collector_Registry.
|
||||
* Dependencies: PRV_Upgrader, PRV_Cron, PRV_Admin_Page, PRV_Settings_Page,
|
||||
* PRV_Collector_Registry.
|
||||
*
|
||||
* @see ARCHITECTURE.md — Boot sequence diagram.
|
||||
* @see ARCHITECTURE.md -- Boot sequence diagram.
|
||||
* @see class-prv-upgrader.php -- Runs migrations on every boot.
|
||||
* @package PrVision
|
||||
*/
|
||||
class PRV_Plugin {
|
||||
@@ -25,18 +27,28 @@ class PRV_Plugin {
|
||||
/**
|
||||
* Wire up all WordPress hooks.
|
||||
*
|
||||
* Side effects: Registers admin menus, enqueues assets, and registers the
|
||||
* collector/panel registry.
|
||||
* Migrations run before anything reads from prv_models so the v2 format
|
||||
* is always in place regardless of whether the plugin was freshly activated
|
||||
* or just upgraded from v0.1.x while live.
|
||||
*
|
||||
* Side effects: Registers admin menus, enqueues assets, registers the
|
||||
* collector/panel registry, runs data migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function init(): void {
|
||||
// Migrations first -- must run before any config read.
|
||||
PRV_Upgrader::run();
|
||||
|
||||
$cron = new PRV_Cron();
|
||||
$cron->register_hooks();
|
||||
|
||||
if ( is_admin() ) {
|
||||
$page = new PRV_Admin_Page();
|
||||
$page->register_hooks();
|
||||
|
||||
$settings = new PRV_Settings_Page();
|
||||
$settings->register_hooks();
|
||||
}
|
||||
|
||||
// Register the v1 AI-visibility collector + panel.
|
||||
|
||||
@@ -8,22 +8,25 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Orchestrates a full probe run: peptides × intents × models.
|
||||
* Orchestrates a full probe run: peptides x intents x models.
|
||||
*
|
||||
* For each combination the runner:
|
||||
* 1. Checks the monthly budget cap (aborts gracefully when hit).
|
||||
* 2. Dispatches to the correct provider (resolved from PRV_Config::get_models()).
|
||||
* 3. Writes a row to the prv_ai_visibility table.
|
||||
* 4. Settles the actual cost against the ledger row.
|
||||
* 1. Acquires the run-lock (fails fast if already locked).
|
||||
* 2. Checks the monthly budget cap (aborts gracefully when hit).
|
||||
* 3. Dispatches to the correct provider (resolved from PRV_Model_Registry).
|
||||
* 4. Writes a row to the prv_ai_visibility table with config_version stamp.
|
||||
* 5. Settles the actual cost against the ledger row.
|
||||
* 6. After the run, updates per-model health and releases the lock.
|
||||
*
|
||||
* Who triggers: PRV_Cron::handle_cron_tick() and PRV_Admin_Page (Run now action).
|
||||
* Dependencies: PRV_Config, PRV_Cost_Ledger, PRV_Table_Manager, all providers.
|
||||
* Who triggers: PRV_Cron::handle_cron_tick() and PRV_Settings_Page (Run now).
|
||||
* Dependencies: PRV_Config, PRV_Cost_Ledger, PRV_Table_Manager,
|
||||
* PRV_Run_Lock, PRV_Model_Registry, PRV_Config_Version, all providers.
|
||||
*
|
||||
* @see class-prv-cron.php — Calls run() on the weekly schedule.
|
||||
* @see class-prv-cost-ledger.php — Budget cap enforcement.
|
||||
* @see class-prv-perplexity-provider.php
|
||||
* @see class-prv-openrouter-provider.php
|
||||
* @see ARCHITECTURE.md — §Probe run flow.
|
||||
* @see class-prv-cron.php -- Calls run() on the weekly schedule.
|
||||
* @see class-prv-cost-ledger.php -- Budget cap enforcement.
|
||||
* @see class-prv-run-lock.php -- Concurrency guard.
|
||||
* @see class-prv-model-registry.php -- Per-model health updates.
|
||||
* @see ARCHITECTURE.md -- Section Probe run flow.
|
||||
* @package PrVision
|
||||
*/
|
||||
class PRV_Probe_Runner {
|
||||
@@ -47,27 +50,69 @@ class PRV_Probe_Runner {
|
||||
/**
|
||||
* Execute the full probe run.
|
||||
*
|
||||
* Iterates peptides × prompt intents × models. Aborts at the budget cap
|
||||
* without throwing — partial results are kept. Each successful probe is
|
||||
* Acquires a run-lock so the cron and "Run now" cannot collide.
|
||||
* Iterates peptides x prompt intents x models. Aborts at the budget cap
|
||||
* without throwing -- partial results are kept. Each successful probe is
|
||||
* persisted immediately so a mid-run failure doesn't lose data.
|
||||
*
|
||||
* Side effects: HTTP calls to LLM APIs, database writes.
|
||||
* Side effects: HTTP calls to LLM APIs, database writes, option writes (health).
|
||||
*
|
||||
* @return array{probed: int, skipped_budget: int, skipped_error: int}
|
||||
* Summary counts for the admin log.
|
||||
* @return array{probed: int, skipped_budget: int, skipped_error: int, truncated: bool, run_id: string}
|
||||
* Summary counts plus truncation flag for the admin display.
|
||||
*/
|
||||
public function run(): array {
|
||||
$run_id = $this->generate_run_id();
|
||||
$peptides = PRV_Config::get_peptides();
|
||||
$intents = PRV_Config::get_prompt_intents();
|
||||
$models = PRV_Config::get_models();
|
||||
$counts = array(
|
||||
'probed' => 0,
|
||||
'skipped_budget' => 0,
|
||||
'skipped_error' => 0,
|
||||
'truncated' => false,
|
||||
'run_id' => '',
|
||||
);
|
||||
|
||||
if ( ! PRV_Run_Lock::acquire() ) {
|
||||
$counts['skipped_error'] = -1; // Sentinel: lock busy.
|
||||
return $counts;
|
||||
}
|
||||
|
||||
try {
|
||||
$counts = $this->execute_run();
|
||||
} finally {
|
||||
PRV_Run_Lock::release();
|
||||
}
|
||||
|
||||
return $counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core run logic (called inside the lock).
|
||||
*
|
||||
* @return array{probed: int, skipped_budget: int, skipped_error: int, truncated: bool, run_id: string}
|
||||
*/
|
||||
private function execute_run(): array {
|
||||
$run_id = $this->generate_run_id();
|
||||
$config_ver = PRV_Config_Version::get_active_version();
|
||||
$peptides = PRV_Config::get_peptides();
|
||||
$intents = PRV_Config::get_prompt_intents();
|
||||
$models = PRV_Config::get_models();
|
||||
$model_outcomes = array(); // Per-slug: {probed, errors}.
|
||||
|
||||
foreach ( $models as $slug ) {
|
||||
$model_outcomes[ $slug ] = array(
|
||||
'probed' => 0,
|
||||
'errors' => 0,
|
||||
);
|
||||
}
|
||||
|
||||
$counts = array(
|
||||
'probed' => 0,
|
||||
'skipped_budget' => 0,
|
||||
'skipped_error' => 0,
|
||||
'truncated' => false,
|
||||
'run_id' => $run_id,
|
||||
);
|
||||
|
||||
$budget_hit = false;
|
||||
|
||||
foreach ( $peptides as $peptide ) {
|
||||
foreach ( $intents as $intent_tpl ) {
|
||||
$query = str_replace( '{peptide}', $peptide['label'], $intent_tpl );
|
||||
@@ -77,6 +122,9 @@ class PRV_Probe_Runner {
|
||||
|
||||
if ( null === $provider || ! $provider->is_configured() ) {
|
||||
++$counts['skipped_error'];
|
||||
if ( isset( $model_outcomes[ $model ] ) ) {
|
||||
++$model_outcomes[ $model ]['errors'];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -84,6 +132,7 @@ class PRV_Probe_Runner {
|
||||
$estimated = $this->get_estimated_cost( $model );
|
||||
if ( ! $this->ledger->can_afford( $estimated ) ) {
|
||||
++$counts['skipped_budget'];
|
||||
$budget_hit = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -93,34 +142,70 @@ class PRV_Probe_Runner {
|
||||
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
|
||||
error_log( sprintf( 'PRV: probe failed [%s / %s]: %s', $model, $peptide['slug'], $e->getMessage() ) );
|
||||
++$counts['skipped_error'];
|
||||
if ( isset( $model_outcomes[ $model ] ) ) {
|
||||
++$model_outcomes[ $model ]['errors'];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
$row_id = $this->persist_result( $run_id, $peptide, $model, $intent_tpl, $result );
|
||||
$row_id = $this->persist_result( $run_id, $peptide, $model, $intent_tpl, $result, $config_ver );
|
||||
if ( $row_id > 0 ) {
|
||||
$this->ledger->update_row_cost( $row_id, $result->get_cost_usd() );
|
||||
}
|
||||
|
||||
++$counts['probed'];
|
||||
if ( isset( $model_outcomes[ $model ] ) ) {
|
||||
++$model_outcomes[ $model ]['probed'];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ( $budget_hit && $counts['probed'] > 0 ) {
|
||||
$counts['truncated'] = true;
|
||||
}
|
||||
|
||||
// Record truncation state for dashboard display.
|
||||
if ( $counts['truncated'] ) {
|
||||
update_option( 'prv_last_run_truncated', 1 );
|
||||
update_option( 'prv_last_run_truncated_at', current_time( 'mysql', true ) );
|
||||
} else {
|
||||
update_option( 'prv_last_run_truncated', 0 );
|
||||
}
|
||||
|
||||
// Persist run summary for dashboard.
|
||||
update_option( 'prv_last_run_at', current_time( 'mysql', true ) );
|
||||
update_option( 'prv_last_run_counts', $counts );
|
||||
|
||||
// Update per-model run-health.
|
||||
PRV_Model_Registry::update_health( $run_id, $model_outcomes );
|
||||
|
||||
// Update API-key status (derive from whether probes succeeded).
|
||||
$this->update_api_key_status( $counts, $model_outcomes );
|
||||
|
||||
return $counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a single probe result to the database.
|
||||
* Persist a single probe result to the database with config_version stamp.
|
||||
*
|
||||
* @param string $run_id UUID for this run.
|
||||
* @param array<string,string> $peptide Peptide config with slug and label.
|
||||
* @param string $model Model identifier.
|
||||
* @param string $intent_tpl Raw intent template string.
|
||||
* @param PRV_Probe_Result $result Probe result.
|
||||
* @param int $config_ver Config version at run time.
|
||||
*
|
||||
* @return int Inserted row ID, or 0 on failure.
|
||||
*/
|
||||
private function persist_result( string $run_id, array $peptide, string $model, string $intent_tpl, PRV_Probe_Result $result ): int {
|
||||
private function persist_result(
|
||||
string $run_id,
|
||||
array $peptide,
|
||||
string $model,
|
||||
string $intent_tpl,
|
||||
PRV_Probe_Result $result,
|
||||
int $config_ver
|
||||
): int {
|
||||
global $wpdb;
|
||||
|
||||
$table = PRV_Table_Manager::get_table_name();
|
||||
@@ -140,13 +225,31 @@ class PRV_Probe_Runner {
|
||||
'source_domains' => wp_json_encode( $result->get_source_domains() ),
|
||||
'raw_excerpt' => $result->get_raw_excerpt(),
|
||||
'cost_usd' => number_format( $result->get_cost_usd(), 8, '.', '' ),
|
||||
'config_version' => $config_ver,
|
||||
),
|
||||
array( '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%s', '%s', '%s' )
|
||||
array( '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%s', '%s', '%s', '%d' )
|
||||
);
|
||||
|
||||
return (int) $wpdb->insert_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive + persist the API-key status from run outcomes.
|
||||
*
|
||||
* @param array<string, mixed> $counts Run counts.
|
||||
* @param array<string, array{probed: int, errors: int}> $model_outcomes Model outcomes.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function update_api_key_status( array $counts, array $model_outcomes ): void {
|
||||
if ( $counts['probed'] > 0 ) {
|
||||
update_option( 'prv_api_key_status', 'ok' );
|
||||
} elseif ( $counts['skipped_error'] > 0 && 0 === $counts['probed'] ) {
|
||||
update_option( 'prv_api_key_status', 'failed' );
|
||||
}
|
||||
update_option( 'prv_api_key_last_check', current_time( 'mysql', true ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantiate the correct provider class for a model string.
|
||||
*
|
||||
|
||||
94
includes/core/class-prv-run-lock.php
Normal file
94
includes/core/class-prv-run-lock.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
/**
|
||||
* Run-lock: prevents concurrent cron + Run-now executions.
|
||||
*
|
||||
* @package PrVision
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Distributed run-lock using a WP transient.
|
||||
*
|
||||
* Ensures exactly one probe run (cron or manual) executes at a time.
|
||||
* Acquiring the lock is atomic via the transient API with a TTL so stale
|
||||
* locks (e.g. from a crashed run) self-expire after PRV_RUN_LOCK_TTL_SECONDS.
|
||||
*
|
||||
* Who triggers: PRV_Probe_Runner::run() acquires; PRV_Admin_Page "Run now"
|
||||
* handler checks before dispatching.
|
||||
* Dependencies: set_transient(), get_transient(), delete_transient().
|
||||
*
|
||||
* @see class-prv-probe-runner.php -- Acquires + releases lock around the run.
|
||||
* @see class-prv-settings-page.php -- Checks is_locked() before Run now.
|
||||
* @package PrVision
|
||||
*/
|
||||
class PRV_Run_Lock {
|
||||
|
||||
/**
|
||||
* Transient key for the run lock.
|
||||
*/
|
||||
const LOCK_KEY = 'prv_run_lock';
|
||||
|
||||
/**
|
||||
* Lock TTL in seconds. Should exceed the longest plausible run.
|
||||
*/
|
||||
const TTL_SECONDS = 3600; // 1 hour.
|
||||
|
||||
/**
|
||||
* Attempt to acquire the run lock.
|
||||
*
|
||||
* Returns true only if the lock was not already held. Uses a non-blocking
|
||||
* check-and-set approach: if the transient already exists the lock is held.
|
||||
*
|
||||
* Side effects: Sets a WP transient when successful.
|
||||
*
|
||||
* @return bool True if lock acquired; false if already held.
|
||||
*/
|
||||
public static function acquire(): bool {
|
||||
if ( false !== get_transient( self::LOCK_KEY ) ) {
|
||||
return false; // Already locked.
|
||||
}
|
||||
// set_transient returns false if the transient already exists (on some hosts);
|
||||
// re-check after set to guard against the race window.
|
||||
set_transient( self::LOCK_KEY, time(), self::TTL_SECONDS );
|
||||
// Brief window for a second writer -- re-check.
|
||||
$val = get_transient( self::LOCK_KEY );
|
||||
return false !== $val;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the run lock.
|
||||
*
|
||||
* Should be called in a finally block after every run, whether successful
|
||||
* or not, to prevent lock starvation on non-fatal errors.
|
||||
*
|
||||
* Side effects: Deletes the WP transient.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function release(): void {
|
||||
delete_transient( self::LOCK_KEY );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a run is currently in progress.
|
||||
*
|
||||
* @return bool True when locked.
|
||||
*/
|
||||
public static function is_locked(): bool {
|
||||
return false !== get_transient( self::LOCK_KEY );
|
||||
}
|
||||
|
||||
/**
|
||||
* Return approximate seconds since the lock was acquired.
|
||||
*
|
||||
* @return int|null Null if not locked.
|
||||
*/
|
||||
public static function locked_since(): ?int {
|
||||
$val = get_transient( self::LOCK_KEY );
|
||||
if ( false === $val ) {
|
||||
return null;
|
||||
}
|
||||
return (int) ( time() - (int) $val );
|
||||
}
|
||||
}
|
||||
217
includes/core/class-prv-settings-controller.php
Normal file
217
includes/core/class-prv-settings-controller.php
Normal file
@@ -0,0 +1,217 @@
|
||||
<?php
|
||||
/**
|
||||
* PR Vision settings POST-action controller.
|
||||
*
|
||||
* @package PrVision
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Handles the four settings POST actions: save config, and model CRUD.
|
||||
*
|
||||
* Extracted from PRV_Settings_Page to keep each file under the 300-line
|
||||
* limit. PRV_Settings_Page registers all hooks and delegates the four
|
||||
* admin_post_* handlers here.
|
||||
*
|
||||
* All actions require: current_user_can('manage_options') + valid nonce
|
||||
* (enforced by PRV_Settings_Page::require_admin_nonce(), passed in via
|
||||
* the $nonce_cb callable).
|
||||
*
|
||||
* Who triggers: PRV_Settings_Page -- via delegation in register_hooks().
|
||||
* Dependencies: PRV_Config, PRV_Config_Version, PRV_Cron,
|
||||
* PRV_Model_Registry.
|
||||
*
|
||||
* @see class-prv-settings-page.php -- Page registration, dispatch, Run-now, AJAX.
|
||||
* @see class-prv-model-registry.php -- CRUD for prv_models.
|
||||
* @see class-prv-config-version.php -- Config-change versioning.
|
||||
* @see class-prv-cron.php -- Reschedule on cadence change.
|
||||
* @package PrVision
|
||||
*/
|
||||
class PRV_Settings_Controller {
|
||||
|
||||
/** Admin menu slug (shared constant with PRV_Settings_Page). */
|
||||
const MENU_SLUG = 'pr-vision-settings';
|
||||
|
||||
/**
|
||||
* Nonce-verification callback supplied by PRV_Settings_Page.
|
||||
*
|
||||
* @var callable(): void
|
||||
*/
|
||||
private $nonce_cb;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param callable $nonce_cb Called with a nonce-action string; wp_die on failure.
|
||||
*/
|
||||
public function __construct( callable $nonce_cb ) {
|
||||
$this->nonce_cb = $nonce_cb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the settings save POST action.
|
||||
*
|
||||
* Validates, sanitizes, saves peptides/intents/cadence/budget. Bumps
|
||||
* config-version if scoring-relevant fields changed. Reschedules cron
|
||||
* if cadence changed.
|
||||
*
|
||||
* Side effects: Updates wp_options, may reschedule cron, redirects.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function handle_save(): void {
|
||||
( $this->nonce_cb )( PRV_Settings_Page::NONCE_SAVE );
|
||||
$old_cadence = PRV_Config::get_cadence();
|
||||
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce verified above via require_admin_nonce().
|
||||
$budget = isset( $_POST['prv_monthly_budget_usd'] )
|
||||
? max( 0.01, (float) sanitize_text_field( wp_unslash( (string) $_POST['prv_monthly_budget_usd'] ) ) )
|
||||
: PRV_DEFAULT_MONTHLY_BUDGET_USD;
|
||||
update_option( 'prv_monthly_budget_usd', $budget );
|
||||
|
||||
$cadence_raw = isset( $_POST['prv_cadence'] )
|
||||
? sanitize_text_field( wp_unslash( (string) $_POST['prv_cadence'] ) )
|
||||
: 'weekly';
|
||||
$cadence = in_array( $cadence_raw, array( 'weekly', 'daily', 'twicedaily' ), true ) ? $cadence_raw : 'weekly';
|
||||
update_option( PRV_Config::CADENCE_KEY, $cadence );
|
||||
|
||||
if ( ! empty( $_POST['prv_peptides_json'] ) ) {
|
||||
$raw_json = sanitize_textarea_field( wp_unslash( (string) $_POST['prv_peptides_json'] ) );
|
||||
$decoded = json_decode( $raw_json, true );
|
||||
if ( is_array( $decoded ) ) {
|
||||
$clean = array();
|
||||
foreach ( $decoded as $p ) {
|
||||
if ( isset( $p['slug'], $p['label'] ) ) {
|
||||
$clean[] = array(
|
||||
'slug' => sanitize_text_field( (string) $p['slug'] ),
|
||||
'label' => sanitize_text_field( (string) $p['label'] ),
|
||||
);
|
||||
}
|
||||
}
|
||||
update_option( 'prv_peptides', $clean );
|
||||
}
|
||||
}
|
||||
|
||||
if ( isset( $_POST['prv_prompt_intents_text'] ) ) {
|
||||
$raw_intents = sanitize_textarea_field( wp_unslash( (string) $_POST['prv_prompt_intents_text'] ) );
|
||||
$intent_lines = array_filter( array_map( 'trim', explode( "\n", $raw_intents ) ) );
|
||||
update_option( 'prv_prompt_intents', array_values( $intent_lines ) );
|
||||
}
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||
|
||||
PRV_Config_Version::bump_version_if_changed();
|
||||
|
||||
if ( $cadence !== $old_cadence ) {
|
||||
PRV_Cron::reschedule( $cadence );
|
||||
}
|
||||
|
||||
wp_safe_redirect(
|
||||
add_query_arg(
|
||||
array(
|
||||
'page' => self::MENU_SLUG,
|
||||
'prv_saved' => 1,
|
||||
),
|
||||
admin_url( 'admin.php' )
|
||||
)
|
||||
);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Add model POST.
|
||||
*
|
||||
* Side effects: Adds to prv_models; may bump config-version; redirects.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function handle_model_add(): void {
|
||||
( $this->nonce_cb )( PRV_Settings_Page::NONCE_MODEL );
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce verified above via require_admin_nonce().
|
||||
$slug = sanitize_text_field( wp_unslash( (string) ( $_POST['prv_model_slug'] ?? '' ) ) );
|
||||
$provider = sanitize_text_field( wp_unslash( (string) ( $_POST['prv_model_provider'] ?? 'openrouter' ) ) );
|
||||
$note = sanitize_text_field( wp_unslash( (string) ( $_POST['prv_model_note'] ?? '' ) ) );
|
||||
$enabled = ! empty( $_POST['prv_model_enabled'] );
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||
if ( '' !== $slug ) {
|
||||
PRV_Model_Registry::add( $slug, $provider, $enabled, $note );
|
||||
PRV_Config_Version::bump_version_if_changed();
|
||||
}
|
||||
wp_safe_redirect(
|
||||
add_query_arg(
|
||||
array(
|
||||
'page' => self::MENU_SLUG,
|
||||
'prv_model_added' => 1,
|
||||
),
|
||||
admin_url( 'admin.php' )
|
||||
)
|
||||
);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Update model POST.
|
||||
*
|
||||
* Side effects: Updates prv_models; may bump config-version; redirects.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function handle_model_update(): void {
|
||||
( $this->nonce_cb )( PRV_Settings_Page::NONCE_MODEL );
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce verified above via require_admin_nonce().
|
||||
$id = sanitize_text_field( wp_unslash( (string) ( $_POST['prv_model_id'] ?? '' ) ) );
|
||||
$slug = sanitize_text_field( wp_unslash( (string) ( $_POST['prv_model_slug'] ?? '' ) ) );
|
||||
$enabled = ! empty( $_POST['prv_model_enabled'] );
|
||||
$note = sanitize_text_field( wp_unslash( (string) ( $_POST['prv_model_note'] ?? '' ) ) );
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||
if ( '' !== $id && '' !== $slug ) {
|
||||
PRV_Model_Registry::update(
|
||||
$id,
|
||||
array(
|
||||
'slug' => $slug,
|
||||
'enabled' => $enabled,
|
||||
'note' => $note,
|
||||
)
|
||||
);
|
||||
PRV_Config_Version::bump_version_if_changed();
|
||||
}
|
||||
wp_safe_redirect(
|
||||
add_query_arg(
|
||||
array(
|
||||
'page' => self::MENU_SLUG,
|
||||
'prv_model_updated' => 1,
|
||||
),
|
||||
admin_url( 'admin.php' )
|
||||
)
|
||||
);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Remove model POST.
|
||||
*
|
||||
* Side effects: Removes from prv_models; may bump config-version; redirects.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function handle_model_remove(): void {
|
||||
( $this->nonce_cb )( PRV_Settings_Page::NONCE_MODEL );
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce verified above via require_admin_nonce().
|
||||
$id = sanitize_text_field( wp_unslash( (string) ( $_POST['prv_model_id'] ?? '' ) ) );
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||
if ( '' !== $id ) {
|
||||
PRV_Model_Registry::remove( $id );
|
||||
PRV_Config_Version::bump_version_if_changed();
|
||||
}
|
||||
wp_safe_redirect(
|
||||
add_query_arg(
|
||||
array(
|
||||
'page' => self::MENU_SLUG,
|
||||
'prv_model_removed' => 1,
|
||||
),
|
||||
admin_url( 'admin.php' )
|
||||
)
|
||||
);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
193
includes/core/class-prv-settings-page.php
Normal file
193
includes/core/class-prv-settings-page.php
Normal file
@@ -0,0 +1,193 @@
|
||||
<?php
|
||||
/**
|
||||
* PR Vision admin settings page: model manager + config surface.
|
||||
*
|
||||
* @package PrVision
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* PR Vision Settings admin page.
|
||||
*
|
||||
* Registers "PR Vision > Settings" sub-menu, dispatches POST actions to
|
||||
* PRV_Settings_Controller, handles Run-now and AJAX test-model, and
|
||||
* delegates all HTML rendering to PRV_Settings_Renderer.
|
||||
*
|
||||
* All actions require: current_user_can('manage_options') + valid nonce.
|
||||
*
|
||||
* Who triggers: PRV_Plugin::init() -- is_admin() guard.
|
||||
* Dependencies: PRV_Settings_Controller, PRV_Settings_Renderer,
|
||||
* PRV_Model_Test_Ajax, PRV_Probe_Runner, PRV_Run_Lock.
|
||||
*
|
||||
* @see class-prv-settings-controller.php -- POST handler implementations.
|
||||
* @see class-prv-settings-renderer.php -- Renders the HTML.
|
||||
* @see class-prv-model-test-ajax.php -- AJAX test handler.
|
||||
* @see class-prv-model-registry.php -- CRUD for prv_models.
|
||||
* @see class-prv-config-version.php -- Config-change versioning.
|
||||
* @see class-prv-cron.php -- Reschedule on cadence change.
|
||||
* @package PrVision
|
||||
*/
|
||||
class PRV_Settings_Page {
|
||||
|
||||
/** Admin menu slug for the settings page. */
|
||||
const MENU_SLUG = 'pr-vision-settings';
|
||||
|
||||
/** Nonce action for settings save. */
|
||||
const NONCE_SAVE = 'prv_settings_save';
|
||||
|
||||
/** Nonce action for model CRUD. */
|
||||
const NONCE_MODEL = 'prv_model_action';
|
||||
|
||||
/** Nonce action for Run now on settings page. */
|
||||
const NONCE_RUN = 'prv_settings_run_now';
|
||||
|
||||
/** Nonce action for Test model AJAX. */
|
||||
const NONCE_TEST = 'prv_model_test';
|
||||
|
||||
/**
|
||||
* Register WordPress admin hooks.
|
||||
*
|
||||
* POST handlers for save/model-CRUD are delegated to PRV_Settings_Controller,
|
||||
* which receives a nonce-verification callback bound to this page.
|
||||
*
|
||||
* Side effects: Adds admin_menu, admin_post, and wp_ajax actions.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register_hooks(): void {
|
||||
$ctrl = new PRV_Settings_Controller(
|
||||
function ( string $action ): void {
|
||||
$this->require_admin_nonce( $action );
|
||||
}
|
||||
);
|
||||
|
||||
add_action( 'admin_menu', array( $this, 'add_settings_menu' ) );
|
||||
add_action( 'admin_post_prv_settings_save', array( $ctrl, 'handle_save' ) );
|
||||
add_action( 'admin_post_prv_model_add', array( $ctrl, 'handle_model_add' ) );
|
||||
add_action( 'admin_post_prv_model_update', array( $ctrl, 'handle_model_update' ) );
|
||||
add_action( 'admin_post_prv_model_remove', array( $ctrl, 'handle_model_remove' ) );
|
||||
add_action( 'admin_post_prv_settings_run_now', array( $this, 'handle_run_now' ) );
|
||||
add_action( 'wp_ajax_prv_test_model', array( $this, 'handle_test_model' ) );
|
||||
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the Settings sub-menu under PR Vision.
|
||||
*
|
||||
* Side effects: Adds a WP admin sub-menu entry.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function add_settings_menu(): void {
|
||||
add_submenu_page(
|
||||
'pr-vision',
|
||||
__( 'PR Vision Settings', 'pr-vision' ),
|
||||
__( 'Settings', 'pr-vision' ),
|
||||
'manage_options',
|
||||
self::MENU_SLUG,
|
||||
array( $this, 'render_page' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue settings page Chart.js.
|
||||
*
|
||||
* @param string $hook Current admin page hook.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function enqueue_assets( string $hook ): void {
|
||||
if ( false === strpos( $hook, self::MENU_SLUG ) ) {
|
||||
return;
|
||||
}
|
||||
wp_enqueue_script(
|
||||
'prv-chartjs',
|
||||
'https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js',
|
||||
array(),
|
||||
'4.4.3',
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Run now POST from the settings page.
|
||||
*
|
||||
* Refuses if a run is already in progress.
|
||||
*
|
||||
* Side effects: May trigger probe run; redirects.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function handle_run_now(): void {
|
||||
$this->require_admin_nonce( self::NONCE_RUN );
|
||||
if ( PRV_Run_Lock::is_locked() ) {
|
||||
wp_safe_redirect(
|
||||
add_query_arg(
|
||||
array(
|
||||
'page' => self::MENU_SLUG,
|
||||
'prv_run_locked' => 1,
|
||||
),
|
||||
admin_url( 'admin.php' )
|
||||
)
|
||||
);
|
||||
exit;
|
||||
}
|
||||
$runner = new PRV_Probe_Runner();
|
||||
$counts = $runner->run();
|
||||
$args = array(
|
||||
'page' => self::MENU_SLUG,
|
||||
'prv_run_done' => 1,
|
||||
'prv_probed' => (int) $counts['probed'],
|
||||
'prv_skipped' => (int) ( $counts['skipped_budget'] + max( 0, $counts['skipped_error'] ) ),
|
||||
'prv_truncated' => $counts['truncated'] ? 1 : 0,
|
||||
);
|
||||
wp_safe_redirect( add_query_arg( $args, admin_url( 'admin.php' ) ) );
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch the AJAX test-model request to PRV_Model_Test_Ajax.
|
||||
*
|
||||
* Side effects: Exits via PRV_Model_Test_Ajax::handle().
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function handle_test_model(): void {
|
||||
( new PRV_Model_Test_Ajax() )->handle();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the settings page.
|
||||
*
|
||||
* Side effects: Outputs HTML via PRV_Settings_Renderer.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function render_page(): void {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_die( esc_html__( 'Insufficient permissions.', 'pr-vision' ) );
|
||||
}
|
||||
( new PRV_Settings_Renderer() )->render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify admin capability and nonce; wp_die on failure.
|
||||
*
|
||||
* Called directly for Run-now, and passed as a callback to
|
||||
* PRV_Settings_Controller for the four POST handlers.
|
||||
*
|
||||
* @param string $action Nonce action.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function require_admin_nonce( string $action ): void {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_die( esc_html__( 'Insufficient permissions.', 'pr-vision' ), 403 );
|
||||
}
|
||||
$nonce = isset( $_POST['prv_nonce'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['prv_nonce'] ) ) : '';
|
||||
if ( ! wp_verify_nonce( $nonce, $action ) ) {
|
||||
wp_die( esc_html__( 'Security check failed.', 'pr-vision' ), 403 );
|
||||
}
|
||||
}
|
||||
}
|
||||
298
includes/core/class-prv-settings-renderer.php
Normal file
298
includes/core/class-prv-settings-renderer.php
Normal file
@@ -0,0 +1,298 @@
|
||||
<?php
|
||||
/**
|
||||
* Settings page HTML renderer for PR Vision.
|
||||
*
|
||||
* @package PrVision
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Renders the PR Vision Settings admin page (dark "Assay" theme).
|
||||
*
|
||||
* Coordinates the page sections: styles, notices, model manager card
|
||||
* (via PRV_Model_Manager_Table), secondary config form, sticky save-bar,
|
||||
* and inline JS. All output is escaped at the point of emission.
|
||||
*
|
||||
* Who triggers: PRV_Settings_Page::render_page().
|
||||
* Dependencies: PRV_Model_Registry, PRV_Config, PRV_Cron, PRV_Run_Lock,
|
||||
* PRV_Config_Version, PRV_Model_Manager_Table.
|
||||
*
|
||||
* @see class-prv-settings-page.php -- Instantiates and calls render().
|
||||
* @see class-prv-model-manager-table.php -- Renders the model table.
|
||||
* @package PrVision
|
||||
*/
|
||||
class PRV_Settings_Renderer {
|
||||
|
||||
/**
|
||||
* Render the full settings page.
|
||||
*
|
||||
* Side effects: Outputs HTML directly.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function render(): void {
|
||||
$this->render_styles();
|
||||
echo '<div class="prv-settings-wrap">';
|
||||
echo '<h1 class="prv-page-title">' . esc_html__( 'PR Vision — Settings', 'pr-vision' ) . '</h1>';
|
||||
$this->render_notices();
|
||||
echo '<div class="prv-card"><h2>' . esc_html__( 'Model Manager', 'pr-vision' ) . '</h2>';
|
||||
( new PRV_Model_Manager_Table() )->render();
|
||||
echo '</div>';
|
||||
$this->render_config_form();
|
||||
$this->render_save_bar();
|
||||
$this->render_scripts();
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render dark CSS variables and component styles.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function render_styles(): void {
|
||||
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
echo '<style>
|
||||
:root{--prv-bg:#14181C;--prv-surface:#1C2228;--prv-surface2:#232B33;--prv-surface3:#2A333C;
|
||||
--prv-border:#333D47;--prv-border-strong:#3A4651;--prv-border-bright:#46535F;
|
||||
--prv-text:#EEF2F5;--prv-text-sec:#C2CCD6;--prv-text-muted:#9AA7B2;--prv-ink:#0D1116;
|
||||
--prv-teal:#34C0CA;--prv-teal-hover:#4ACCD5;--prv-lime:#9BE635;
|
||||
--prv-orange:#FFB36E;--prv-red:#FF6B5E;--prv-red-chip:#FF8A80;
|
||||
--prv-gray-absent:#8893A0;--prv-chip-text-disabled:#B4BDC7;
|
||||
--prv-tint-red:rgba(255,107,94,.13);--prv-tint-orange:rgba(255,179,110,.12);
|
||||
--prv-tint-lime:rgba(155,230,53,.12);--prv-tint-gray:rgba(136,147,160,.12);}
|
||||
.prv-settings-wrap{background:var(--prv-bg);color:var(--prv-text);padding:24px;font-family:Inter,sans-serif;min-height:600px;}
|
||||
.prv-page-title{color:var(--prv-text);font-family:Poppins,sans-serif;font-size:24px;font-weight:700;margin:0 0 20px;}
|
||||
.prv-card{background:var(--prv-surface);border:1px solid var(--prv-border-strong);border-radius:8px;padding:24px;margin-bottom:20px;}
|
||||
.prv-card h2{color:var(--prv-text);font-size:16px;font-weight:600;margin:0 0 16px;}
|
||||
.prv-table{width:100%;border-collapse:collapse;font-size:13px;}
|
||||
.prv-table th{background:var(--prv-surface2);color:var(--prv-text-sec);font-weight:600;text-align:left;padding:8px 12px;border-bottom:1px solid var(--prv-border);}
|
||||
.prv-table th button{background:none;border:none;color:inherit;cursor:pointer;font:inherit;padding:0;display:inline-flex;align-items:center;gap:4px;}
|
||||
.prv-table th button:focus-visible{outline:2px solid var(--prv-teal);outline-offset:2px;}
|
||||
.prv-table td{padding:8px 12px;border-bottom:1px solid var(--prv-border);color:var(--prv-text-sec);vertical-align:middle;}
|
||||
.prv-row-retired{border-left:3px solid var(--prv-orange);background:repeating-linear-gradient(135deg,var(--prv-surface) 0,var(--prv-surface) 6px,var(--prv-surface2) 6px,var(--prv-surface2) 12px);}
|
||||
.prv-badge{display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border-radius:999px;font-size:12px;font-weight:500;}
|
||||
.prv-badge-healthy{background:var(--prv-tint-lime);color:var(--prv-lime);}
|
||||
.prv-badge-retired{background:var(--prv-tint-red);color:var(--prv-red-chip);}
|
||||
.prv-badge-disabled{background:var(--prv-tint-gray);color:var(--prv-chip-text-disabled);}
|
||||
.prv-badge-unknown{background:var(--prv-tint-gray);color:var(--prv-text-muted);}
|
||||
.prv-btn{display:inline-flex;align-items:center;gap:6px;padding:6px 14px;border-radius:6px;font-size:13px;cursor:pointer;border:none;font-weight:500;transition:background .12s;}
|
||||
.prv-btn:focus-visible{outline:2px solid var(--prv-ink);outline-offset:2px;}
|
||||
.prv-btn-primary{background:var(--prv-teal);color:var(--prv-ink);}
|
||||
.prv-btn-primary:hover{background:var(--prv-teal-hover);}
|
||||
.prv-btn-ghost{background:transparent;color:var(--prv-teal);border:1px solid var(--prv-border-bright);}
|
||||
.prv-btn-ghost:hover{background:var(--prv-surface3);}
|
||||
.prv-btn-danger{background:transparent;color:var(--prv-red-chip);border:1px solid var(--prv-border-bright);}
|
||||
.prv-btn-danger:hover{background:var(--prv-tint-red);}
|
||||
.prv-btn:disabled,.prv-btn[disabled]{opacity:.5;cursor:not-allowed;}
|
||||
.prv-input{background:var(--prv-surface2);border:1px solid var(--prv-border-bright);border-radius:6px;color:var(--prv-text);padding:7px 10px;font-size:13px;width:100%;box-sizing:border-box;}
|
||||
.prv-input:focus{outline:2px solid var(--prv-teal);outline-offset:1px;}
|
||||
.prv-label{display:block;color:var(--prv-text-sec);font-size:12px;margin-bottom:4px;}
|
||||
.prv-field{margin-bottom:14px;}
|
||||
.prv-grid2{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
|
||||
.prv-cost-inset{background:var(--prv-surface2);border:1px solid var(--prv-border);border-radius:6px;padding:12px;margin-top:8px;font-size:12px;color:var(--prv-text-sec);}
|
||||
.prv-cost-inset.over-cap{border-color:var(--prv-orange);background:var(--prv-tint-orange);color:var(--prv-orange);}
|
||||
.prv-api-status{padding:8px 12px;border-radius:6px;font-size:13px;display:inline-flex;align-items:center;gap:8px;}
|
||||
.prv-api-ok{background:var(--prv-tint-lime);color:var(--prv-lime);}
|
||||
.prv-api-fail{background:var(--prv-tint-red);color:var(--prv-red-chip);}
|
||||
.prv-api-undef{background:var(--prv-tint-gray);color:var(--prv-text-muted);}
|
||||
.prv-savebar{position:sticky;bottom:0;background:var(--prv-surface2);border-top:1px solid var(--prv-border-strong);padding:12px 24px;display:flex;align-items:center;gap:12px;z-index:50;}
|
||||
.prv-savebar-warn{color:var(--prv-orange);font-size:12px;flex:1;display:none;}
|
||||
.prv-savebar-warn.visible{display:block;}
|
||||
.prv-notice{padding:10px 14px;border-radius:6px;font-size:13px;margin-bottom:14px;}
|
||||
.prv-notice-success{background:var(--prv-tint-lime);color:var(--prv-lime);}
|
||||
.prv-notice-warning{background:var(--prv-tint-orange);color:var(--prv-orange);}
|
||||
.prv-section-label{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--prv-text-muted);margin-bottom:8px;}
|
||||
@media print{.prv-settings-wrap{background:#fff;color:#111;}.prv-card{background:#fff;border-color:#ccc;}.prv-savebar,.prv-btn-primary,.prv-btn-ghost,.prv-btn-danger{display:none!important;}.prv-table th{background:#f3f3f3;color:#333;}.prv-table td{color:#444;}}
|
||||
</style>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render POST-redirect notices (saved, run done, model CRUD, lock).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function render_notices(): void {
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Recommended
|
||||
if ( ! empty( $_GET['prv_saved'] ) ) {
|
||||
echo '<div class="prv-notice prv-notice-success">' . esc_html__( 'Settings saved.', 'pr-vision' ) . '</div>';
|
||||
}
|
||||
if ( ! empty( $_GET['prv_run_done'] ) ) {
|
||||
$p = absint( $_GET['prv_probed'] ?? 0 );
|
||||
$s = absint( $_GET['prv_skipped'] ?? 0 );
|
||||
$trn = ! empty( $_GET['prv_truncated'] );
|
||||
$msg = sprintf( 'Run complete: %d probed, %d skipped.%s', $p, $s, $trn ? ' Last run was truncated by the monthly budget cap.' : '' );
|
||||
echo '<div class="prv-notice prv-notice-success">' . esc_html( $msg ) . '</div>';
|
||||
}
|
||||
if ( ! empty( $_GET['prv_run_locked'] ) ) {
|
||||
echo '<div class="prv-notice prv-notice-warning">' . esc_html__( 'A run is already in progress — try again when it finishes.', 'pr-vision' ) . '</div>';
|
||||
}
|
||||
if ( ! empty( $_GET['prv_model_added'] ) ) {
|
||||
echo '<div class="prv-notice prv-notice-success">' . esc_html__( 'Model added.', 'pr-vision' ) . '</div>';
|
||||
}
|
||||
if ( ! empty( $_GET['prv_model_updated'] ) ) {
|
||||
echo '<div class="prv-notice prv-notice-success">' . esc_html__( 'Model updated.', 'pr-vision' ) . '</div>';
|
||||
}
|
||||
if ( ! empty( $_GET['prv_model_removed'] ) ) {
|
||||
echo '<div class="prv-notice prv-notice-success">' . esc_html__( 'Model removed.', 'pr-vision' ) . '</div>';
|
||||
}
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Recommended
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the secondary config form.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function render_config_form(): void {
|
||||
echo '<form method="post" action="' . esc_url( admin_url( 'admin-post.php' ) ) . '" id="prv-config-form">';
|
||||
echo '<input type="hidden" name="action" value="prv_settings_save">';
|
||||
wp_nonce_field( PRV_Settings_Page::NONCE_SAVE, 'prv_nonce' );
|
||||
echo '<div class="prv-card"><h2>' . esc_html__( 'Probe Configuration', 'pr-vision' ) . '</h2>';
|
||||
$this->render_cadence_field();
|
||||
$this->render_budget_field();
|
||||
$this->render_projected_cost();
|
||||
$this->render_peptides_field();
|
||||
$this->render_intents_field();
|
||||
$this->render_api_key_status();
|
||||
echo '</div></form>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render cadence selector with next-run timestamp.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function render_cadence_field(): void {
|
||||
$cadence = PRV_Config::get_cadence();
|
||||
$next_ts = PRV_Cron::next_run_timestamp();
|
||||
$next_str = $next_ts ? gmdate( 'Y-m-d H:i T', (int) $next_ts ) : 'Not scheduled';
|
||||
echo '<div class="prv-field"><label class="prv-label" for="prv_cadence">' . esc_html__( 'Probe cadence', 'pr-vision' ) . '</label>';
|
||||
echo '<select class="prv-input" id="prv_cadence" name="prv_cadence" data-scoring-relevant="1" style="max-width:200px">';
|
||||
$cadence_options = array(
|
||||
'weekly' => 'Weekly',
|
||||
'twicedaily' => 'Twice daily',
|
||||
'daily' => 'Daily',
|
||||
);
|
||||
foreach ( $cadence_options as $v => $l ) {
|
||||
echo '<option value="' . esc_attr( $v ) . '"' . selected( $cadence, $v, false ) . '>' . esc_html( $l ) . '</option>';
|
||||
}
|
||||
echo '</select>';
|
||||
echo '<p style="color:var(--prv-text-muted);font-size:12px;margin:4px 0 0">' . esc_html__( 'Next run: ', 'pr-vision' ) . esc_html( $next_str ) . '</p></div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render monthly budget cap input.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function render_budget_field(): void {
|
||||
$budget = PRV_Config::get_monthly_budget_usd();
|
||||
echo '<div class="prv-field"><label class="prv-label" for="prv_monthly_budget_usd">' . esc_html__( 'Monthly budget cap (USD)', 'pr-vision' ) . '</label>';
|
||||
echo '<input class="prv-input" style="max-width:160px;" type="number" min="0.01" step="0.01" id="prv_monthly_budget_usd" name="prv_monthly_budget_usd" value="' . esc_attr( number_format( $budget, 2, '.', '' ) ) . '" data-scoring-relevant="1"></div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render live projected-cost inset panel.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function render_projected_cost(): void {
|
||||
$cost = PRV_Config::get_projected_cost();
|
||||
$cls = $cost['over_cap'] ? 'prv-cost-inset over-cap' : 'prv-cost-inset';
|
||||
echo '<div id="prv-cost-inset" class="' . esc_attr( $cls ) . '" aria-live="polite">';
|
||||
echo '<strong>Projected cost:</strong> ~$' . esc_html( number_format( $cost['per_run_usd'], 4 ) ) . '/run · ';
|
||||
echo '~$' . esc_html( number_format( $cost['per_month_usd'], 4 ) ) . '/month · ';
|
||||
echo esc_html( (string) $cost['probe_count'] ) . ' probes/run';
|
||||
if ( $cost['over_cap'] ) {
|
||||
echo ' <strong>⚠ Over cap — runs will truncate</strong>';
|
||||
}
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render peptide set textarea (JSON).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function render_peptides_field(): void {
|
||||
$peptides = PRV_Config::get_peptides();
|
||||
echo '<div class="prv-field"><label class="prv-label">' . esc_html__( 'Peptide set', 'pr-vision' ) . '</label>';
|
||||
echo '<p style="color:var(--prv-text-muted);font-size:12px;margin:0 0 6px">' . esc_html( count( $peptides ) ) . ' peptides (JSON array of {slug, label}).</p>';
|
||||
echo '<textarea class="prv-input" name="prv_peptides_json" rows="5" style="font-family:monospace;font-size:12px;" data-scoring-relevant="1">' . esc_textarea( (string) wp_json_encode( $peptides ) ) . '</textarea></div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render prompt intents textarea.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function render_intents_field(): void {
|
||||
$intents = PRV_Config::get_prompt_intents();
|
||||
echo '<div class="prv-field"><label class="prv-label" for="prv_prompt_intents_text">' . esc_html__( 'Prompt intents (one per line, use {peptide})', 'pr-vision' ) . '</label>';
|
||||
echo '<textarea class="prv-input" id="prv_prompt_intents_text" name="prv_prompt_intents_text" rows="4" data-scoring-relevant="1">' . esc_textarea( implode( "\n", $intents ) ) . '</textarea></div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the read-only API-key three-state status.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function render_api_key_status(): void {
|
||||
$defined = defined( 'PRV_OPENROUTER_API_KEY' ) && '' !== constant( 'PRV_OPENROUTER_API_KEY' );
|
||||
$status = (string) get_option( 'prv_api_key_status', 'unknown' );
|
||||
echo '<div class="prv-field"><div class="prv-section-label">' . esc_html__( 'API Key Status (read-only)', 'pr-vision' ) . '</div>';
|
||||
if ( ! $defined ) {
|
||||
echo '<span class="prv-api-status prv-api-undef">● ' . esc_html__( 'Not defined — set PRV_OPENROUTER_API_KEY in wp-config.php', 'pr-vision' ) . '</span>';
|
||||
} elseif ( 'failed' === $status ) {
|
||||
echo '<span class="prv-api-status prv-api-fail">● ' . esc_html__( 'Defined — last run failed (key may be invalid or revoked)', 'pr-vision' ) . '</span>';
|
||||
} else {
|
||||
echo '<span class="prv-api-status prv-api-ok">● ' . esc_html__( 'Defined — last run OK', 'pr-vision' ) . '</span>';
|
||||
}
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the sticky save-bar with scoring-change warning and Run now.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function render_save_bar(): void {
|
||||
echo '<div class="prv-savebar" id="prv-savebar">';
|
||||
echo '<button type="submit" form="prv-config-form" class="prv-btn prv-btn-primary">' . esc_html__( 'Save settings', 'pr-vision' ) . '</button>';
|
||||
echo '<span id="prv-savebar-warn" class="prv-savebar-warn" role="alert"></span>';
|
||||
echo '<form method="post" action="' . esc_url( admin_url( 'admin-post.php' ) ) . '" id="prv-run-form" style="margin:0">';
|
||||
echo '<input type="hidden" name="action" value="prv_settings_run_now">';
|
||||
wp_nonce_field( PRV_Settings_Page::NONCE_RUN, 'prv_nonce' );
|
||||
$locked = PRV_Run_Lock::is_locked();
|
||||
echo '<button type="submit" id="prv-run-btn" class="prv-btn prv-btn-ghost"' . ( $locked ? ' disabled' : '' ) . '>';
|
||||
echo $locked ? esc_html__( 'Run in progress…', 'pr-vision' ) : esc_html__( 'Run now', 'pr-vision' );
|
||||
echo '</button>';
|
||||
echo '</form>';
|
||||
$next_ts = PRV_Cron::next_run_timestamp();
|
||||
if ( $next_ts ) {
|
||||
echo '<span style="color:var(--prv-text-muted);font-size:12px">' . esc_html__( 'Next auto-run: ', 'pr-vision' ) . esc_html( gmdate( 'Y-m-d H:i T', (int) $next_ts ) ) . '</span>';
|
||||
}
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render inline JS: scoring-change warn, sort headers, Test AJAX, Run-now guard.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function render_scripts(): void {
|
||||
$ajax_url = admin_url( 'admin-ajax.php' );
|
||||
$nonce = wp_create_nonce( PRV_Settings_Page::NONCE_TEST );
|
||||
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
echo '<script>(function(){
|
||||
var ajaxUrl=' . wp_json_encode( $ajax_url ) . ',testNonce=' . wp_json_encode( $nonce ) . ';
|
||||
var runFired=false;
|
||||
var rf=document.getElementById("prv-run-form");
|
||||
if(rf){rf.addEventListener("submit",function(e){if(runFired){e.preventDefault();return;}runFired=true;var b=document.getElementById("prv-run-btn");if(b){b.disabled=true;b.textContent="Running…";}});}
|
||||
document.querySelectorAll("[data-scoring-relevant]").forEach(function(f){f.addEventListener("change",function(){var w=document.getElementById("prv-savebar-warn");if(w){w.textContent="Scoring-relevant change — this breaks comparison with prior runs. A new config version will be recorded.";w.classList.add("visible");}var s=document.querySelector(".prv-savebar .prv-btn-primary");if(s){s.textContent="Save anyway";}});});
|
||||
document.querySelectorAll("th[aria-sort] button").forEach(function(btn){btn.addEventListener("click",function(){var th=btn.closest("th"),table=th.closest("table"),idx=Array.from(th.parentElement.children).indexOf(th);var asc=th.getAttribute("aria-sort")!=="ascending";table.querySelectorAll("th[aria-sort]").forEach(function(h){h.setAttribute("aria-sort","none");});th.setAttribute("aria-sort",asc?"ascending":"descending");var tb=table.querySelector("tbody"),rows=Array.from(tb.querySelectorAll("tr"));rows.sort(function(a,b){var ac=(a.cells[idx]||{}).textContent||"",bc=(b.cells[idx]||{}).textContent||"";return asc?ac.localeCompare(bc):bc.localeCompare(ac);});rows.forEach(function(r){tb.appendChild(r);});});});
|
||||
document.querySelectorAll(".prv-test-btn").forEach(function(btn){btn.addEventListener("click",function(){var mid=btn.dataset.modelId,chip=document.getElementById("prv-test-chip-"+mid);btn.disabled=true;btn.setAttribute("aria-busy","true");if(chip){chip.textContent="Testing…";chip.className="prv-badge prv-badge-unknown";}fetch(ajaxUrl,{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({action:"prv_test_model",model_id:mid,prv_nonce:testNonce})}).then(function(r){return r.json();}).then(function(d){btn.disabled=false;btn.removeAttribute("aria-busy");if(d.success){if(chip){chip.textContent="✓ "+d.data.message;chip.className="prv-badge prv-badge-healthy";}}else{if(chip){chip.textContent="✗ "+(d.data?d.data.message:"Failed");chip.className="prv-badge prv-badge-retired";}}}).catch(function(){btn.disabled=false;btn.removeAttribute("aria-busy");if(chip){chip.textContent="Error";chip.className="prv-badge prv-badge-retired";}});});});
|
||||
})();</script>';
|
||||
}
|
||||
}
|
||||
@@ -14,11 +14,14 @@ declare(strict_types=1);
|
||||
* schema version in the prv_schema_version option so future migrations can
|
||||
* detect when an upgrade is needed without destructive ALTER TABLE.
|
||||
*
|
||||
* Who triggers: PRV_Activator::activate() on plugin activation.
|
||||
* v0.2.0 adds config_version INT column (nullable; existing rows get NULL
|
||||
* which is handled cleanly by scoring queries that filter by version).
|
||||
*
|
||||
* Who triggers: PRV_Upgrader::run() on every plugins_loaded call (idempotent).
|
||||
* Dependencies: $wpdb global, dbDelta() (requires upgrade.php).
|
||||
*
|
||||
* @see class-prv-activator.php — Calls create_table() on activation.
|
||||
* @see ARCHITECTURE.md — §Storage.
|
||||
* @see class-prv-upgrader.php -- Calls create_table() on upgrade.
|
||||
* @see ARCHITECTURE.md -- Section Storage.
|
||||
* @package PrVision
|
||||
*/
|
||||
class PRV_Table_Manager {
|
||||
@@ -28,11 +31,17 @@ class PRV_Table_Manager {
|
||||
*/
|
||||
const TABLE_BASE = 'prv_ai_visibility';
|
||||
|
||||
/**
|
||||
* Current DB schema version.
|
||||
*/
|
||||
const SCHEMA_VERSION = 2;
|
||||
|
||||
/**
|
||||
* Create or upgrade the AI-visibility table.
|
||||
*
|
||||
* Uses dbDelta to create or non-destructively alter the table.
|
||||
* Records the schema version in an option afterwards.
|
||||
* Safe to call on every plugins_loaded -- dbDelta is idempotent.
|
||||
*
|
||||
* Side effects: Writes to the database; sets prv_schema_version option.
|
||||
*
|
||||
@@ -45,22 +54,24 @@ class PRV_Table_Manager {
|
||||
$charset_collate = $wpdb->get_charset_collate();
|
||||
|
||||
$sql = "CREATE TABLE {$table_name} (
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
run_id VARCHAR(36) NOT NULL,
|
||||
captured_at DATETIME NOT NULL,
|
||||
peptide_slug VARCHAR(200) NOT NULL,
|
||||
peptide_label VARCHAR(200) NOT NULL,
|
||||
model VARCHAR(200) NOT NULL,
|
||||
prompt_intent VARCHAR(200) NOT NULL,
|
||||
cited TINYINT(1) NOT NULL DEFAULT 0,
|
||||
our_position INT(11) NULL DEFAULT NULL,
|
||||
source_domains LONGTEXT NOT NULL DEFAULT '',
|
||||
raw_excerpt LONGTEXT NOT NULL DEFAULT '',
|
||||
cost_usd DECIMAL(12,8) NOT NULL DEFAULT 0.00000000,
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
run_id VARCHAR(36) NOT NULL,
|
||||
captured_at DATETIME NOT NULL,
|
||||
peptide_slug VARCHAR(200) NOT NULL,
|
||||
peptide_label VARCHAR(200) NOT NULL,
|
||||
model VARCHAR(200) NOT NULL,
|
||||
prompt_intent VARCHAR(200) NOT NULL,
|
||||
cited TINYINT(1) NOT NULL DEFAULT 0,
|
||||
our_position INT(11) NULL DEFAULT NULL,
|
||||
source_domains LONGTEXT NOT NULL DEFAULT '',
|
||||
raw_excerpt LONGTEXT NOT NULL DEFAULT '',
|
||||
cost_usd DECIMAL(12,8) NOT NULL DEFAULT 0.00000000,
|
||||
config_version INT(11) NULL DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY run_id (run_id),
|
||||
KEY peptide_slug (peptide_slug),
|
||||
KEY captured_at (captured_at)
|
||||
KEY captured_at (captured_at),
|
||||
KEY config_version (config_version)
|
||||
) {$charset_collate};";
|
||||
|
||||
if ( ! function_exists( 'dbDelta' ) ) {
|
||||
@@ -69,7 +80,7 @@ class PRV_Table_Manager {
|
||||
|
||||
dbDelta( $sql );
|
||||
|
||||
update_option( 'prv_schema_version', PRV_SCHEMA_VERSION );
|
||||
update_option( 'prv_schema_version', self::SCHEMA_VERSION );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,7 +96,7 @@ class PRV_Table_Manager {
|
||||
/**
|
||||
* Drop the AI-visibility table entirely.
|
||||
*
|
||||
* Called only from uninstall.php — never during normal deactivation.
|
||||
* Called only from uninstall.php -- never during normal deactivation.
|
||||
*
|
||||
* Side effects: Permanently destroys all probe data.
|
||||
*
|
||||
|
||||
40
includes/core/class-prv-upgrader.php
Normal file
40
includes/core/class-prv-upgrader.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
/**
|
||||
* Upgrade runner: idempotent, version-gated migrations.
|
||||
*
|
||||
* @package PrVision
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Runs pending data migrations on every plugins_loaded call.
|
||||
*
|
||||
* Each migration is version-gated (cheap option check first) so it only
|
||||
* runs when truly needed. Safe to call repeatedly -- all migrations are
|
||||
* idempotent. This fires on UPGRADE (not just activation), so the plugin
|
||||
* running live on prod without a fresh activation still gets migrated.
|
||||
*
|
||||
* Who triggers: PRV_Plugin::init() at plugins_loaded priority 1.
|
||||
* Dependencies: PRV_Model_Registry, PRV_Config_Version.
|
||||
*
|
||||
* @see class-prv-model-registry.php -- Migration v2 implementation.
|
||||
* @see class-prv-config-version.php -- Scoring-config versioning.
|
||||
* @see class-prv-plugin.php -- Calls PRV_Upgrader::run().
|
||||
* @package PrVision
|
||||
*/
|
||||
class PRV_Upgrader {
|
||||
|
||||
/**
|
||||
* Run all pending migrations in version order.
|
||||
*
|
||||
* Side effects: May write to wp_options (migration state).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function run(): void {
|
||||
PRV_Table_Manager::create_table();
|
||||
PRV_Model_Registry::run_migration_v2();
|
||||
PRV_Config_Version::maybe_seed_initial_version();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user