peptide-geo-monitor v0.1.0: internal AI-visibility dashboard (#1)
New internal plugin: weekly LLM probes of core peptides (Perplexity sonar + GPT/Gemini-search via OpenRouter/CF gateway) -> custom table -> wp-admin trendline + standings. Provider interface + cost ledger w/ hard $5/mo cap + collector/panel seam (AI-visibility only). v1 = AI-visibility only. QA: APPROVE-WITH-P2-NOTES — qa-reviews/peptide-geo-monitor/2026-06-13-f488f4a966cbb9e771d3abc20daee3fa49074520.md (P2 follow-ups: ltrim www display bug, PHPCS mandatory, dead cache) Agent-Session: geomonitor v0.1.0 merge
This commit is contained in:
32
includes/core/class-pgm-activator.php
Normal file
32
includes/core/class-pgm-activator.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Plugin activation handler.
|
||||
*
|
||||
* Creates the custom database table via dbDelta and registers the weekly
|
||||
* 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.
|
||||
*
|
||||
* @see class-pgm-table-manager.php — Database schema creation.
|
||||
* @see class-pgm-cron.php — Cron scheduling.
|
||||
* @package PeptideGeoMonitor
|
||||
*/
|
||||
class PGM_Activator {
|
||||
|
||||
/**
|
||||
* Run all activation tasks.
|
||||
*
|
||||
* Side effects: Creates DB table, writes default options, schedules cron.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function activate(): void {
|
||||
PGM_Table_Manager::create_table();
|
||||
PGM_Config::seed_defaults();
|
||||
PGM_Cron::schedule_weekly();
|
||||
}
|
||||
}
|
||||
202
includes/core/class-pgm-admin-page.php
Normal file
202
includes/core/class-pgm-admin-page.php
Normal file
@@ -0,0 +1,202 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* GEO Monitor top-level admin page.
|
||||
*
|
||||
* Registers the "GEO Monitor" 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.
|
||||
*
|
||||
* Who triggers: PGM_Plugin::init() → is_admin() guard.
|
||||
* Dependencies: PGM_Collector_Registry, PGM_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 ARCHITECTURE.md — §Admin dashboard.
|
||||
* @package PeptideGeoMonitor
|
||||
*/
|
||||
class PGM_Admin_Page {
|
||||
|
||||
/**
|
||||
* Admin menu slug.
|
||||
*/
|
||||
const MENU_SLUG = 'pgm-geo-monitor';
|
||||
|
||||
/**
|
||||
* Nonce action for the Run now form.
|
||||
*/
|
||||
const NONCE_ACTION = 'pgm_run_now';
|
||||
|
||||
/**
|
||||
* Nonce field name.
|
||||
*/
|
||||
const NONCE_FIELD = 'pgm_nonce';
|
||||
|
||||
/**
|
||||
* Register WordPress admin hooks.
|
||||
*
|
||||
* Side effects: Adds admin_menu and admin_post actions.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
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_enqueue_scripts', array( $this, 'enqueue_assets' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the "GEO Monitor" top-level menu page.
|
||||
*
|
||||
* Side effects: Adds a WP admin menu entry.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function add_menu_page(): void {
|
||||
add_menu_page(
|
||||
__( 'GEO Monitor', 'peptide-geo-monitor' ),
|
||||
__( 'GEO Monitor', 'peptide-geo-monitor' ),
|
||||
'manage_options',
|
||||
self::MENU_SLUG,
|
||||
array( $this, 'render_page' ),
|
||||
'dashicons-chart-line',
|
||||
58
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue Chart.js on the GEO Monitor page only.
|
||||
*
|
||||
* @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(
|
||||
'pgm-chartjs',
|
||||
'https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js',
|
||||
array(),
|
||||
'4.4.3',
|
||||
true
|
||||
);
|
||||
|
||||
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;}'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the "Run now" POST action.
|
||||
*
|
||||
* Validates nonce and capability before dispatching to PGM_Probe_Runner.
|
||||
* Redirects back to the admin page with a status query arg.
|
||||
*
|
||||
* Side effects: Triggers a full probe run; redirects.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function handle_run_now(): void {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_die( esc_html__( 'Insufficient permissions.', 'peptide-geo-monitor' ), 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 );
|
||||
}
|
||||
|
||||
$runner = new PGM_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'] ),
|
||||
),
|
||||
admin_url( 'admin.php' )
|
||||
);
|
||||
|
||||
wp_safe_redirect( $redirect );
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the full GEO Monitor admin page.
|
||||
*
|
||||
* Side effects: Outputs HTML directly.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function render_page(): void {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_die( esc_html__( 'Insufficient permissions.', 'peptide-geo-monitor' ) );
|
||||
}
|
||||
|
||||
echo '<div class="wrap">';
|
||||
echo '<h1>' . esc_html__( 'GEO Monitor — AI Visibility', 'peptide-geo-monitor' ) . '</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">';
|
||||
wp_nonce_field( self::NONCE_ACTION, self::NONCE_FIELD );
|
||||
submit_button( __( 'Run now', 'peptide-geo-monitor' ), 'secondary', 'pgm_run_now_btn', false );
|
||||
echo '</form>';
|
||||
|
||||
$registry = PGM_Collector_Registry::instance();
|
||||
|
||||
foreach ( $registry->get_collectors() as $key => $collector ) {
|
||||
$panel = $registry->get_panel( $key );
|
||||
if ( null === $panel ) {
|
||||
continue;
|
||||
}
|
||||
$data = $collector->collect();
|
||||
$panel->render( $data );
|
||||
}
|
||||
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a success or partial notice after a Run now redirect.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function render_run_done_notice(): void {
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
||||
if ( empty( $_GET['pgm_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 );
|
||||
|
||||
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' ),
|
||||
$probed,
|
||||
$skipped
|
||||
)
|
||||
);
|
||||
echo '</p></div>';
|
||||
}
|
||||
}
|
||||
69
includes/core/class-pgm-autoloader.php
Normal file
69
includes/core/class-pgm-autoloader.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Class and interface autoloader for the Peptide GEO Monitor plugin.
|
||||
*
|
||||
* Converts PGM_* 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)
|
||||
*
|
||||
* Who triggers: Plugin boot (peptide-geo-monitor.php) — called once before
|
||||
* any PGM_* class or interface is instantiated.
|
||||
* Dependencies: None (pure PHP).
|
||||
*
|
||||
* @package PeptideGeoMonitor
|
||||
*/
|
||||
class PGM_Autoloader {
|
||||
|
||||
/**
|
||||
* Register the autoloader with the SPL stack.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function register(): void {
|
||||
spl_autoload_register( array( static::class, 'load' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to load a PGM_* class or interface file.
|
||||
*
|
||||
* Walks the known include subdirectories in order, trying the class-
|
||||
* filename first and the interface-filename second.
|
||||
*
|
||||
* Side effects: require_once on a matching file.
|
||||
*
|
||||
* @param string $class_name Fully-qualified class or interface name.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function load( string $class_name ): void {
|
||||
if ( 0 !== strpos( $class_name, 'PGM_' ) ) {
|
||||
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/',
|
||||
);
|
||||
|
||||
// Try class file first, then interface file.
|
||||
$candidates = array( 'class-' . $slug . '.php', 'interface-' . $slug . '.php' );
|
||||
|
||||
foreach ( $search_dirs as $dir ) {
|
||||
foreach ( $candidates as $file ) {
|
||||
$path = $dir . $file;
|
||||
if ( file_exists( $path ) ) {
|
||||
require_once $path;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
112
includes/core/class-pgm-collector-registry.php
Normal file
112
includes/core/class-pgm-collector-registry.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Registry: holds all registered collectors and panels.
|
||||
*
|
||||
* PGM_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.
|
||||
*
|
||||
* @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 ARCHITECTURE.md — §Collector/Panel seam.
|
||||
* @package PeptideGeoMonitor
|
||||
*/
|
||||
class PGM_Collector_Registry {
|
||||
|
||||
/**
|
||||
* Singleton instance.
|
||||
*
|
||||
* @var PGM_Collector_Registry|null
|
||||
*/
|
||||
private static ?PGM_Collector_Registry $instance = null;
|
||||
|
||||
/**
|
||||
* Registered collectors keyed by their collector key.
|
||||
*
|
||||
* @var array<string, PGM_Data_Collector>
|
||||
*/
|
||||
private array $collectors = array();
|
||||
|
||||
/**
|
||||
* Registered panels keyed by their panel key (matches collector key).
|
||||
*
|
||||
* @var array<string, PGM_Dashboard_Panel>
|
||||
*/
|
||||
private array $panels = array();
|
||||
|
||||
/**
|
||||
* Private constructor — use instance().
|
||||
*/
|
||||
private function __construct() {}
|
||||
|
||||
/**
|
||||
* Get (or create) the singleton instance.
|
||||
*
|
||||
* @return static
|
||||
*/
|
||||
public static function instance(): static {
|
||||
if ( null === static::$instance ) {
|
||||
static::$instance = new static();
|
||||
}
|
||||
return static::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a data collector.
|
||||
*
|
||||
* @param PGM_Data_Collector $collector
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register_collector( PGM_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
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register_panel( string $key, PGM_Dashboard_Panel $panel ): void {
|
||||
$this->panels[ $key ] = $panel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered collectors.
|
||||
*
|
||||
* @return array<string, PGM_Data_Collector>
|
||||
*/
|
||||
public function get_collectors(): array {
|
||||
return $this->collectors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the panel for a given key (null when not registered).
|
||||
*
|
||||
* @param string $key Panel key.
|
||||
*
|
||||
* @return PGM_Dashboard_Panel|null
|
||||
*/
|
||||
public function get_panel( string $key ): ?PGM_Dashboard_Panel {
|
||||
return $this->panels[ $key ] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the registry — test use only.
|
||||
*
|
||||
* @internal
|
||||
* @return void
|
||||
*/
|
||||
public static function reset_for_testing(): void {
|
||||
static::$instance = null;
|
||||
}
|
||||
}
|
||||
129
includes/core/class-pgm-config.php
Normal file
129
includes/core/class-pgm-config.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
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
|
||||
* 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
|
||||
* 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
|
||||
*/
|
||||
class PGM_Config {
|
||||
|
||||
/**
|
||||
* Seed the default option values on first activation.
|
||||
*
|
||||
* Uses add_option() so existing values are never overwritten on re-activation.
|
||||
*
|
||||
* Side effects: Writes multiple wp_options rows on first run.
|
||||
*
|
||||
* @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() );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the monthly budget cap in USD.
|
||||
*
|
||||
* @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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of peptide slugs/labels to probe.
|
||||
*
|
||||
* Each element is an associative array with 'slug' and 'label' keys.
|
||||
*
|
||||
* @return array<int, array{slug: string, label: string}>
|
||||
*/
|
||||
public static function get_peptides(): array {
|
||||
$raw = get_option( 'pgm_peptides', self::default_peptides() );
|
||||
return is_array( $raw ) ? $raw : self::default_peptides();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the prompt intent templates.
|
||||
*
|
||||
* Each string may contain {peptide} as a placeholder.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function get_prompt_intents(): array {
|
||||
$raw = get_option( 'pgm_prompt_intents', self::default_prompt_intents() );
|
||||
return is_array( $raw ) ? $raw : self::default_prompt_intents();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the model identifiers to probe with.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function get_models(): array {
|
||||
$raw = get_option( 'pgm_models', self::default_models() );
|
||||
return is_array( $raw ) ? $raw : self::default_models();
|
||||
}
|
||||
|
||||
/**
|
||||
* Default set of ~12 high-interest peptides (slug + human-readable label).
|
||||
*
|
||||
* @return array<int, array{slug: string, label: string}>
|
||||
*/
|
||||
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' ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Default prompt intent templates — three query styles per peptide.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private static function default_prompt_intents(): array {
|
||||
return array(
|
||||
'what is {peptide}',
|
||||
'{peptide} benefits and dosage',
|
||||
'{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',
|
||||
);
|
||||
}
|
||||
}
|
||||
109
includes/core/class-pgm-cost-ledger.php
Normal file
109
includes/core/class-pgm-cost-ledger.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Cost ledger: per-call logging + hard monthly budget enforcement.
|
||||
*
|
||||
* Every paid API call goes through record_call(). Before each call the
|
||||
* runner should check can_afford() to abort gracefully at the cap without
|
||||
* 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)
|
||||
* 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.
|
||||
*
|
||||
* @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 CONTEXT.md — "monthly cap" definition.
|
||||
* @package PeptideGeoMonitor
|
||||
*/
|
||||
class PGM_Cost_Ledger {
|
||||
|
||||
/**
|
||||
* Retrieve the total probe cost incurred in the current calendar month (UTC).
|
||||
*
|
||||
* Side effects: Database read.
|
||||
*
|
||||
* @return float Sum of cost_usd for rows in the current month, in USD.
|
||||
*/
|
||||
public function get_month_to_date_usd(): float {
|
||||
global $wpdb;
|
||||
|
||||
$table = PGM_Table_Manager::get_table_name();
|
||||
$month = gmdate( 'Y-m' );
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$result = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
"SELECT COALESCE(SUM(cost_usd), 0) FROM {$table} WHERE DATE_FORMAT(captured_at, %s) = %s",
|
||||
'%Y-%m',
|
||||
$month
|
||||
)
|
||||
);
|
||||
|
||||
return (float) $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether there is remaining budget for another API call.
|
||||
*
|
||||
* Callers should invoke this BEFORE dispatching a probe to avoid
|
||||
* overspend. Returns false when MTD spend is at or above the cap.
|
||||
*
|
||||
* @param float $estimated_cost_usd Estimated cost of the upcoming call.
|
||||
*
|
||||
* @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();
|
||||
return ( $spent + $estimated_cost_usd ) <= $cap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check remaining budget headroom in USD.
|
||||
*
|
||||
* @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();
|
||||
$spent = $this->get_month_to_date_usd();
|
||||
return max( 0.0, $cap - $spent );
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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 float $cost_usd Actual cost in USD.
|
||||
*
|
||||
* @return bool True on success.
|
||||
*/
|
||||
public function update_row_cost( int $row_id, float $cost_usd ): bool {
|
||||
global $wpdb;
|
||||
|
||||
$table = PGM_Table_Manager::get_table_name();
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$result = $wpdb->update(
|
||||
$table,
|
||||
array( 'cost_usd' => number_format( $cost_usd, 8, '.', '' ) ),
|
||||
array( 'id' => $row_id ),
|
||||
array( '%s' ),
|
||||
array( '%d' )
|
||||
);
|
||||
|
||||
return false !== $result;
|
||||
}
|
||||
}
|
||||
82
includes/core/class-pgm-cron.php
Normal file
82
includes/core/class-pgm-cron.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
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.
|
||||
*
|
||||
* Who triggers: PGM_Plugin::init() (hook registration), PGM_Activator
|
||||
* (schedule), PGM_Deactivator (clear).
|
||||
* Dependencies: PGM_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
|
||||
*/
|
||||
class PGM_Cron {
|
||||
|
||||
/**
|
||||
* Register WordPress action hooks.
|
||||
*
|
||||
* Side effects: Adds action for PGM_CRON_HOOK.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register_hooks(): void {
|
||||
add_action( PGM_CRON_HOOK, array( $this, 'handle_cron_tick' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback fired by WP-Cron on the weekly schedule.
|
||||
*
|
||||
* Side effects: Instantiates and runs PGM_Probe_Runner.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function handle_cron_tick(): void {
|
||||
$runner = new PGM_Probe_Runner();
|
||||
$runner->run();
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule the weekly cron event if not already scheduled.
|
||||
*
|
||||
* Safe to call on re-activation — no-op when already scheduled.
|
||||
*
|
||||
* Side effects: Adds a WP-Cron event.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function schedule_weekly(): void {
|
||||
if ( ! wp_next_scheduled( PGM_CRON_HOOK ) ) {
|
||||
wp_schedule_event( time(), 'weekly', PGM_CRON_HOOK );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the weekly cron schedule.
|
||||
*
|
||||
* Called on deactivation. Does not purge data.
|
||||
*
|
||||
* Side effects: Removes the WP-Cron event.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function clear_schedule(): void {
|
||||
$timestamp = wp_next_scheduled( PGM_CRON_HOOK );
|
||||
if ( false !== $timestamp ) {
|
||||
wp_unschedule_event( $timestamp, PGM_CRON_HOOK );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the weekly event is currently scheduled.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_scheduled(): bool {
|
||||
return false !== wp_next_scheduled( PGM_CRON_HOOK );
|
||||
}
|
||||
}
|
||||
29
includes/core/class-pgm-deactivator.php
Normal file
29
includes/core/class-pgm-deactivator.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Plugin deactivation handler.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* @see class-pgm-cron.php — Cron registration + clearing.
|
||||
* @see uninstall.php — Full data purge on uninstall.
|
||||
* @package PeptideGeoMonitor
|
||||
*/
|
||||
class PGM_Deactivator {
|
||||
|
||||
/**
|
||||
* Clear the weekly cron schedule.
|
||||
*
|
||||
* Side effects: Removes the pgm_weekly_probe event from WP-Cron.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function deactivate(): void {
|
||||
PGM_Cron::clear_schedule();
|
||||
}
|
||||
}
|
||||
41
includes/core/class-pgm-plugin.php
Normal file
41
includes/core/class-pgm-plugin.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Main orchestrator for the Peptide GEO Monitor 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.
|
||||
*
|
||||
* @see ARCHITECTURE.md — Boot sequence diagram.
|
||||
* @package PeptideGeoMonitor
|
||||
*/
|
||||
class PGM_Plugin {
|
||||
|
||||
/**
|
||||
* Wire up all WordPress hooks.
|
||||
*
|
||||
* Side effects: Registers admin menus, enqueues assets, and registers the
|
||||
* collector/panel registry.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function init(): void {
|
||||
$cron = new PGM_Cron();
|
||||
$cron->register_hooks();
|
||||
|
||||
if ( is_admin() ) {
|
||||
$page = new PGM_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() );
|
||||
}
|
||||
}
|
||||
69
includes/core/class-pgm-probe-result.php
Normal file
69
includes/core/class-pgm-probe-result.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Value object: the result of a single LLM probe call.
|
||||
*
|
||||
* Returned by every PGM_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.
|
||||
* 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 CONTEXT.md — "probe result", "cited", "our_position".
|
||||
* @package PeptideGeoMonitor
|
||||
*/
|
||||
class PGM_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.
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly string $raw_excerpt,
|
||||
private readonly array $source_domains,
|
||||
private readonly bool $cited,
|
||||
private readonly ?int $our_position,
|
||||
private readonly float $cost_usd
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function get_raw_excerpt(): string {
|
||||
return $this->raw_excerpt;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function get_source_domains(): array {
|
||||
return $this->source_domains;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool
|
||||
*/
|
||||
public function is_cited(): bool {
|
||||
return $this->cited;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int|null 1-based position or null when not cited.
|
||||
*/
|
||||
public function get_our_position(): ?int {
|
||||
return $this->our_position;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return float Estimated cost in USD.
|
||||
*/
|
||||
public function get_cost_usd(): float {
|
||||
return $this->cost_usd;
|
||||
}
|
||||
}
|
||||
202
includes/core/class-pgm-probe-runner.php
Normal file
202
includes/core/class-pgm-probe-runner.php
Normal file
@@ -0,0 +1,202 @@
|
||||
<?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 )
|
||||
);
|
||||
}
|
||||
}
|
||||
94
includes/core/class-pgm-table-manager.php
Normal file
94
includes/core/class-pgm-table-manager.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Database table creation and schema versioning for PGM.
|
||||
*
|
||||
* Creates {prefix}pgm_ai_visibility via dbDelta (idempotent). Tracks the
|
||||
* schema version in the pgm_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.
|
||||
* Dependencies: $wpdb global, dbDelta() (requires upgrade.php).
|
||||
*
|
||||
* @see class-pgm-activator.php — Calls create_table() on activation.
|
||||
* @see ARCHITECTURE.md — §Storage.
|
||||
* @package PeptideGeoMonitor
|
||||
*/
|
||||
class PGM_Table_Manager {
|
||||
|
||||
/**
|
||||
* Name of the custom table (without $wpdb->prefix).
|
||||
*/
|
||||
const TABLE_BASE = 'pgm_ai_visibility';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Side effects: Writes to the database; sets pgm_schema_version option.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function create_table(): void {
|
||||
global $wpdb;
|
||||
|
||||
$table_name = $wpdb->prefix . self::TABLE_BASE;
|
||||
$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,
|
||||
PRIMARY KEY (id),
|
||||
KEY run_id (run_id),
|
||||
KEY peptide_slug (peptide_slug),
|
||||
KEY captured_at (captured_at)
|
||||
) {$charset_collate};";
|
||||
|
||||
if ( ! function_exists( 'dbDelta' ) ) {
|
||||
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||
}
|
||||
|
||||
dbDelta( $sql );
|
||||
|
||||
update_option( 'pgm_schema_version', PGM_SCHEMA_VERSION );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full table name including the WP prefix.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function get_table_name(): string {
|
||||
global $wpdb;
|
||||
return $wpdb->prefix . self::TABLE_BASE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the AI-visibility table entirely.
|
||||
*
|
||||
* Called only from uninstall.php — never during normal deactivation.
|
||||
*
|
||||
* Side effects: Permanently destroys all probe data.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
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
|
||||
$wpdb->query( "DROP TABLE IF EXISTS {$table}" );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user