Files
prautoblogger/includes/core/class-research-judge.php
peptiderepo 344412c4e7 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
2026-06-24 07:40:23 +07:00

265 lines
9.4 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
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 ) )
)
);
}
}