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:
80
includes/providers/class-pgm-citation-detector.php
Normal file
80
includes/providers/class-pgm-citation-detector.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Detects whether peptiderepo.com appears in a list of source domains.
|
||||
*
|
||||
* Centralises the citation-detection logic so both providers can share it
|
||||
* without duplication. Also parses raw citation arrays/URLs into a clean
|
||||
* domain list.
|
||||
*
|
||||
* Who triggers: PGM_Perplexity_Provider, PGM_OpenRouter_Provider.
|
||||
* Dependencies: None (pure PHP).
|
||||
*
|
||||
* @see CONTEXT.md — "cited", "our_position", "source_domains".
|
||||
* @package PeptideGeoMonitor
|
||||
*/
|
||||
class PGM_Citation_Detector {
|
||||
|
||||
/**
|
||||
* Parse raw citation data from an API response into a clean domain list.
|
||||
*
|
||||
* Accepts either a flat array of URL strings or an array of objects with
|
||||
* a 'url' key (Perplexity style). Unknown shapes are skipped gracefully.
|
||||
*
|
||||
* @param array<mixed> $raw_citations Raw citations from the API response.
|
||||
*
|
||||
* @return string[] Lowercase domain names (e.g. ['peptiderepo.com', 'examine.com']).
|
||||
*/
|
||||
public function parse_domains( array $raw_citations ): array {
|
||||
$domains = array();
|
||||
|
||||
foreach ( $raw_citations as $item ) {
|
||||
$url = '';
|
||||
if ( is_string( $item ) ) {
|
||||
$url = $item;
|
||||
} elseif ( is_array( $item ) && isset( $item['url'] ) && is_string( $item['url'] ) ) {
|
||||
$url = $item['url'];
|
||||
}
|
||||
|
||||
if ( '' === $url ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$host = (string) wp_parse_url( $url, PHP_URL_HOST );
|
||||
if ( '' !== $host ) {
|
||||
$domains[] = strtolower( ltrim( $host, 'www.' ) );
|
||||
}
|
||||
}
|
||||
|
||||
return array_values( array_unique( $domains ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether PGM_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 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the 1-based position of PGM_TARGET_DOMAIN in a domain list.
|
||||
*
|
||||
* Returns null when the target domain is not found.
|
||||
*
|
||||
* @param string[] $domains Parsed domain list.
|
||||
*
|
||||
* @return int|null
|
||||
*/
|
||||
public function get_our_position( array $domains ): ?int {
|
||||
$pos = array_search( PGM_TARGET_DOMAIN, $domains, true );
|
||||
if ( false === $pos ) {
|
||||
return null;
|
||||
}
|
||||
return (int) $pos + 1; // Convert 0-based index to 1-based position.
|
||||
}
|
||||
}
|
||||
172
includes/providers/class-pgm-gateway-client.php
Normal file
172
includes/providers/class-pgm-gateway-client.php
Normal file
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Cloudflare AI Gateway HTTP client — shared by all providers.
|
||||
*
|
||||
* Builds the gateway URL, injects the Authorization header, applies
|
||||
* exponential-backoff retry, and delegates cURL auth injection (for
|
||||
* hosts that strip the Authorization header) — mirroring the approach
|
||||
* used in PRAutoBlogger's OpenRouter provider.
|
||||
*
|
||||
* Gateway URL shape:
|
||||
* 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-…)
|
||||
*
|
||||
* Who triggers: PGM_Perplexity_Provider, PGM_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 ARCHITECTURE.md — §External API integrations.
|
||||
* @package PeptideGeoMonitor
|
||||
*/
|
||||
class PGM_Gateway_Client {
|
||||
|
||||
/**
|
||||
* Base URL for the Cloudflare AI Gateway (without trailing slash).
|
||||
*/
|
||||
private const GATEWAY_BASE = 'https://gateway.ai.cloudflare.com/v1';
|
||||
|
||||
/**
|
||||
* Fallback to direct OpenRouter when the gateway is not configured.
|
||||
*/
|
||||
private const OPENROUTER_BASE = 'https://openrouter.ai/api/v1';
|
||||
|
||||
/**
|
||||
* Send a chat-completion POST request through the gateway (or direct).
|
||||
*
|
||||
* Retries with exponential backoff on transient failures (5xx, timeout).
|
||||
* Throws on permanent failure after exhausting retries.
|
||||
*
|
||||
* Side effects: HTTP request; logs errors to PHP error log.
|
||||
*
|
||||
* @param string $provider_slug Gateway provider path segment (e.g. "openrouter", "perplexity").
|
||||
* @param array<string, mixed> $body JSON request body.
|
||||
* @param string $api_key Bearer token.
|
||||
* @param string $endpoint Path appended after the provider base (default "/chat/completions").
|
||||
*
|
||||
* @return array<string, mixed> Decoded JSON response body.
|
||||
*
|
||||
* @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_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',
|
||||
);
|
||||
|
||||
// Belt-and-suspenders cURL injection — some hosts strip Authorization.
|
||||
$curl_filter = $this->register_curl_auth_filter( $headers, $base_host );
|
||||
|
||||
$last_error = '';
|
||||
|
||||
try {
|
||||
for ( $attempt = 1; $attempt <= PGM_MAX_RETRIES; $attempt++ ) {
|
||||
$response = wp_remote_post(
|
||||
$url,
|
||||
array(
|
||||
'timeout' => PGM_API_TIMEOUT_SECONDS,
|
||||
'headers' => $headers,
|
||||
'body' => wp_json_encode( $body ),
|
||||
)
|
||||
);
|
||||
|
||||
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 ) );
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
$status = wp_remote_retrieve_response_code( $response );
|
||||
$raw_body = wp_remote_retrieve_body( $response );
|
||||
|
||||
if ( $status >= 500 || 429 === $status ) {
|
||||
$last_error = sprintf( 'HTTP %d', $status );
|
||||
if ( $attempt < PGM_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 );
|
||||
sleep( $delay );
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( $status >= 400 ) {
|
||||
throw new \RuntimeException(
|
||||
sprintf( 'GEO Monitor 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.' );
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
} finally {
|
||||
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 )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the API base URL (gateway or direct fallback).
|
||||
*
|
||||
* @param string $provider_slug Path segment after gateway_id (e.g. "openrouter").
|
||||
*
|
||||
* @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 ) {
|
||||
return sprintf(
|
||||
'%s/%s/%s/%s',
|
||||
self::GATEWAY_BASE,
|
||||
PGM_CF_ACCOUNT_ID,
|
||||
PGM_CF_GATEWAY_ID,
|
||||
$provider_slug
|
||||
);
|
||||
}
|
||||
return self::OPENROUTER_BASE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register cURL auth injection filter (mirrors PRAutoBlogger pattern).
|
||||
*
|
||||
* @param array<string, string> $headers Request headers including Authorization.
|
||||
* @param string $base_host Upstream host — limits leakage scope.
|
||||
*
|
||||
* @return callable The registered filter (caller MUST remove_action after request).
|
||||
*/
|
||||
private function register_curl_auth_filter( array $headers, string $base_host ): callable {
|
||||
$filter = function ( $handle, $parsed_args, $url ) use ( $headers, $base_host ): void {
|
||||
if ( '' === $base_host || false === strpos( (string) $url, $base_host ) ) {
|
||||
return;
|
||||
}
|
||||
$curl_headers = array();
|
||||
foreach ( $headers as $name => $value ) {
|
||||
$curl_headers[] = $name . ': ' . $value;
|
||||
}
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt
|
||||
curl_setopt( $handle, CURLOPT_HTTPHEADER, $curl_headers );
|
||||
};
|
||||
add_action( 'http_api_curl', $filter, 99, 3 );
|
||||
return $filter;
|
||||
}
|
||||
}
|
||||
160
includes/providers/class-pgm-openrouter-provider.php
Normal file
160
includes/providers/class-pgm-openrouter-provider.php
Normal file
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Generic OpenRouter provider — parameterised for GPT-search and Gemini-search.
|
||||
*
|
||||
* 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().
|
||||
*
|
||||
* GPT-search (openai/gpt-4o-search-preview) and Gemini-search
|
||||
* (google/gemini-2.0-flash-001) do not return a structured citations array
|
||||
* via OpenRouter — citations are parsed from URLs embedded in the response
|
||||
* text or from any 'annotations' key the model may include.
|
||||
*
|
||||
* Required WP-config constant:
|
||||
* PGM_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.
|
||||
*
|
||||
* @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 ARCHITECTURE.md — §Provider implementations.
|
||||
* @package PeptideGeoMonitor
|
||||
*/
|
||||
class PGM_OpenRouter_Provider implements PGM_Probe_Provider {
|
||||
|
||||
/**
|
||||
* Estimated cost per probe call in USD (conservative cross-model default).
|
||||
*/
|
||||
const ESTIMATED_COST_PER_PROBE = 0.003;
|
||||
|
||||
/**
|
||||
* @var string OpenRouter model identifier (e.g. "openai/gpt-4o-search-preview").
|
||||
*/
|
||||
private string $model;
|
||||
|
||||
/**
|
||||
* @var PGM_Gateway_Client
|
||||
*/
|
||||
private PGM_Gateway_Client $gateway;
|
||||
|
||||
/**
|
||||
* @var PGM_Citation_Detector
|
||||
*/
|
||||
private PGM_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.
|
||||
*/
|
||||
public function __construct( string $model, ?PGM_Gateway_Client $gateway = null, ?PGM_Citation_Detector $detector = null ) {
|
||||
$this->model = $model;
|
||||
$this->gateway = $gateway ?? new PGM_Gateway_Client();
|
||||
$this->detector = $detector ?? new PGM_Citation_Detector();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* @param string $query
|
||||
* @return PGM_Probe_Result
|
||||
* @throws \RuntimeException On permanent API failure.
|
||||
*/
|
||||
public function probe( string $query ): PGM_Probe_Result {
|
||||
$api_key = $this->resolve_api_key();
|
||||
|
||||
$body = array(
|
||||
'model' => $this->model,
|
||||
'messages' => array(
|
||||
array( 'role' => 'user', 'content' => $query ),
|
||||
),
|
||||
'max_tokens' => 512,
|
||||
);
|
||||
|
||||
$data = $this->gateway->post_to_gateway( 'openrouter', $body, $api_key );
|
||||
|
||||
return $this->parse_response( $data );
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the OpenRouter response into a PGM_Probe_Result.
|
||||
*
|
||||
* Attempts to extract citations from:
|
||||
* 1. `annotations` array (some models include structured source references).
|
||||
* 2. URLs found inline in the response text (regex fallback).
|
||||
*
|
||||
* @param array<string, mixed> $data Decoded API response.
|
||||
*
|
||||
* @return PGM_Probe_Result
|
||||
*/
|
||||
public function parse_response( array $data ): PGM_Probe_Result {
|
||||
$content = '';
|
||||
if ( isset( $data['choices'][0]['message']['content'] ) ) {
|
||||
$content = (string) $data['choices'][0]['message']['content'];
|
||||
}
|
||||
$raw_excerpt = mb_substr( $content, 0, 500 );
|
||||
|
||||
// 1. Prefer structured annotations when present.
|
||||
$raw_citations = array();
|
||||
if ( isset( $data['choices'][0]['message']['annotations'] ) && is_array( $data['choices'][0]['message']['annotations'] ) ) {
|
||||
foreach ( $data['choices'][0]['message']['annotations'] as $ann ) {
|
||||
if ( isset( $ann['url_citation']['url'] ) ) {
|
||||
$raw_citations[] = $ann['url_citation']['url'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Regex fallback: extract URLs from response text.
|
||||
if ( empty( $raw_citations ) && '' !== $content ) {
|
||||
preg_match_all( '#https?://[^\s\)\]\"\'<>]+#i', $content, $matches );
|
||||
$raw_citations = $matches[0] ?? array();
|
||||
}
|
||||
|
||||
$domains = $this->detector->parse_domains( $raw_citations );
|
||||
$cited = $this->detector->is_cited( $domains );
|
||||
$our_position = $this->detector->get_our_position( $domains );
|
||||
|
||||
// Cost estimation from usage data.
|
||||
$cost_usd = self::ESTIMATED_COST_PER_PROBE;
|
||||
if ( isset( $data['usage']['total_tokens'] ) ) {
|
||||
// Rough blended rate ~$2/1M tokens across GPT-4o and Gemini Flash.
|
||||
$cost_usd = (float) $data['usage']['total_tokens'] * 0.000002;
|
||||
}
|
||||
|
||||
return new PGM_Probe_Result( $raw_excerpt, $domains, $cited, $our_position, $cost_usd );
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function get_name(): string {
|
||||
return 'OpenRouter/' . $this->model;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function is_configured(): bool {
|
||||
return defined( 'PGM_OPENROUTER_API_KEY' ) && '' !== PGM_OPENROUTER_API_KEY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the OpenRouter API key from wp-config constants.
|
||||
*
|
||||
* @return string
|
||||
* @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' )
|
||||
);
|
||||
}
|
||||
return PGM_OPENROUTER_API_KEY;
|
||||
}
|
||||
}
|
||||
149
includes/providers/class-pgm-perplexity-provider.php
Normal file
149
includes/providers/class-pgm-perplexity-provider.php
Normal file
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Perplexity sonar provider — primary citation signal.
|
||||
*
|
||||
* Routes through the Cloudflare AI Gateway (openrouter path) because
|
||||
* OpenRouter proxies Perplexity models. Perplexity's sonar returns
|
||||
* an explicit `citations` array — the most faithful citation signal
|
||||
* available (real web retrieval vs. training-time knowledge).
|
||||
*
|
||||
* Required WP-config constant:
|
||||
* PGM_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.
|
||||
*
|
||||
* @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 ARCHITECTURE.md — §Provider implementations.
|
||||
* @see CONTEXT.md — "Perplexity sonar", "citations".
|
||||
* @package PeptideGeoMonitor
|
||||
*/
|
||||
class PGM_Perplexity_Provider implements PGM_Probe_Provider {
|
||||
|
||||
/**
|
||||
* Model identifier passed to OpenRouter for Perplexity routing.
|
||||
*/
|
||||
const MODEL = 'perplexity/sonar';
|
||||
|
||||
/**
|
||||
* Estimated cost per probe call in USD (sonar is ~$0.005 / 1k tokens).
|
||||
* Used by the ledger's can_afford() pre-check. Actual cost settled post-call.
|
||||
*/
|
||||
const ESTIMATED_COST_PER_PROBE = 0.005;
|
||||
|
||||
/**
|
||||
* @var PGM_Gateway_Client
|
||||
*/
|
||||
private PGM_Gateway_Client $gateway;
|
||||
|
||||
/**
|
||||
* @var PGM_Citation_Detector
|
||||
*/
|
||||
private PGM_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.
|
||||
*/
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* Sends the query as a user message to perplexity/sonar via OpenRouter.
|
||||
* Parses the citations[] array from the OpenRouter response envelope.
|
||||
*
|
||||
* @param string $query
|
||||
*
|
||||
* @return PGM_Probe_Result
|
||||
* @throws \RuntimeException On permanent API failure.
|
||||
*/
|
||||
public function probe( string $query ): PGM_Probe_Result {
|
||||
$api_key = $this->resolve_api_key();
|
||||
|
||||
$body = array(
|
||||
'model' => self::MODEL,
|
||||
'messages' => array(
|
||||
array( 'role' => 'user', 'content' => $query ),
|
||||
),
|
||||
'max_tokens' => 512,
|
||||
);
|
||||
|
||||
$data = $this->gateway->post_to_gateway( 'openrouter', $body, $api_key );
|
||||
|
||||
return $this->parse_response( $data );
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the OpenRouter/Perplexity response into a PGM_Probe_Result.
|
||||
*
|
||||
* Side effects: None.
|
||||
*
|
||||
* @param array<string, mixed> $data Decoded API response.
|
||||
*
|
||||
* @return PGM_Probe_Result
|
||||
*/
|
||||
public function parse_response( array $data ): PGM_Probe_Result {
|
||||
// Extract text excerpt from the first choice.
|
||||
$content = '';
|
||||
if ( isset( $data['choices'][0]['message']['content'] ) ) {
|
||||
$content = (string) $data['choices'][0]['message']['content'];
|
||||
}
|
||||
$raw_excerpt = mb_substr( $content, 0, 500 );
|
||||
|
||||
// Perplexity returns citations at the top level of the response envelope.
|
||||
$raw_citations = array();
|
||||
if ( isset( $data['citations'] ) && is_array( $data['citations'] ) ) {
|
||||
$raw_citations = $data['citations'];
|
||||
}
|
||||
|
||||
$domains = $this->detector->parse_domains( $raw_citations );
|
||||
$cited = $this->detector->is_cited( $domains );
|
||||
$our_position = $this->detector->get_our_position( $domains );
|
||||
|
||||
// Cost from usage data when available; fall back to estimate.
|
||||
$cost_usd = self::ESTIMATED_COST_PER_PROBE;
|
||||
if ( isset( $data['usage']['total_tokens'] ) ) {
|
||||
// Sonar: ~$1 per 1M input, $1 per 1M output tokens. Avg ~$0.001/1k.
|
||||
$cost_usd = (float) $data['usage']['total_tokens'] * 0.000001;
|
||||
}
|
||||
|
||||
return new PGM_Probe_Result( $raw_excerpt, $domains, $cited, $our_position, $cost_usd );
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function get_name(): string {
|
||||
return 'Perplexity sonar';
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function is_configured(): bool {
|
||||
return defined( 'PGM_OPENROUTER_API_KEY' ) && '' !== PGM_OPENROUTER_API_KEY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the OpenRouter API key from wp-config constants.
|
||||
*
|
||||
* @return string
|
||||
* @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' )
|
||||
);
|
||||
}
|
||||
return PGM_OPENROUTER_API_KEY;
|
||||
}
|
||||
}
|
||||
56
includes/providers/interface-pgm-probe-provider.php
Normal file
56
includes/providers/interface-pgm-probe-provider.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Contract for all LLM probe providers.
|
||||
*
|
||||
* Each provider implementation wraps one LLM backend (Perplexity sonar,
|
||||
* 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.
|
||||
* 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 CONTEXT.md — "provider", "probe".
|
||||
* @package PeptideGeoMonitor
|
||||
*/
|
||||
interface PGM_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.
|
||||
* - 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
|
||||
*
|
||||
* @throws \RuntimeException When the API call fails after retries.
|
||||
*/
|
||||
public function probe( string $query ): PGM_Probe_Result;
|
||||
|
||||
/**
|
||||
* Get a short human-readable name for this provider (e.g. "Perplexity sonar").
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function get_name(): string;
|
||||
|
||||
/**
|
||||
* Check whether the required credentials/constants are configured.
|
||||
*
|
||||
* Returns false (never throws) so the runner can skip unconfigured
|
||||
* providers without aborting the whole run.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function is_configured(): bool;
|
||||
}
|
||||
Reference in New Issue
Block a user