Files
pr-vision/includes/collector/class-prv-ai-visibility-collector.php
peptiderepo dfaee0bca6 PHPCS: use disable/enable blocks for multi-line SQL interpolation
phpcs:ignore only suppresses the immediately following line; the SQL
queries span multiple lines so the InterpolatedNotPrepared violation
fires on the string continuation lines. Replaced with phpcs:disable/
enable pairs that wrap the full get_results/get_var call. Applies to
all three DB queries in class-prv-ai-visibility-collector.php.

Agent-Session: pr-vision rename v0.1.1
2026-06-13 09:50:41 +00:00

186 lines
6.8 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* AI-visibility data collector for the PR Vision dashboard.
*
* @package PrVision
*/
declare(strict_types=1);
/**
* AI-visibility data collector — v1 implementation of PRV_Data_Collector.
*
* 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.
*
* Score formula (documented in CONTEXT.md):
* base_score = cited_probes / total_probes (range 0–1)
* position_bonus = Σ (1 / our_position) for cited probes / total_probes
* visibility_score = round((base_score + position_bonus) / 2, 4)
*
* Who triggers: PRV_Admin_Page calls the registered collector via the registry.
* Dependencies: $wpdb, PRV_Table_Manager, PRV_Cost_Ledger, PRV_Config.
*
* @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 PrVision
*/
class PRV_Ai_Visibility_Collector implements PRV_Data_Collector {
/**
* Budget ledger for MTD cost lookup.
*
* @var PRV_Cost_Ledger
*/
private PRV_Cost_Ledger $ledger;
/**
* Constructor.
*
* @param PRV_Cost_Ledger|null $ledger Injected for testing.
*/
public function __construct( ?PRV_Cost_Ledger $ledger = null ) {
$this->ledger = $ledger ?? new PRV_Cost_Ledger();
}
/**
* {@inheritDoc}
*
* @return array{
* trendline: array<int, array{run_id: string, captured_at: string, score: float}>,
* standings: array<string, array{label: string, cited: bool, our_position: int|null, top_domains: string[], model_count: int}>,
* last_run_at: string|null,
* mtd_cost_usd: float,
* monthly_cap_usd: float,
* }
*/
public function collect(): array {
global $wpdb;
$table = PRV_Table_Manager::get_table_name();
// ── Trendline: one score per run_id ────────────────────────────
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$run_rows = $wpdb->get_results(
"SELECT run_id, MIN(captured_at) AS captured_at,
SUM(cited) AS cited_count,
COUNT(*) AS total_count,
SUM(CASE WHEN our_position IS NOT NULL THEN 1.0/our_position ELSE 0 END) AS position_sum
FROM {$table}
GROUP BY run_id
ORDER BY MIN(captured_at) ASC
LIMIT 52",
ARRAY_A
);
$trendline = array();
foreach ( (array) $run_rows as $row ) {
$trendline[] = array(
'run_id' => (string) $row['run_id'],
'captured_at' => (string) $row['captured_at'],
'score' => $this->compute_score(
(int) $row['cited_count'],
(int) $row['total_count'],
(float) $row['position_sum']
),
);
}
// ── Standings: latest result per peptide ────────────────────────
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$standing_rows = $wpdb->get_results(
"SELECT peptide_slug, peptide_label,
MAX(cited) AS cited,
MIN(CASE WHEN cited=1 THEN our_position ELSE NULL END) AS our_position,
GROUP_CONCAT(DISTINCT source_domains ORDER BY captured_at DESC SEPARATOR '|||') AS domains_json_list,
COUNT(DISTINCT model) AS model_count
FROM {$table}
WHERE run_id = (SELECT run_id FROM {$table} ORDER BY captured_at DESC LIMIT 1)
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'] ?? '' ) );
$standings[ (string) $row['peptide_slug'] ] = array(
'label' => (string) $row['peptide_label'],
'cited' => (bool) (int) $row['cited'],
'our_position' => null !== $row['our_position'] ? (int) $row['our_position'] : null,
'top_domains' => $top_domains,
'model_count' => (int) $row['model_count'],
);
}
// ── Run metadata ────────────────────────────────────────────────
// 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' => PRV_Config::get_monthly_budget_usd(),
);
}
/**
* {@inheritDoc}
*/
public function get_key(): string {
return 'ai_visibility';
}
/**
* Compute the visibility score from a run's aggregate stats.
*
* See CONTEXT.md for the full formula explanation.
*
* @param int $cited_count Number of probes where we were cited.
* @param int $total_count Total probes in the run.
* @param float $position_sum Sum of (1/our_position) for cited probes.
*
* @return float Visibility score in range [0, 1].
*/
public function compute_score( int $cited_count, int $total_count, float $position_sum ): float {
if ( 0 === $total_count ) {
return 0.0;
}
$base_score = $cited_count / $total_count;
$position_bonus = $position_sum / $total_count;
return round( ( $base_score + $position_bonus ) / 2.0, 4 );
}
/**
* Extract and deduplicate the top domain names from a GROUP_CONCAT result.
*
* @param string $domains_json_list Pipe-separated JSON arrays from GROUP_CONCAT.
*
* @return string[] Up to 5 top domains.
*/
private function extract_top_domains( string $domains_json_list ): array {
if ( '' === $domains_json_list ) {
return array();
}
$all = array();
$chunks = explode( '|||', $domains_json_list );
foreach ( $chunks as $chunk ) {
$decoded = json_decode( $chunk, true );
if ( is_array( $decoded ) ) {
$all = array_merge( $all, $decoded );
}
}
$counts = array_count_values( $all );
arsort( $counts );
$top = array_slice( array_keys( $counts ), 0, 5 );
return array_values( array_filter( $top, fn( $d ) => PRV_TARGET_DOMAIN !== $d ) );
}
}