PR Vision v0.1.1: rename from peptide-geo-monitor + QA P2 fixes (#2)

Rename to PR Vision (PRV_/pr-vision); fix www-strip char-mask in competitor display (str_starts_with); make PHPCS a CI gate; remove dead in-run cache.

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

Agent-Session: pr-vision rename v0.1.1 merge
This commit is contained in:
peptiderepo
2026-06-13 17:17:52 +07:00
committed by GitHub
parent 46fda89e18
commit 401077fe45
40 changed files with 1277 additions and 1056 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,197 @@
<?php
/**
* Orchestrates a full probe run across peptides, intents, and models.
*
* @package PrVision
*/
declare(strict_types=1);
/**
* Orchestrates a full probe run: peptides × intents × models.
*
* For each combination the runner:
* 1. Checks the monthly budget cap (aborts gracefully when hit).
* 2. Dispatches to the correct provider (resolved from PRV_Config::get_models()).
* 3. Writes a row to the prv_ai_visibility table.
* 4. Settles the actual cost against the ledger row.
*
* Who triggers: PRV_Cron::handle_cron_tick() and PRV_Admin_Page (Run now action).
* Dependencies: PRV_Config, PRV_Cost_Ledger, PRV_Table_Manager, all providers.
*
* @see class-prv-cron.php — Calls run() on the weekly schedule.
* @see class-prv-cost-ledger.php — Budget cap enforcement.
* @see class-prv-perplexity-provider.php
* @see class-prv-openrouter-provider.php
* @see ARCHITECTURE.md — §Probe run flow.
* @package PrVision
*/
class PRV_Probe_Runner {
/**
* Budget ledger for cost-cap enforcement.
*
* @var PRV_Cost_Ledger
*/
private PRV_Cost_Ledger $ledger;
/**
* Constructor.
*
* @param PRV_Cost_Ledger|null $ledger Injected for testing; auto-created otherwise.
*/
public function __construct( ?PRV_Cost_Ledger $ledger = null ) {
$this->ledger = $ledger ?? new PRV_Cost_Ledger();
}
/**
* Execute the full probe run.
*
* Iterates peptides × prompt intents × models. Aborts at the budget cap
* without throwing — partial results are kept. Each successful probe is
* persisted immediately so a mid-run failure doesn't lose data.
*
* Side effects: HTTP calls to LLM APIs, database writes.
*
* @return array{probed: int, skipped_budget: int, skipped_error: int}
* Summary counts for the admin log.
*/
public function run(): array {
$run_id = $this->generate_run_id();
$peptides = PRV_Config::get_peptides();
$intents = PRV_Config::get_prompt_intents();
$models = PRV_Config::get_models();
$counts = array(
'probed' => 0,
'skipped_budget' => 0,
'skipped_error' => 0,
);
foreach ( $peptides as $peptide ) {
foreach ( $intents as $intent_tpl ) {
$query = str_replace( '{peptide}', $peptide['label'], $intent_tpl );
foreach ( $models as $model ) {
$provider = $this->resolve_provider( $model );
if ( null === $provider || ! $provider->is_configured() ) {
++$counts['skipped_error'];
continue;
}
// Budget pre-check.
$estimated = $this->get_estimated_cost( $model );
if ( ! $this->ledger->can_afford( $estimated ) ) {
++$counts['skipped_budget'];
continue;
}
try {
$result = $provider->probe( $query );
} catch ( \Exception $e ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( sprintf( 'PRV: probe failed [%s / %s]: %s', $model, $peptide['slug'], $e->getMessage() ) );
++$counts['skipped_error'];
continue;
}
$row_id = $this->persist_result( $run_id, $peptide, $model, $intent_tpl, $result );
if ( $row_id > 0 ) {
$this->ledger->update_row_cost( $row_id, $result->get_cost_usd() );
}
++$counts['probed'];
}
}
}
return $counts;
}
/**
* Persist a single probe result to the database.
*
* @param string $run_id UUID for this run.
* @param array<string,string> $peptide Peptide config with slug and label.
* @param string $model Model identifier.
* @param string $intent_tpl Raw intent template string.
* @param PRV_Probe_Result $result Probe result.
*
* @return int Inserted row ID, or 0 on failure.
*/
private function persist_result( string $run_id, array $peptide, string $model, string $intent_tpl, PRV_Probe_Result $result ): int {
global $wpdb;
$table = PRV_Table_Manager::get_table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->insert(
$table,
array(
'run_id' => $run_id,
'captured_at' => current_time( 'mysql', true ),
'peptide_slug' => $peptide['slug'],
'peptide_label' => $peptide['label'],
'model' => $model,
'prompt_intent' => $intent_tpl,
'cited' => $result->is_cited() ? 1 : 0,
'our_position' => $result->get_our_position(),
'source_domains' => wp_json_encode( $result->get_source_domains() ),
'raw_excerpt' => $result->get_raw_excerpt(),
'cost_usd' => number_format( $result->get_cost_usd(), 8, '.', '' ),
),
array( '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%s', '%s', '%s' )
);
return (int) $wpdb->insert_id;
}
/**
* Instantiate the correct provider class for a model string.
*
* @param string $model OpenRouter model identifier.
*
* @return PRV_Probe_Provider|null Null when the model is unrecognised.
*/
private function resolve_provider( string $model ): ?PRV_Probe_Provider {
if ( 'perplexity/sonar' === $model ) {
return new PRV_Perplexity_Provider();
}
// All other models route through the generic OpenRouter provider.
return new PRV_OpenRouter_Provider( $model );
}
/**
* Conservative per-call cost estimate used for the budget pre-check.
*
* @param string $model Model identifier.
*
* @return float Estimated USD.
*/
private function get_estimated_cost( string $model ): float {
if ( 'perplexity/sonar' === $model ) {
return PRV_Perplexity_Provider::ESTIMATED_COST_PER_PROBE;
}
return PRV_OpenRouter_Provider::ESTIMATED_COST_PER_PROBE;
}
/**
* Generate a UUID v4 run identifier.
*
* @return string
*/
private function generate_run_id(): string {
return sprintf(
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
wp_rand( 0, 0xffff ),
wp_rand( 0, 0xffff ),
wp_rand( 0, 0xffff ),
wp_rand( 0, 0x0fff ) | 0x4000,
wp_rand( 0, 0x3fff ) | 0x8000,
wp_rand( 0, 0xffff ),
wp_rand( 0, 0xffff ),
wp_rand( 0, 0xffff )
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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