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:
peptiderepo
2026-06-24 07:40:23 +07:00
committed by GitHub
parent 32c7f9476d
commit 344412c4e7
16 changed files with 1928 additions and 21 deletions

View 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,
);
}
}

View 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 ) )
)
);
}
}

View 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;
}
}

View 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 ) )
)
);
}
}

View 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;
}
}

View 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;
}
}

View 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;
}

View 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;
}