feat(phase2b): P2b.1 — Research_Fanout + Research_Judge (curate stage) (#178, v0.28.0)
Additive: parallel research fan-out (curl_multi, quorum, cost-reserve-before-dispatch) + curate judge (dedup/score/run_sources). Live Economy path untouched; Authority activates at P2b.4. QA APPROVE-WITH-P2 685c40b; CI green (PHPUnit verified on VPS runner). Agent-Session: prab-phase2b-1-merge
This commit is contained in:
200
includes/core/class-research-batch.php
Normal file
200
includes/core/class-research-batch.php
Normal file
@@ -0,0 +1,200 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Low-level curl_multi executor for the Authority research fan-out.
|
||||
*
|
||||
* What: Wraps PHP's curl_multi interface to dispatch N parallel LLM
|
||||
* chat-completion requests to OpenRouter and parse each handle's
|
||||
* response into a normalised result array. Mirrors the pattern of
|
||||
* PRAutoBlogger_OpenRouter_Image_Batch (image parallel dispatch).
|
||||
* All failures emit Logger::warning and fold the slot into an
|
||||
* `{error: string}` entry — the caller (Research_Fanout) applies
|
||||
* quorum logic. No retry: the 300s timeout + single-attempt
|
||||
* success rate on OpenRouter is sufficient at this fan-out size.
|
||||
* Who triggers it: PRAutoBlogger_Research_Fanout::dispatch() only.
|
||||
* Not wired into the Economy path.
|
||||
* Dependencies: PRAutoBlogger_OpenRouter_Config, Request_Builder,
|
||||
* PRAutoBlogger_OpenRouter_Pricing, Logger.
|
||||
*
|
||||
* @see core/class-research-fanout.php — Sole caller.
|
||||
* @see providers/class-open-router-image-batch.php — curl_multi pattern.
|
||||
* @see ARCHITECTURE.md — Phase 2b data flow.
|
||||
*/
|
||||
class PRAutoBlogger_Research_Batch {
|
||||
|
||||
/**
|
||||
* Execute multiple chat-completion requests concurrently via curl_multi.
|
||||
*
|
||||
* @param string $model OpenRouter model slug.
|
||||
* @param array<int, array<int, array{role: string, content: string}>> $messages_per_agent One message-set per agent slot.
|
||||
* @param array<string, mixed> $options Shared call options (temperature, max_tokens, response_format).
|
||||
* @param string[] $roles Role label per slot — for log attribution only.
|
||||
* @return array<int, array{content?: string, model?: string, prompt_tokens?: int, completion_tokens?: int, actual_cost?: float, error?: string}>
|
||||
* Indexed 0..N-1; error key present on failure.
|
||||
*
|
||||
* Side effects: outbound HTTP via curl_multi, Logger::warning on failures.
|
||||
*/
|
||||
public function execute(
|
||||
string $model,
|
||||
array $messages_per_agent,
|
||||
array $options,
|
||||
array $roles
|
||||
): array {
|
||||
if ( empty( $messages_per_agent ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$config = new PRAutoBlogger_OpenRouter_Config();
|
||||
$builder = new PRAutoBlogger_OpenRouter_Request_Builder();
|
||||
$api_key = $builder->resolve_api_key();
|
||||
$url = $config->get_api_base_url() . '/chat/completions';
|
||||
$headers = $builder->build_headers( $api_key, $config->is_via_gateway(), 0 );
|
||||
|
||||
$curl_headers = array();
|
||||
foreach ( $headers as $name => $value ) {
|
||||
$curl_headers[] = $name . ': ' . $value;
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_multi_init
|
||||
$mh = curl_multi_init();
|
||||
$handles = array();
|
||||
|
||||
foreach ( $messages_per_agent as $idx => $msgs ) {
|
||||
$body = array(
|
||||
'model' => $model,
|
||||
'messages' => $msgs,
|
||||
'temperature' => $options['temperature'] ?? 0.3,
|
||||
'max_tokens' => $options['max_tokens'] ?? 3000,
|
||||
'response_format' => $options['response_format'] ?? array( 'type' => 'json_object' ),
|
||||
);
|
||||
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_init
|
||||
$ch = curl_init();
|
||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_setopt
|
||||
curl_setopt( $ch, CURLOPT_URL, $url );
|
||||
curl_setopt( $ch, CURLOPT_POST, true );
|
||||
curl_setopt( $ch, CURLOPT_POSTFIELDS, wp_json_encode( $body ) );
|
||||
curl_setopt( $ch, CURLOPT_HTTPHEADER, $curl_headers );
|
||||
curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
|
||||
curl_setopt( $ch, CURLOPT_TIMEOUT, PRAUTOBLOGGER_API_TIMEOUT_SECONDS );
|
||||
curl_setopt( $ch, CURLOPT_CONNECTTIMEOUT, 10 );
|
||||
// phpcs:enable WordPress.WP.AlternativeFunctions.curl_curl_setopt
|
||||
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_multi_add_handle
|
||||
curl_multi_add_handle( $mh, $ch );
|
||||
$handles[ $idx ] = array(
|
||||
'handle' => $ch,
|
||||
'role' => $roles[ $idx ] ?? (string) $idx,
|
||||
);
|
||||
}
|
||||
|
||||
$this->run_multi( $mh );
|
||||
|
||||
$results = array();
|
||||
foreach ( $handles as $idx => $entry ) {
|
||||
$results[ $idx ] = $this->parse_handle(
|
||||
$entry['handle'],
|
||||
$model,
|
||||
$entry['role']
|
||||
);
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_multi_remove_handle
|
||||
curl_multi_remove_handle( $mh, $entry['handle'] );
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_close
|
||||
curl_close( $entry['handle'] );
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_multi_close
|
||||
curl_multi_close( $mh );
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
// ── Private helpers ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Drive curl_multi_exec to completion.
|
||||
*
|
||||
* @param \CurlMultiHandle $mh Multi handle.
|
||||
* @return void
|
||||
*/
|
||||
private function run_multi( $mh ): void {
|
||||
$running = 0;
|
||||
do {
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_multi_exec
|
||||
$status = curl_multi_exec( $mh, $running );
|
||||
if ( $running > 0 ) {
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_multi_select
|
||||
curl_multi_select( $mh, 1.0 );
|
||||
}
|
||||
} while ( $running > 0 && CURLM_OK === $status );
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one completed curl handle into a normalised result.
|
||||
*
|
||||
* @param \CurlHandle $ch Completed handle.
|
||||
* @param string $model Model slug (for cost estimation fallback).
|
||||
* @param string $role Agent role label (for log attribution).
|
||||
* @return array{content?: string, model?: string, prompt_tokens?: int, completion_tokens?: int, actual_cost?: float, error?: string}
|
||||
*
|
||||
* Side effects: Logger::warning on failure.
|
||||
*/
|
||||
private function parse_handle( $ch, string $model, string $role ): array {
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_errno
|
||||
if ( 0 !== curl_errno( $ch ) ) {
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_error
|
||||
$err = curl_error( $ch );
|
||||
PRAutoBlogger_Logger::instance()->warning(
|
||||
sprintf( 'Research batch agent "%s" cURL error: %s', $role, $err ),
|
||||
'research-batch'
|
||||
);
|
||||
return array( 'error' => $err );
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_getinfo
|
||||
$http = (int) curl_getinfo( $ch, CURLINFO_HTTP_CODE );
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_multi_getcontent
|
||||
$raw = (string) curl_multi_getcontent( $ch );
|
||||
|
||||
if ( $http >= 400 ) {
|
||||
$msg = sprintf(
|
||||
'Research batch agent "%s" HTTP %d: %s',
|
||||
$role,
|
||||
$http,
|
||||
substr( $raw, 0, 200 )
|
||||
);
|
||||
PRAutoBlogger_Logger::instance()->warning( $msg, 'research-batch' );
|
||||
return array( 'error' => $msg );
|
||||
}
|
||||
|
||||
$decoded = json_decode( $raw, true );
|
||||
if ( ! is_array( $decoded ) || ! isset( $decoded['choices'][0]['message']['content'] ) ) {
|
||||
$msg = sprintf( 'Research batch agent "%s" unexpected response shape.', $role );
|
||||
PRAutoBlogger_Logger::instance()->warning( $msg, 'research-batch' );
|
||||
return array( 'error' => $msg );
|
||||
}
|
||||
|
||||
$content = (string) $decoded['choices'][0]['message']['content'];
|
||||
$usage = $decoded['usage'] ?? array();
|
||||
$prompt_tokens = (int) ( $usage['prompt_tokens'] ?? 0 );
|
||||
$completion_tokens = (int) ( $usage['completion_tokens'] ?? 0 );
|
||||
$actual_model = (string) ( $decoded['model'] ?? $model );
|
||||
|
||||
$actual_cost = ( new PRAutoBlogger_OpenRouter_Pricing() )->estimate_cost(
|
||||
$actual_model,
|
||||
$prompt_tokens,
|
||||
$completion_tokens
|
||||
);
|
||||
|
||||
return array(
|
||||
'content' => $content,
|
||||
'model' => $actual_model,
|
||||
'prompt_tokens' => $prompt_tokens,
|
||||
'completion_tokens' => $completion_tokens,
|
||||
'actual_cost' => $actual_cost,
|
||||
);
|
||||
}
|
||||
}
|
||||
130
includes/core/class-research-dedup.php
Normal file
130
includes/core/class-research-dedup.php
Normal file
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Research Dedup — URL-exact and semantic deduplication for the curate stage.
|
||||
*
|
||||
* What: Deduplicates a flat source list using (1) canonical URL matching,
|
||||
* then (2) cosine-similarity on OpenRouter embeddings with a
|
||||
* keyword-overlap fallback when the embedding API is unavailable.
|
||||
* Pure computation — no DB writes, no side effects beyond logging.
|
||||
* Who triggers it: PRAutoBlogger_Research_Judge (curate stage) only.
|
||||
* Not wired into the Economy path.
|
||||
* Dependencies: PRAutoBlogger_OpenRouter_Embedding_Provider (semantic dedup),
|
||||
* WordPress wp_parse_url(), Logger (warning on embedding failure).
|
||||
*
|
||||
* @see core/class-research-judge.php — Sole consumer.
|
||||
* @see core/class-semantic-dedup.php — Embedding pattern reference.
|
||||
* @see ARCHITECTURE.md — Phase 2b curate stage.
|
||||
*/
|
||||
class PRAutoBlogger_Research_Dedup {
|
||||
|
||||
/** Cosine similarity threshold above which two sources are duplicates. */
|
||||
private const SEMANTIC_DEDUP_THRESHOLD = 0.90;
|
||||
|
||||
/**
|
||||
* Deduplicate: URL-exact first, then semantic similarity via embeddings
|
||||
* (falls back to keyword-overlap when the embedding API is unavailable).
|
||||
*
|
||||
* @param array<int, array{url: string, title: string, excerpt: string, relevance: float, agent_role: string}> $sources Flat source list.
|
||||
* @return array<int, array{url: string, title: string, excerpt: string, relevance: float, agent_role: string}> Deduplicated sources.
|
||||
*
|
||||
* Side effects: Logger::warning() when embedding API is unreachable.
|
||||
*/
|
||||
public function deduplicate( array $sources ): array {
|
||||
$seen_urls = array();
|
||||
$unique = array();
|
||||
$unique_embeddings = array();
|
||||
$unique_kw = array();
|
||||
$embeddings_ok = true;
|
||||
$embed_provider = null;
|
||||
|
||||
foreach ( $sources as $source ) {
|
||||
$canonical = $this->canonical_url( $source['url'] );
|
||||
if ( '' !== $canonical ) {
|
||||
if ( isset( $seen_urls[ $canonical ] ) ) {
|
||||
continue;
|
||||
}
|
||||
$seen_urls[ $canonical ] = true;
|
||||
}
|
||||
|
||||
$text = trim( $source['title'] . ' ' . $source['excerpt'] );
|
||||
|
||||
if ( $embeddings_ok && '' !== $text ) {
|
||||
try {
|
||||
if ( null === $embed_provider ) {
|
||||
$embed_provider = new PRAutoBlogger_OpenRouter_Embedding_Provider();
|
||||
}
|
||||
$emb = $embed_provider->get_embeddings( array( $text ) );
|
||||
$vec = $emb[0] ?? null;
|
||||
if ( null !== $vec ) {
|
||||
foreach ( $unique_embeddings as $ev ) {
|
||||
if ( PRAutoBlogger_OpenRouter_Embedding_Provider::cosine_similarity( $vec, $ev ) >= self::SEMANTIC_DEDUP_THRESHOLD ) {
|
||||
continue 2;
|
||||
}
|
||||
}
|
||||
$unique_embeddings[] = $vec;
|
||||
}
|
||||
} catch ( \Throwable $e ) {
|
||||
$embeddings_ok = false;
|
||||
PRAutoBlogger_Logger::instance()->warning(
|
||||
'Research Judge: embedding unavailable, using keyword fallback. ' . $e->getMessage(),
|
||||
'research-judge'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! $embeddings_ok && '' !== $text ) {
|
||||
$kw = $this->extract_keywords( $text );
|
||||
foreach ( $unique_kw as $existing ) {
|
||||
$overlap = count( array_intersect( $kw, $existing ) );
|
||||
if ( count( $kw ) > 0 && ( $overlap / count( $kw ) ) >= 0.6 ) {
|
||||
continue 2;
|
||||
}
|
||||
}
|
||||
$unique_kw[] = $kw;
|
||||
}
|
||||
|
||||
$unique[] = $source;
|
||||
}
|
||||
|
||||
return $unique;
|
||||
}
|
||||
|
||||
// ── Private helpers ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Canonical URL: lowercase scheme+host+path, no query/fragment.
|
||||
*
|
||||
* @param string $url Raw URL.
|
||||
* @return string Normalised, or empty on unparseable.
|
||||
*/
|
||||
private function canonical_url( string $url ): string {
|
||||
if ( '' === $url ) {
|
||||
return '';
|
||||
}
|
||||
$parts = wp_parse_url( $url );
|
||||
if ( ! is_array( $parts ) ) {
|
||||
return strtolower( $url );
|
||||
}
|
||||
return strtolower( ( $parts['scheme'] ?? 'https' ) . '://' . ( $parts['host'] ?? '' ) . rtrim( $parts['path'] ?? '', '/' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyword extractor used for the embedding-unavailable fallback.
|
||||
*
|
||||
* @param string $text Input text.
|
||||
* @return string[] Lowercase unique keywords (3+ chars).
|
||||
*/
|
||||
private function extract_keywords( string $text ): array {
|
||||
static $sw = array( 'a', 'an', 'the', 'and', 'or', 'in', 'of', 'to', 'is', 'was', 'for', 'on', 'at' );
|
||||
$words = preg_split( '/[^a-z0-9-]+/', strtolower( $text ), -1, PREG_SPLIT_NO_EMPTY ) ?? array();
|
||||
return array_values(
|
||||
array_unique(
|
||||
array_filter( $words, static fn( string $w ): bool => strlen( $w ) >= 3 && ! in_array( $w, $sw, true ) )
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
298
includes/core/class-research-fanout.php
Normal file
298
includes/core/class-research-fanout.php
Normal file
@@ -0,0 +1,298 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Parallel specialist research fan-out for Authority-tier articles.
|
||||
*
|
||||
* What: Dispatches N specialist LLM research agents in parallel (via
|
||||
* PRAutoBlogger_Research_Batch / curl_multi), each researching a
|
||||
* different angle of the article topic. Before dispatch, reserves
|
||||
* the SUMMED worst-case cost of the entire batch from the per-run
|
||||
* cost governor — a single atomic write so concurrent writers
|
||||
* cannot slip past the per-run ceiling between check and call.
|
||||
* Quorum check (⌈N/2⌉+1 usable results required) guards against
|
||||
* thin research proceeding on partial failures. Invalid or
|
||||
* schema-mismatched agent results are excluded and never silently
|
||||
* passed. Failing agents mark their run_stages rows as failed.
|
||||
* Who triggers it: PRAutoBlogger_Authority_Pipeline (Phase 2b.4, the
|
||||
* tier router). NOT wired into the Economy (generate_single_pass)
|
||||
* path — additive only, not live until P2b.4.
|
||||
* Dependencies: PRAutoBlogger_Cost_Governor (reserve-before-dispatch),
|
||||
* PRAutoBlogger_Research_Batch (curl_multi execution),
|
||||
* PRAutoBlogger_Run_Stage_State (per-agent stage rows),
|
||||
* PRAutoBlogger_Prompt_Registry (research.system prompt),
|
||||
* PRAutoBlogger_Json_Extractor (JSON schema validation),
|
||||
* PRAutoBlogger_Cost_Tracker (log_api_call), Logger.
|
||||
*
|
||||
* @see providers/interface-research-fanout.php — Interface this class implements.
|
||||
* @see core/class-research-batch.php — curl_multi execution delegate.
|
||||
* @see core/class-research-judge.php — Consumes these results (curate).
|
||||
* @see ARCHITECTURE.md — Phase 2b data flow.
|
||||
*/
|
||||
class PRAutoBlogger_Research_Fanout implements PRAutoBlogger_Research_Fanout_Interface {
|
||||
|
||||
/** Maximum configurable agent count. */
|
||||
private const MAX_AGENTS = 5;
|
||||
|
||||
/** Minimum configurable agent count. Floor=2: quorum=⌈N/2⌉+1=2 for N=1 is unsatisfiable. See CONTEXT.md §Phase 2b. */
|
||||
private const MIN_AGENTS = 2;
|
||||
|
||||
/** Specialist roles in priority order; first N are used per run. */
|
||||
private const SPECIALIST_ROLES = array(
|
||||
'mechanisms',
|
||||
'clinical',
|
||||
'safety',
|
||||
'comparison',
|
||||
'practical',
|
||||
);
|
||||
|
||||
/** @var PRAutoBlogger_Research_Batch curl_multi execution delegate. */
|
||||
private PRAutoBlogger_Research_Batch $batch;
|
||||
|
||||
/**
|
||||
* @param PRAutoBlogger_Research_Batch|null $batch Optional batch override (tests).
|
||||
*/
|
||||
public function __construct( ?PRAutoBlogger_Research_Batch $batch = null ) {
|
||||
$this->batch = $batch ?? new PRAutoBlogger_Research_Batch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch N specialist research agents in parallel and collect results.
|
||||
*
|
||||
* Returns an indexed array of per-agent result maps. If fewer than
|
||||
* quorum (⌈N/2⌉+1) agents return usable results, returns an empty
|
||||
* array — the caller should HOLD the run rather than proceeding on
|
||||
* thin research.
|
||||
*
|
||||
* Side effects: curl_multi HTTP calls to OpenRouter, run_stages DB
|
||||
* writes, cost-governor reservation + settlement, Logger calls.
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param string $item_key Article-scoped item key.
|
||||
* @param PRAutoBlogger_Article_Idea $idea The idea being researched.
|
||||
* @param PRAutoBlogger_Cost_Tracker $cost_tracker Pipeline cost tracker.
|
||||
* @return array<int, array{sources: array<int, array{url: string, title: string, excerpt: string, relevance: float}>, agent_role: string}> Per-agent results, or empty when quorum not met.
|
||||
*/
|
||||
public function dispatch(
|
||||
string $run_id,
|
||||
string $item_key,
|
||||
PRAutoBlogger_Article_Idea $idea,
|
||||
PRAutoBlogger_Cost_Tracker $cost_tracker
|
||||
): array {
|
||||
$n = $this->resolve_agent_count();
|
||||
$model = $this->resolve_model();
|
||||
$quorum = (int) ceil( $n / 2 ) + 1;
|
||||
$roles = array_slice( self::SPECIALIST_ROLES, 0, $n );
|
||||
$topic = $idea->get_topic();
|
||||
$title = $idea->get_suggested_title();
|
||||
|
||||
$messages_per_agent = $this->build_agent_messages( $topic, $title, $roles );
|
||||
$options = array(
|
||||
'temperature' => 0.3,
|
||||
'max_tokens' => 3000,
|
||||
'response_format' => array( 'type' => 'json_object' ),
|
||||
);
|
||||
|
||||
// Reserve the SUMMED worst-case cost of all N agents before dispatch.
|
||||
$per_estimate = PRAutoBlogger_Cost_Governor::estimate_chat_cost( $model, $messages_per_agent[0], $options );
|
||||
$batch_estimate = $per_estimate * $n;
|
||||
$reservation = PRAutoBlogger_Cost_Governor::open_amount_reservation(
|
||||
$batch_estimate,
|
||||
sprintf( 'research_fanout:n=%d:%s', $n, $model )
|
||||
);
|
||||
|
||||
PRAutoBlogger_Logger::instance()->info(
|
||||
sprintf(
|
||||
'Research fan-out: dispatching %d agents (quorum=%d) for "%s". Batch reserve: $%.4f.',
|
||||
$n,
|
||||
$quorum,
|
||||
mb_substr( $topic, 0, 60 ),
|
||||
$batch_estimate
|
||||
),
|
||||
'research-fanout'
|
||||
);
|
||||
|
||||
foreach ( $roles as $role ) {
|
||||
PRAutoBlogger_Run_Stage_State::start( $run_id, 'research', 'researcher:' . $role, $item_key );
|
||||
}
|
||||
|
||||
$valid_results = array();
|
||||
$actual_total = 0.0;
|
||||
|
||||
// try/finally: settle() ALWAYS runs — even if batch->execute() throws.
|
||||
// Without this, an uncaught exception leaves reserved_usd open in the ledger
|
||||
// until the run reaper, silently eroding the ceiling. Settles 0.0 on throw.
|
||||
try {
|
||||
$raw_results = $this->batch->execute( $model, $messages_per_agent, $options, $roles );
|
||||
|
||||
foreach ( $raw_results as $idx => $raw ) {
|
||||
$role = $roles[ $idx ];
|
||||
if ( isset( $raw['error'] ) ) {
|
||||
PRAutoBlogger_Logger::instance()->warning(
|
||||
sprintf( 'Research agent "%s" failed: %s', $role, $raw['error'] ),
|
||||
'research-fanout'
|
||||
);
|
||||
PRAutoBlogger_Run_Stage_State::fail( $run_id, 'research', 'researcher:' . $role, $item_key );
|
||||
continue;
|
||||
}
|
||||
|
||||
$parsed = PRAutoBlogger_Json_Extractor::decode( $raw['content'] ?? '' );
|
||||
$sources = $this->validate_sources( $parsed );
|
||||
|
||||
if ( null === $sources ) {
|
||||
PRAutoBlogger_Logger::instance()->warning(
|
||||
sprintf( 'Research agent "%s" returned invalid schema; excluding.', $role ),
|
||||
'research-fanout'
|
||||
);
|
||||
PRAutoBlogger_Run_Stage_State::fail( $run_id, 'research', 'researcher:' . $role, $item_key );
|
||||
continue;
|
||||
}
|
||||
|
||||
$actual_cost = (float) ( $raw['actual_cost'] ?? 0.0 );
|
||||
$actual_total += $actual_cost;
|
||||
|
||||
$cost_tracker->log_api_call(
|
||||
null,
|
||||
'research',
|
||||
'openrouter',
|
||||
$raw['model'] ?? $model,
|
||||
(int) ( $raw['prompt_tokens'] ?? 0 ),
|
||||
(int) ( $raw['completion_tokens'] ?? 0 )
|
||||
);
|
||||
|
||||
PRAutoBlogger_Run_Stage_State::done(
|
||||
$run_id,
|
||||
'research',
|
||||
'researcher:' . $role,
|
||||
$item_key,
|
||||
wp_json_encode( array( 'source_count' => count( $sources ) ) ),
|
||||
$actual_cost
|
||||
);
|
||||
|
||||
$valid_results[] = array(
|
||||
'sources' => $sources,
|
||||
'agent_role' => 'researcher:' . $role,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
PRAutoBlogger_Cost_Governor::settle( $reservation, $actual_total ); // Always settles (0 on throw).
|
||||
}
|
||||
|
||||
$valid_count = count( $valid_results );
|
||||
PRAutoBlogger_Logger::instance()->info(
|
||||
sprintf(
|
||||
'Research fan-out complete: %d/%d agents usable (quorum=%d).',
|
||||
$valid_count,
|
||||
$n,
|
||||
$quorum
|
||||
),
|
||||
'research-fanout'
|
||||
);
|
||||
|
||||
if ( $valid_count < $quorum ) {
|
||||
PRAutoBlogger_Logger::instance()->warning(
|
||||
sprintf( 'Research quorum not met (%d/%d). Run should hold.', $valid_count, $quorum ),
|
||||
'research-fanout'
|
||||
);
|
||||
return array();
|
||||
}
|
||||
|
||||
return $valid_results;
|
||||
}
|
||||
|
||||
// ── Private helpers ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Read the configured agent count, clamped to [MIN_AGENTS, MAX_AGENTS].
|
||||
* MIN_AGENTS=2 since quorum=⌈N/2⌉+1=2 for N=1 is unsatisfiable; see CONTEXT.md §Phase 2b.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
private function resolve_agent_count(): int {
|
||||
$n = (int) get_option( 'prautoblogger_research_agent_count', 3 );
|
||||
return max( self::MIN_AGENTS, min( self::MAX_AGENTS, $n ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the configured research model.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function resolve_model(): string {
|
||||
return (string) get_option( 'prautoblogger_research_model', PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL );
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the per-agent message sets (system + specialist user prompt).
|
||||
*
|
||||
* @param string $topic Topic text.
|
||||
* @param string $title Suggested article title.
|
||||
* @param string[] $roles Specialist role names.
|
||||
* @return array<int, array<int, array{role: string, content: string}>>
|
||||
*/
|
||||
private function build_agent_messages( string $topic, string $title, array $roles ): array {
|
||||
$system = PRAutoBlogger_Prompt_Registry::render( 'research.system' );
|
||||
$sets = array();
|
||||
foreach ( $roles as $role ) {
|
||||
$sets[] = array(
|
||||
array(
|
||||
'role' => 'system',
|
||||
'content' => $system,
|
||||
),
|
||||
array(
|
||||
'role' => 'user',
|
||||
'content' => sprintf(
|
||||
"Topic: %s\nProposed title: %s\n\nSpecialist focus: %s\n\n" .
|
||||
"Find 3-6 high-quality sources for this topic from a '%s' perspective. " .
|
||||
'Return JSON: {"sources":[{"url":"","title":"","excerpt":"","relevance":0.0}]}',
|
||||
$topic,
|
||||
$title,
|
||||
$role,
|
||||
$role
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return $sets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and normalise the parsed sources from one agent's response.
|
||||
*
|
||||
* Schema: `{sources: [{url, title, excerpt, relevance}]}`. Any entry
|
||||
* missing both url and title is silently skipped. Returns null when
|
||||
* the top-level schema is missing or sources is empty after filtering.
|
||||
*
|
||||
* @param array<string, mixed>|null $parsed Decoded JSON from one agent.
|
||||
* @return array<int, array{url: string, title: string, excerpt: string, relevance: float}>|null
|
||||
*
|
||||
* Side effects: none.
|
||||
*/
|
||||
private function validate_sources( ?array $parsed ): ?array {
|
||||
if ( ! is_array( $parsed ) || ! isset( $parsed['sources'] ) || ! is_array( $parsed['sources'] ) ) {
|
||||
return null;
|
||||
}
|
||||
$out = array();
|
||||
foreach ( $parsed['sources'] as $s ) {
|
||||
if ( ! is_array( $s ) ) {
|
||||
continue;
|
||||
}
|
||||
$url = sanitize_url( (string) ( $s['url'] ?? '' ) );
|
||||
$title = sanitize_text_field( (string) ( $s['title'] ?? '' ) );
|
||||
$excerpt = sanitize_textarea_field( (string) ( $s['excerpt'] ?? '' ) );
|
||||
$relevance = max( 0.0, min( 1.0, (float) ( $s['relevance'] ?? 0.0 ) ) );
|
||||
if ( '' === $url && '' === $title ) {
|
||||
continue;
|
||||
}
|
||||
$out[] = array(
|
||||
'url' => $url,
|
||||
'title' => $title,
|
||||
'excerpt' => $excerpt,
|
||||
'relevance' => $relevance,
|
||||
);
|
||||
}
|
||||
return count( $out ) > 0 ? $out : null;
|
||||
}
|
||||
}
|
||||
264
includes/core/class-research-judge.php
Normal file
264
includes/core/class-research-judge.php
Normal file
@@ -0,0 +1,264 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Research Judge — the `curate` stage of the Authority pipeline.
|
||||
*
|
||||
* What: Deduplicates sources across all fan-out agent results using
|
||||
* URL-exact matching first, then semantic similarity (via the
|
||||
* OpenRouter embedding API + cosine similarity, matching the
|
||||
* Semantic_Dedup pattern) with keyword-overlap fallback. Assigns
|
||||
* a quality_score to each source (relevance × source-type weight).
|
||||
* Writes one run_sources row per source (kept=1 or kept=0) with
|
||||
* reason + quality_score via PRAutoBlogger_Research_Source_Writer.
|
||||
* Returns the kept, scored list for the draft stage.
|
||||
* Who triggers it: PRAutoBlogger_Authority_Pipeline (Phase 2b.4, the
|
||||
* tier router). NOT wired into the Economy path — additive only.
|
||||
* Dependencies: PRAutoBlogger_Research_Source_Writer (run_sources writes),
|
||||
* PRAutoBlogger_Research_Source_Scorer (quality scoring),
|
||||
* PRAutoBlogger_Audit_Writer (run_decisions write),
|
||||
* PRAutoBlogger_OpenRouter_Embedding_Provider (semantic dedup),
|
||||
* PRAutoBlogger_Run_Stage_State (curate stage row),
|
||||
* PRAutoBlogger_Stage_Display_Map (default agent role),
|
||||
* WordPress $wpdb, Logger.
|
||||
*
|
||||
* @see providers/interface-research-judge.php — Interface.
|
||||
* @see core/class-research-fanout.php — Produces the input.
|
||||
* @see core/class-research-source-writer.php — Writes run_sources rows.
|
||||
* @see core/class-research-source-scorer.php — Source-type weighting delegate.
|
||||
* @see core/class-semantic-dedup.php — Embedding pattern reference.
|
||||
* @see ARCHITECTURE.md — Phase 2b curate stage.
|
||||
*/
|
||||
class PRAutoBlogger_Research_Judge implements PRAutoBlogger_Research_Judge_Interface {
|
||||
|
||||
/** Cosine similarity threshold above which two sources are duplicates. */
|
||||
private const SEMANTIC_DEDUP_THRESHOLD = 0.90;
|
||||
|
||||
/** Maximum kept sources passed to the draft stage. */
|
||||
private const MAX_KEPT_SOURCES = 12;
|
||||
|
||||
/** @var PRAutoBlogger_Research_Source_Scorer Scores source authority. */
|
||||
private PRAutoBlogger_Research_Source_Scorer $scorer;
|
||||
|
||||
/** @var PRAutoBlogger_Research_Source_Writer Persists run_sources rows. */
|
||||
private PRAutoBlogger_Research_Source_Writer $writer;
|
||||
|
||||
/**
|
||||
* @param PRAutoBlogger_Research_Source_Scorer|null $scorer Optional scorer override.
|
||||
* @param PRAutoBlogger_Research_Source_Writer|null $writer Optional writer override.
|
||||
*/
|
||||
public function __construct(
|
||||
?PRAutoBlogger_Research_Source_Scorer $scorer = null,
|
||||
?PRAutoBlogger_Research_Source_Writer $writer = null
|
||||
) {
|
||||
$this->scorer = $scorer ?? new PRAutoBlogger_Research_Source_Scorer();
|
||||
$this->writer = $writer ?? new PRAutoBlogger_Research_Source_Writer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicate and score fan-out results; write run_sources rows; return kept.
|
||||
*
|
||||
* Dedup order: (1) URL canonical match; (2) semantic similarity via embedding
|
||||
* API (fallback to keyword-overlap when the API is unavailable). Quality
|
||||
* score = relevance × source-type weight. Sources are sorted by quality_score
|
||||
* descending and capped at MAX_KEPT_SOURCES; overflow rows are written to
|
||||
* run_sources with kept=0 ("exceeded max").
|
||||
*
|
||||
* Side effects: run_sources DB writes, run_stages DB write (curate stage),
|
||||
* possible embedding API call, Logger calls.
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param string $item_key Article-scoped item key.
|
||||
* @param array<int, array{sources: array<int, array{url: string, title: string, excerpt: string, relevance: float}>, agent_role: string}> $fanout_results Agent results from Research_Fanout::dispatch().
|
||||
* @return array<int, array{url: string, title: string, excerpt: string, relevance: float, quality_score: float}> Kept, scored sources.
|
||||
*/
|
||||
public function curate(
|
||||
string $run_id,
|
||||
string $item_key,
|
||||
array $fanout_results
|
||||
): array {
|
||||
PRAutoBlogger_Run_Stage_State::start(
|
||||
$run_id,
|
||||
'curate',
|
||||
(string) PRAutoBlogger_Stage_Display_Map::default_agent_role( 'curate' ),
|
||||
$item_key
|
||||
);
|
||||
|
||||
$all_sources = $this->flatten( $fanout_results );
|
||||
$deduped = $this->deduplicate( $all_sources );
|
||||
$scored = array_map( array( $this->scorer, 'score' ), $deduped );
|
||||
|
||||
usort(
|
||||
$scored,
|
||||
static fn( array $a, array $b ): int => $b['quality_score'] <=> $a['quality_score']
|
||||
);
|
||||
|
||||
$kept = array_slice( $scored, 0, self::MAX_KEPT_SOURCES );
|
||||
$discarded = array_slice( $scored, self::MAX_KEPT_SOURCES );
|
||||
|
||||
$this->writer->write( $run_id, $kept, $discarded );
|
||||
|
||||
PRAutoBlogger_Audit_Writer::record_decision(
|
||||
$run_id,
|
||||
'curate',
|
||||
'completed',
|
||||
sprintf(
|
||||
'Curated %d sources from %d candidates across %d agents (discarded %d).',
|
||||
count( $kept ),
|
||||
count( $all_sources ),
|
||||
count( $fanout_results ),
|
||||
count( $discarded )
|
||||
)
|
||||
);
|
||||
|
||||
PRAutoBlogger_Run_Stage_State::done(
|
||||
$run_id,
|
||||
'curate',
|
||||
(string) PRAutoBlogger_Stage_Display_Map::default_agent_role( 'curate' ),
|
||||
$item_key,
|
||||
wp_json_encode(
|
||||
array(
|
||||
'kept' => count( $kept ),
|
||||
'total' => count( $all_sources ),
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
PRAutoBlogger_Logger::instance()->info(
|
||||
sprintf(
|
||||
'Research Judge: kept %d/%d sources (max=%d).',
|
||||
count( $kept ),
|
||||
count( $all_sources ),
|
||||
self::MAX_KEPT_SOURCES
|
||||
),
|
||||
'research-judge'
|
||||
);
|
||||
|
||||
return $kept;
|
||||
}
|
||||
|
||||
// ── Private helpers ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Flatten per-agent source lists into one array with agent_role attached.
|
||||
*
|
||||
* @param array<int, array{sources: array<int, array{url: string, title: string, excerpt: string, relevance: float}>, agent_role: string}> $fanout_results
|
||||
* @return array<int, array{url: string, title: string, excerpt: string, relevance: float, agent_role: string}>
|
||||
*/
|
||||
private function flatten( array $fanout_results ): array {
|
||||
$flat = array();
|
||||
foreach ( $fanout_results as $agent ) {
|
||||
foreach ( $agent['sources'] as $source ) {
|
||||
$flat[] = array_merge( $source, array( 'agent_role' => $agent['agent_role'] ) );
|
||||
}
|
||||
}
|
||||
return $flat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicate: URL-exact first, then semantic similarity.
|
||||
*
|
||||
* @param array<int, array{url: string, title: string, excerpt: string, relevance: float, agent_role: string}> $sources
|
||||
* @return array<int, array{url: string, title: string, excerpt: string, relevance: float, agent_role: string}>
|
||||
*/
|
||||
private function deduplicate( array $sources ): array {
|
||||
$seen_urls = array();
|
||||
$unique = array();
|
||||
$unique_embeddings = array();
|
||||
$unique_kw = array();
|
||||
$embeddings_ok = true;
|
||||
$embed_provider = null;
|
||||
|
||||
foreach ( $sources as $source ) {
|
||||
$canonical = $this->canonical_url( $source['url'] );
|
||||
if ( '' !== $canonical ) {
|
||||
if ( isset( $seen_urls[ $canonical ] ) ) {
|
||||
continue;
|
||||
}
|
||||
$seen_urls[ $canonical ] = true;
|
||||
}
|
||||
|
||||
$text = trim( $source['title'] . ' ' . $source['excerpt'] );
|
||||
|
||||
if ( $embeddings_ok && '' !== $text ) {
|
||||
try {
|
||||
if ( null === $embed_provider ) {
|
||||
$embed_provider = new PRAutoBlogger_OpenRouter_Embedding_Provider();
|
||||
}
|
||||
$emb = $embed_provider->get_embeddings( array( $text ) );
|
||||
$vec = $emb[0] ?? null;
|
||||
if ( null !== $vec ) {
|
||||
foreach ( $unique_embeddings as $ev ) {
|
||||
if ( PRAutoBlogger_OpenRouter_Embedding_Provider::cosine_similarity( $vec, $ev ) >= self::SEMANTIC_DEDUP_THRESHOLD ) {
|
||||
continue 2;
|
||||
}
|
||||
}
|
||||
$unique_embeddings[] = $vec;
|
||||
}
|
||||
} catch ( \Throwable $e ) {
|
||||
$embeddings_ok = false;
|
||||
PRAutoBlogger_Logger::instance()->warning(
|
||||
'Research Judge: embedding unavailable, using keyword fallback. ' . $e->getMessage(),
|
||||
'research-judge'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! $embeddings_ok && '' !== $text ) {
|
||||
$kw = $this->extract_keywords( $text );
|
||||
// Mirror Semantic_Dedup: require >=3 keywords before applying overlap
|
||||
// check. Sparse keyword sets (e.g. generic titles like "Source 0")
|
||||
// are kept unconditionally; URL-exact dedup already filtered real
|
||||
// duplicates with identical URLs.
|
||||
if ( count( $kw ) >= 3 ) {
|
||||
foreach ( $unique_kw as $existing ) {
|
||||
$overlap = count( array_intersect( $kw, $existing ) );
|
||||
if ( ( $overlap / count( $kw ) ) >= 0.6 ) {
|
||||
continue 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
$unique_kw[] = $kw;
|
||||
}
|
||||
|
||||
$unique[] = $source;
|
||||
}
|
||||
|
||||
return $unique;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical URL: lowercase scheme+host+path, no query/fragment.
|
||||
*
|
||||
* @param string $url Raw URL.
|
||||
* @return string Normalised, or empty on unparseable.
|
||||
*/
|
||||
private function canonical_url( string $url ): string {
|
||||
if ( '' === $url ) {
|
||||
return '';
|
||||
}
|
||||
$parts = wp_parse_url( $url );
|
||||
if ( ! is_array( $parts ) ) {
|
||||
return strtolower( $url );
|
||||
}
|
||||
return strtolower( ( $parts['scheme'] ?? 'https' ) . '://' . ( $parts['host'] ?? '' ) . rtrim( $parts['path'] ?? '', '/' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyword extractor used for the embedding-unavailable fallback.
|
||||
*
|
||||
* @param string $text Input text.
|
||||
* @return string[] Lowercase unique keywords (3+ chars).
|
||||
*/
|
||||
private function extract_keywords( string $text ): array {
|
||||
static $sw = array( 'a', 'an', 'the', 'and', 'or', 'in', 'of', 'to', 'is', 'was', 'for', 'on', 'at' );
|
||||
$words = preg_split( '/[^a-z0-9-]+/', strtolower( $text ), -1, PREG_SPLIT_NO_EMPTY ) ?? array();
|
||||
return array_values(
|
||||
array_unique(
|
||||
array_filter( $words, static fn( string $w ): bool => strlen( $w ) >= 3 && ! in_array( $w, $sw, true ) )
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
82
includes/core/class-research-source-scorer.php
Normal file
82
includes/core/class-research-source-scorer.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Source authority scorer for the Authority pipeline curate stage.
|
||||
*
|
||||
* What: Computes a quality_score for each research source by multiplying
|
||||
* its agent-assigned relevance by a source-type authority weight
|
||||
* derived from the URL scheme and hostname. Weight tiers:
|
||||
* peer-reviewed (DOI/PubMed/NCBI → 1.0), institutional (.gov /
|
||||
* .edu / WHO / FDA → 0.85), preprint (medRxiv/bioRxiv → 0.70),
|
||||
* general HTTPS (→ 0.60), HTTP or missing (→ 0.40).
|
||||
* Who triggers it: PRAutoBlogger_Research_Judge (curate stage) only.
|
||||
* Not wired into the Economy path.
|
||||
* Dependencies: none — pure computation.
|
||||
*
|
||||
* @see core/class-research-judge.php — Sole consumer.
|
||||
* @see ARCHITECTURE.md — Phase 2b curate stage.
|
||||
*/
|
||||
class PRAutoBlogger_Research_Source_Scorer {
|
||||
|
||||
/**
|
||||
* Add a quality_score field to a source array in place.
|
||||
*
|
||||
* @param array{url: string, title: string, excerpt: string, relevance: float, agent_role: string} $source Source from a research agent.
|
||||
* @return array{url: string, title: string, excerpt: string, relevance: float, agent_role: string, quality_score: float} Source with quality_score added.
|
||||
*
|
||||
* Side effects: none.
|
||||
*/
|
||||
public function score( array $source ): array {
|
||||
$source['quality_score'] = round(
|
||||
$source['relevance'] * $this->weight( $source['url'] ?? '' ),
|
||||
4
|
||||
);
|
||||
return $source;
|
||||
}
|
||||
|
||||
// ── Private helpers ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Authority weight for a source URL.
|
||||
*
|
||||
* @param string $url Source URL.
|
||||
* @return float Weight in [0.40, 1.0].
|
||||
*/
|
||||
private function weight( string $url ): float {
|
||||
$u = strtolower( $url );
|
||||
|
||||
if (
|
||||
false !== strpos( $u, 'doi.org' ) ||
|
||||
false !== strpos( $u, 'pubmed.ncbi' ) ||
|
||||
false !== strpos( $u, 'ncbi.nlm.nih' )
|
||||
) {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
if (
|
||||
false !== strpos( $u, '.gov' ) ||
|
||||
false !== strpos( $u, '.edu' ) ||
|
||||
false !== strpos( $u, 'who.int' ) ||
|
||||
false !== strpos( $u, 'fda.gov' )
|
||||
) {
|
||||
return 0.85;
|
||||
}
|
||||
|
||||
if (
|
||||
false !== strpos( $u, 'medrxiv' ) ||
|
||||
false !== strpos( $u, 'biorxiv' ) ||
|
||||
false !== strpos( $u, 'preprint' )
|
||||
) {
|
||||
return 0.70;
|
||||
}
|
||||
|
||||
if ( 0 === strpos( $u, 'https://' ) ) {
|
||||
return 0.60;
|
||||
}
|
||||
|
||||
return 0.40;
|
||||
}
|
||||
}
|
||||
88
includes/core/class-research-source-writer.php
Normal file
88
includes/core/class-research-source-writer.php
Normal file
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Run-sources DB writer for the Authority pipeline curate stage.
|
||||
*
|
||||
* What: Persists kept and discarded source records to the
|
||||
* wp_prautoblogger_run_sources table via PRAutoBlogger_Audit_Writer.
|
||||
* Builds the reason string for kept rows (quality/relevance) and the
|
||||
* fixed "exceeded max" reason for discarded rows. Also extracts DOI
|
||||
* identifiers from source URLs for storage.
|
||||
* Who triggers it: PRAutoBlogger_Research_Judge (curate stage) only.
|
||||
* Not wired into the Economy path.
|
||||
* Dependencies: PRAutoBlogger_Audit_Writer (availability check + DB insert),
|
||||
* WordPress $wpdb, current_time().
|
||||
*
|
||||
* @see core/class-research-judge.php — Sole consumer.
|
||||
* @see core/class-audit-writer.php — Underlying DB insert layer.
|
||||
* @see ARCHITECTURE.md — Phase 2b curate stage.
|
||||
*/
|
||||
class PRAutoBlogger_Research_Source_Writer {
|
||||
|
||||
/**
|
||||
* Write run_sources rows for kept (kept=1) and discarded (kept=0) sources.
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param array<int, array{url: string, agent_role?: string, quality_score: float, relevance: float}> $kept Scored kept sources.
|
||||
* @param array<int, array{url: string, agent_role?: string, quality_score: float, relevance: float}> $discarded Scored discarded sources (over max).
|
||||
* @return void Side effects: DB inserts; no-op when audit tables absent.
|
||||
*/
|
||||
public function write( string $run_id, array $kept, array $discarded ): void {
|
||||
if ( ! PRAutoBlogger_Audit_Writer::is_available() ) {
|
||||
return;
|
||||
}
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'prautoblogger_run_sources';
|
||||
$now = current_time( 'mysql' );
|
||||
$sources = array_merge(
|
||||
array_map(
|
||||
static fn( $s ) => $s + array(
|
||||
'is_kept' => 1,
|
||||
'discard_reason' => '',
|
||||
),
|
||||
$kept
|
||||
),
|
||||
array_map(
|
||||
static fn( $s ) => $s + array(
|
||||
'is_kept' => 0,
|
||||
'discard_reason' => 'Exceeded maximum kept sources per run',
|
||||
),
|
||||
$discarded
|
||||
)
|
||||
);
|
||||
foreach ( $sources as $s ) {
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$wpdb->insert(
|
||||
$table,
|
||||
array(
|
||||
'run_id' => $run_id,
|
||||
'agent_role' => $s['agent_role'] ?? 'curator',
|
||||
'source_url' => $s['url'],
|
||||
'doi' => $this->extract_doi( $s['url'] ),
|
||||
'kept' => $s['is_kept'],
|
||||
'reason' => $s['is_kept'] ? sprintf( 'Quality %.3f (relevance=%.2f)', $s['quality_score'], $s['relevance'] ) : $s['discard_reason'],
|
||||
'quality_score' => $s['quality_score'],
|
||||
'created_at' => $now,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Private helpers ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extract a DOI from a URL if one is present.
|
||||
*
|
||||
* @param string $url Source URL.
|
||||
* @return string|null DOI, or null.
|
||||
*/
|
||||
private function extract_doi( string $url ): ?string {
|
||||
if ( preg_match( '#10\.\d{4,}/\S+#', $url, $matches ) ) {
|
||||
return rtrim( $matches[0], '.' );
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
27
includes/providers/interface-research-fanout.php
Normal file
27
includes/providers/interface-research-fanout.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Contract for parallel specialist research fan-out.
|
||||
*
|
||||
* @see core/class-research-fanout.php — Primary implementation.
|
||||
* @see ARCHITECTURE.md — Phase 2b design.
|
||||
*/
|
||||
interface PRAutoBlogger_Research_Fanout_Interface {
|
||||
|
||||
/**
|
||||
* Dispatch N specialist research agents in parallel and collect results.
|
||||
*
|
||||
* @param string $run_id Pipeline run UUID.
|
||||
* @param string $item_key Article item key for stage scoping.
|
||||
* @param PRAutoBlogger_Article_Idea $idea The idea being researched.
|
||||
* @param PRAutoBlogger_Cost_Tracker $cost_tracker Pipeline cost tracker.
|
||||
* @return array<int, array{sources: array<int, array{url: string, title: string, excerpt: string, relevance: float}>, agent_role: string}> Results indexed by agent slot (0..N-1). Empty array means quorum not met (run should hold).
|
||||
*/
|
||||
public function dispatch(
|
||||
string $run_id,
|
||||
string $item_key,
|
||||
PRAutoBlogger_Article_Idea $idea,
|
||||
PRAutoBlogger_Cost_Tracker $cost_tracker
|
||||
): array;
|
||||
}
|
||||
26
includes/providers/interface-research-judge.php
Normal file
26
includes/providers/interface-research-judge.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Contract for the research judge that curates fan-out results.
|
||||
*
|
||||
* @see core/class-research-judge.php — Primary implementation.
|
||||
* @see ARCHITECTURE.md — Phase 2b curate stage.
|
||||
*/
|
||||
interface PRAutoBlogger_Research_Judge_Interface {
|
||||
|
||||
/**
|
||||
* Deduplicate and score the fan-out results; write keep/discard decisions
|
||||
* to run_sources; return kept sources for the draft stage.
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param string $item_key Article-scoped item key.
|
||||
* @param array<int, array{sources: array<int, array{url: string, title: string, excerpt: string, relevance: float}>, agent_role: string}> $fanout_results Agent results from Research_Fanout::dispatch().
|
||||
* @return array<int, array{url: string, title: string, excerpt: string, relevance: float, quality_score: float}> Kept, deduplicated, scored sources.
|
||||
*/
|
||||
public function curate(
|
||||
string $run_id,
|
||||
string $item_key,
|
||||
array $fanout_results
|
||||
): array;
|
||||
}
|
||||
Reference in New Issue
Block a user