ledger = $ledger; $this->capture = $capture; } /** * Run the full peptide × intent × model loop. * * Side effects: HTTP calls to LLM APIs, database writes, option writes. * * @param string $run_id UUID for this run. * @param int $config_ver Config version at run time. * @return array{probed: int, skipped_budget: int, skipped_error: int, truncated: bool, run_id: string} */ public function execute( string $run_id, int $config_ver ): array { $peptides = PRV_Config::get_peptides(); $intents = PRV_Config::get_prompt_intents(); $models = PRV_Config::get_models(); $model_outcomes = array(); foreach ( $models as $slug ) { // phpcs:ignore WordPress.Arrays.ArrayDeclarationSpacing.AssociativeArrayFound $model_outcomes[ $slug ] = array( 'probed' => 0, 'errors' => 0 ); } $counts = array( 'probed' => 0, 'skipped_budget' => 0, 'skipped_error' => 0, 'truncated' => false, 'run_id' => $run_id, ); $budget_hit = false; foreach ( $peptides as $peptide ) { foreach ( $intents as $intent_tpl ) { $query = str_replace( '{peptide}', $peptide['label'], $intent_tpl ); foreach ( $models as $model ) { $this->probe_one( $run_id, $peptide, $model, $intent_tpl, $query, $config_ver, $counts, $model_outcomes, $budget_hit ); } } } if ( $budget_hit && $counts['probed'] > 0 ) { $counts['truncated'] = true; } update_option( 'prv_last_run_truncated', $counts['truncated'] ? 1 : 0 ); if ( $counts['truncated'] ) { update_option( 'prv_last_run_truncated_at', current_time( 'mysql', true ) ); } update_option( 'prv_last_run_at', current_time( 'mysql', true ) ); update_option( 'prv_last_run_counts', $counts ); PRV_Model_Registry::update_health( $run_id, $model_outcomes ); $this->settle_api_key_status( $counts, $model_outcomes ); return $counts; } /** * Execute one probe combination in mandatory P0 order. * * @param string $run_id Run UUID. * @param array $peptide Peptide config. * @param string $model Model slug. * @param string $intent_tpl Intent template. * @param string $query Rendered query. * @param int $config_ver Config version. * @param array &$counts Counts (modified). * @param array &$model_outcomes Outcomes (modified). * @param bool &$budget_hit Budget flag (modified). * @return void Modifies $counts and $model_outcomes in-place. */ private function probe_one( string $run_id, array $peptide, string $model, string $intent_tpl, string $query, int $config_ver, array &$counts, array &$model_outcomes, bool &$budget_hit ): void { $provider = $this->resolve_provider( $model ); if ( null === $provider || ! $provider->is_configured() ) { ++$counts['skipped_error']; if ( isset( $model_outcomes[ $model ] ) ) { ++$model_outcomes[ $model ]['errors']; } return; } $estimated = $this->get_estimated_cost( $model ); if ( ! $this->ledger->can_afford( $estimated ) ) { ++$counts['skipped_budget']; $budget_hit = true; return; } $start_ms = (int) round( microtime( true ) * 1000 ); try { $result = $provider->probe( $query ); $latency_ms = (int) round( microtime( true ) * 1000 ) - $start_ms; $http_status = 200; } catch ( \Exception $e ) { $latency_ms = (int) round( microtime( true ) * 1000 ) - $start_ms; $http_status = $this->extract_http_status( $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']; if ( isset( $model_outcomes[ $model ] ) ) { ++$model_outcomes[ $model ]['errors']; } // phpcs:disable WordPress.Arrays.ArrayDeclarationSpacing.AssociativeArrayFound, WordPress.Arrays.ArrayDeclarationSpacing.ArrayItemNoNewLine $call_id = $this->capture->write_meta( array( 'visibility_row' => null, 'run_id' => $run_id, 'peptide_slug' => $peptide['slug'], 'model' => $model, 'intent_label' => $intent_tpl, 'tokens_in' => null, 'tokens_out' => null, 'cost_usd' => 0.0, 'latency_ms' => $latency_ms, 'cited' => null, 'http_status' => $http_status, 'config_version' => $config_ver, ) ); // phpcs:enable if ( $call_id > 0 ) { try { $this->capture->write_io( $call_id, $query, '' ); } catch ( \Throwable $io_err ) { // Best-effort: I/O capture failure must not propagate (P0-4). unset( $io_err ); } } return; } $row_id = $this->persist_result( $run_id, $peptide, $model, $intent_tpl, $result, $config_ver ); if ( $row_id > 0 ) { $this->ledger->update_row_cost( $row_id, $result->get_cost_usd() ); } // phpcs:disable WordPress.Arrays.ArrayDeclarationSpacing.AssociativeArrayFound, WordPress.Arrays.ArrayDeclarationSpacing.ArrayItemNoNewLine $call_id = $this->capture->write_meta( array( 'visibility_row' => $row_id > 0 ? $row_id : null, 'run_id' => $run_id, 'peptide_slug' => $peptide['slug'], 'model' => $model, 'intent_label' => $intent_tpl, 'tokens_in' => $result->get_tokens_in(), 'tokens_out' => $result->get_tokens_out(), 'cost_usd' => $result->get_cost_usd(), 'latency_ms' => $latency_ms, 'cited' => $result->is_cited() ? 1 : 0, 'http_status' => $http_status, 'config_version' => $config_ver, ) ); // phpcs:enable if ( $call_id > 0 ) { try { $this->capture->write_io( $call_id, $query, $result->get_raw_excerpt() ); } catch ( \Throwable $io_err ) { // Best-effort: I/O capture failure must not propagate (P0-4). unset( $io_err ); } } ++$counts['probed']; if ( isset( $model_outcomes[ $model ] ) ) { ++$model_outcomes[ $model ]['probed']; } } /** * Persist one probe result to prv_ai_visibility. * * @param string $run_id Run UUID. * @param array $peptide Peptide config. * @param string $model Model slug. * @param string $intent_tpl Intent template. * @param PRV_Probe_Result $result Probe result. * @param int $config_ver Config version. * @return int 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 $config_ver ): int { global $wpdb; $table = PRV_Table_Manager::get_table_name(); // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.Arrays.ArrayDeclarationSpacing.ArrayItemNoNewLine $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, '.', '' ), 'config_version' => $config_ver, ), array( '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%s', '%s', '%s', '%d' ) ); // phpcs:enable return (int) $wpdb->insert_id; } /** * Extract HTTP status from a gateway exception message. * * @param \Exception $e Caught exception. * @return int HTTP status, or 0 if unknown. */ private function extract_http_status( \Exception $e ): int { if ( preg_match( '/PR Vision gateway HTTP (\d+)/', $e->getMessage(), $m ) ) { return (int) $m[1]; } return 0; } /** * Resolve provider instance for a model slug. * * @param string $model Model slug. * @return PRV_Probe_Provider|null Provider, or null if not configured. */ private function resolve_provider( string $model ): ?PRV_Probe_Provider { if ( 'perplexity/sonar' === $model ) { return new PRV_Perplexity_Provider(); } return new PRV_OpenRouter_Provider( $model ); } /** * Conservative per-call cost estimate for budget pre-check. * * @param string $model Model slug. * @return float Estimated USD per call. */ 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; } /** * Write the API-key status option based on run outcomes. * * @param array $counts Run counts. * @param array $model_outcomes Model outcomes. * @return void */ private function settle_api_key_status( array $counts, array $model_outcomes ): void { if ( $counts['probed'] > 0 ) { update_option( 'prv_api_key_status', 'ok' ); } elseif ( $counts['skipped_error'] > 0 && 0 === $counts['probed'] ) { update_option( 'prv_api_key_status', 'failed' ); } update_option( 'prv_api_key_last_check', current_time( 'mysql', true ) ); } }