feat(phase2b): P2b.4 — tier routing + Authority pipeline + citation/imagery gates (#181, v0.31.0)

Authority_Pipeline orchestrator (research→curate→draft→editorial→seo→publish gate) + per-category tier router behind master flag prautoblogger_authority_pipeline_enabled (DEFAULT OFF — zero behavior change until enabled). Cost-ceiling halts→hold; citation + imagery gates. QA APPROVE b086079; CI green (phpcs+phpunit VPS-verified).

Agent-Session: prab-phase2b-4-merge
This commit is contained in:
peptiderepo
2026-06-24 10:08:44 +07:00
committed by GitHub
parent cf276d353e
commit df83c4ebc5
13 changed files with 1256 additions and 2 deletions

View File

@@ -83,6 +83,14 @@ class PRAutoBlogger_Article_Worker {
return $result;
}
// P2b.4: tier routing — flag OFF always returns 'economy' (zero behaviour change).
$tier = ( new PRAutoBlogger_Tier_Router() )->resolve( $idea );
if ( 'authority' === $tier ) {
$pipeline = new PRAutoBlogger_Authority_Pipeline( $this->cost_tracker );
return $pipeline->run( $run_id, $idea, $this->cost_tracker );
}
// Economy path continues unchanged below...
// Initialize Opik trace context for this article generation.
$opik = $this->should_trace_with_opik();
if ( $opik ) {

View File

@@ -0,0 +1,197 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Stage execution helpers for the Authority pipeline orchestrator.
*
* What: Extracted from PRAutoBlogger_Authority_Pipeline to keep the
* orchestrator under 300 lines. Contains per-stage call wrappers,
* the imagery gate, and the hold-article helper. No external I/O
* beyond what the individual stage classes already require.
*
* Who triggers it: PRAutoBlogger_Authority_Pipeline (P2b.4).
* Dependencies: All P2b.1–P2b.3 stage classes + Content_Generator,
* Publisher, Image_Pipeline, Run_Stage_State, Audit_Writer.
*
* @see core/class-authority-pipeline.php — Orchestrator that calls these helpers.
* @see ARCHITECTURE.md — Phase 2b data flow.
*/
class PRAutoBlogger_Authority_Pipeline_Stages {
/**
* Run the research fan-out stage.
*
* @param string $run_id Pipeline 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.
* @param PRAutoBlogger_Research_Fanout $fanout Fan-out instance.
* @return array<int, array{sources: array, agent_role: string}> Per-agent results, or empty on quorum miss.
*/
public static function run_research(
string $run_id,
string $item_key,
PRAutoBlogger_Article_Idea $idea,
PRAutoBlogger_Cost_Tracker $cost_tracker,
PRAutoBlogger_Research_Fanout $fanout
): array {
PRAutoBlogger_Pipeline_Status::broadcast( __( 'Authority: running specialist research fan-out…', 'prautoblogger' ) );
PRAutoBlogger_Run_Stage_State::start( $run_id, 'research', 'researcher', $item_key );
$results = $fanout->dispatch( $run_id, $item_key, $idea, $cost_tracker );
if ( ! empty( $results ) ) {
PRAutoBlogger_Run_Stage_State::done( $run_id, 'research', 'researcher', $item_key, (string) wp_json_encode( array( 'agent_count' => count( $results ) ) ) );
} else {
PRAutoBlogger_Run_Stage_State::fail( $run_id, 'research', 'researcher', $item_key );
}
return $results;
}
/**
* Run the curate (Research_Judge) stage.
*
* @param string $run_id Pipeline run UUID.
* @param string $item_key Article-scoped item key.
* @param array $fanout_results Results from research stage.
* @param PRAutoBlogger_Research_Judge $judge Judge instance.
* @return array<int, array{url: string, title: string, quality_score?: float}> Kept sources.
*/
public static function run_curate(
string $run_id,
string $item_key,
array $fanout_results,
PRAutoBlogger_Research_Judge $judge
): array {
PRAutoBlogger_Pipeline_Status::broadcast( __( 'Authority: curating research sources…', 'prautoblogger' ) );
return $judge->curate( $run_id, $item_key, $fanout_results );
}
/**
* Run the draft (Content_Generator) stage.
*
* @param string $run_id Pipeline run UUID.
* @param string $item_key Article-scoped item key.
* @param PRAutoBlogger_Article_Idea $idea The idea to draft.
* @param PRAutoBlogger_Cost_Tracker $cost_tracker Pipeline cost tracker.
* @param PRAutoBlogger_Content_Generator|null $generator Optional override (tests).
* @return string Draft HTML content.
*/
public static function run_draft(
string $run_id,
string $item_key,
PRAutoBlogger_Article_Idea $idea,
PRAutoBlogger_Cost_Tracker $cost_tracker,
?PRAutoBlogger_Content_Generator $generator = null
): string {
PRAutoBlogger_Pipeline_Status::broadcast( __( 'Authority: generating article draft…', 'prautoblogger' ) );
if ( null === $generator ) {
$llm = new PRAutoBlogger_OpenRouter_Provider();
$generator = new PRAutoBlogger_Content_Generator( $llm, $cost_tracker );
$generator->set_run_item( $run_id, $item_key );
}
return $generator->generate( $idea );
}
/**
* Run the editorial loop stage.
*
* @param string $run_id Pipeline run UUID.
* @param string $item_key Article-scoped item key.
* @param string $content Draft HTML.
* @param PRAutoBlogger_Article_Idea $idea The idea under review.
* @param PRAutoBlogger_Cost_Tracker $cost_tracker Pipeline cost tracker.
* @param PRAutoBlogger_Editorial_Loop $editorial Editorial loop instance.
* @return array{content: string, escalated: bool} Result: content and escalation flag.
*/
public static function run_editorial(
string $run_id,
string $item_key,
string $content,
PRAutoBlogger_Article_Idea $idea,
PRAutoBlogger_Cost_Tracker $cost_tracker,
PRAutoBlogger_Editorial_Loop $editorial
): array {
PRAutoBlogger_Pipeline_Status::broadcast( __( 'Authority: running editorial loop…', 'prautoblogger' ) );
$reviewed = $editorial->run( $run_id, $item_key, $content, $idea, $cost_tracker );
return array(
'content' => '' === $reviewed ? $content : $reviewed,
'escalated' => $editorial->was_escalated(),
);
}
/**
* Run the SEO stage.
*
* @param string $run_id Pipeline run UUID.
* @param string $item_key Article-scoped item key.
* @param int $post_id Published/draft post ID.
* @param array $kept_sources Kept research sources from curate stage.
* @param array $peptide_ids Related peptide post IDs.
* @return float The computed citation_score.
*/
public static function run_seo(
string $run_id,
string $item_key,
int $post_id,
array $kept_sources,
array $peptide_ids
): float {
PRAutoBlogger_Pipeline_Status::broadcast( __( 'Authority: writing SEO meta…', 'prautoblogger' ) );
$seo_stage = new PRAutoBlogger_Seo_Stage();
return $seo_stage->run( $run_id, $item_key, $post_id, $kept_sources, $peptide_ids );
}
/**
* Hold an article as draft (citation gate, escalation, or cost ceiling).
*
* Creates the draft post and suppresses image generation on it. Writes
* a run_decisions row for the hold reason. Returns the decision verdict
* string suitable for the result 'status' key.
*
* @param string $run_id Pipeline run UUID.
* @param string $item_key Article-scoped item key.
* @param string $content Draft HTML content.
* @param PRAutoBlogger_Article_Idea $idea The article idea.
* @param string $hold_reason Short human-readable hold reason.
* @param string $verdict Decision verdict for audit row.
* @param string $run_id_for_publisher Run ID passed to Publisher.
* @return void
*/
public static function hold_as_draft(
string $run_id,
string $item_key,
string $content,
PRAutoBlogger_Article_Idea $idea,
string $hold_reason,
string $verdict,
string $run_id_for_publisher
): void {
PRAutoBlogger_Logger::instance()->info(
sprintf( 'Authority pipeline HOLD for "%s": %s', $idea->get_topic(), $hold_reason ),
'authority-pipeline'
);
// Save as draft with a neutral editorial review stub.
$publisher = new PRAutoBlogger_Publisher();
$review = new PRAutoBlogger_Editorial_Review(
array(
'verdict' => 'rejected',
'notes' => $hold_reason,
'revised_content' => null,
'quality_score' => 0.0,
'seo_score' => 0.0,
'issues' => array(),
)
);
$post_id = $publisher->save_as_draft( $content, $idea, $review, $run_id_for_publisher, null );
// Imagery gate: suppress image generation on held articles.
if ( $post_id > 0 ) {
update_post_meta( $post_id, '_prautoblogger_imagery_suppressed', '1' );
}
// Record the hold decision.
PRAutoBlogger_Audit_Writer::record_decision( $run_id, 'publish-gate', $verdict, $hold_reason );
}
}

View File

@@ -0,0 +1,273 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Authority-tier article generation pipeline orchestrator.
*
* What: Wires together P2b.1 (Research_Fanout + Research_Judge), P2b.2
* (Editorial_Loop), and P2b.3 (Seo_Stage) into the 6-stage Authority
* generation path. Gated behind the master flag
* `prautoblogger_authority_pipeline_enabled` (default FALSE via
* Tier_Router) — never runs in production until an operator opts in.
*
* Stage chain:
* 1. research — Research_Fanout::dispatch() → quorum miss → HOLD
* 2. curate — Research_Judge::curate() → kept sources
* 3. draft — Content_Generator::generate() → creates WP post
* 4. editorial — Editorial_Loop::run() → escalated → HOLD
* 5. seo — Seo_Stage::run() → citation_score
* 6. gate — citation_score >= threshold → publish; else HOLD
*
* Cost ceiling: PRAutoBlogger_Cost_Ceiling_Exception → HOLD (never
* force-complete). Per-run $0.50 ceiling inherited from Cost_Governor.
*
* Who triggers it: PRAutoBlogger_Article_Worker::generate() when tier='authority'.
* Dependencies: All P2b.1–P2b.3 stage classes, Publisher, Cost_Governor,
* Run_Stage_State, Audit_Writer, Image_Pipeline (via Post_Assembler).
*
* @see providers/interface-authority-pipeline.php — Interface.
* @see core/class-authority-pipeline-stages.php — Stage helpers (split for 300-line rule).
* @see core/class-tier-router.php — Routes here on 'authority'.
* @see core/class-article-worker.php — Economy fallback path.
* @see ARCHITECTURE.md — Phase 2b data flow.
*/
class PRAutoBlogger_Authority_Pipeline implements PRAutoBlogger_Authority_Pipeline_Interface {
/** Option key for the citation score publish gate threshold. */
public const OPTION_THRESHOLD = 'prautoblogger_citation_score_threshold';
/** Default threshold: 0.0 — gate always passes until calibrated. */
public const DEFAULT_THRESHOLD = 0.0;
/** @var PRAutoBlogger_Cost_Tracker Pipeline cost tracker. */
private PRAutoBlogger_Cost_Tracker $cost_tracker;
/** @var PRAutoBlogger_Research_Fanout|null Injected for tests. */
private ?PRAutoBlogger_Research_Fanout $fanout;
/** @var PRAutoBlogger_Research_Judge|null Injected for tests. */
private ?PRAutoBlogger_Research_Judge $judge;
/** @var PRAutoBlogger_Editorial_Loop|null Injected for tests. */
private ?PRAutoBlogger_Editorial_Loop $editorial;
/** @var PRAutoBlogger_Content_Generator|null Injected for tests. */
private ?PRAutoBlogger_Content_Generator $generator;
/**
* @param PRAutoBlogger_Cost_Tracker $cost_tracker Pipeline cost tracker.
* @param PRAutoBlogger_Research_Fanout|null $fanout Optional fan-out override (tests).
* @param PRAutoBlogger_Research_Judge|null $judge Optional judge override (tests).
* @param PRAutoBlogger_Editorial_Loop|null $editorial Optional editorial loop override (tests).
* @param PRAutoBlogger_Content_Generator|null $generator Optional generator override (tests).
*/
public function __construct(
PRAutoBlogger_Cost_Tracker $cost_tracker,
?PRAutoBlogger_Research_Fanout $fanout = null,
?PRAutoBlogger_Research_Judge $judge = null,
?PRAutoBlogger_Editorial_Loop $editorial = null,
?PRAutoBlogger_Content_Generator $generator = null
) {
$this->cost_tracker = $cost_tracker;
$this->fanout = $fanout;
$this->judge = $judge;
$this->editorial = $editorial;
$this->generator = $generator;
}
/**
* Run the full Authority-tier pipeline for one article.
*
* Catches PRAutoBlogger_Cost_Ceiling_Exception at the top level and
* converts it to a HOLD (never a force-complete). All other exceptions
* propagate to the Article_Worker's outer Throwable catch (which logs
* + marks open stages as failed).
*
* Side effects: LLM API calls, DB writes, WP post creation, imagery
* gate post-meta, audit rows (run_stages + run_decisions), Logger calls.
*
* @param string $run_id Unique run identifier.
* @param PRAutoBlogger_Article_Idea $idea The article idea to process.
* @param PRAutoBlogger_Cost_Tracker $cost_tracker Active cost tracker for this run.
* @return array{generated: int, published: int, rejected: int, cost: float, status: string}
*/
public function run(
string $run_id,
PRAutoBlogger_Article_Idea $idea,
PRAutoBlogger_Cost_Tracker $cost_tracker
): array {
$item_key = PRAutoBlogger_Run_Stage_State::item_key_for_idea( $idea );
$result = array(
'generated' => 0,
'published' => 0,
'rejected' => 0,
'cost' => 0.0,
'status' => 'pending',
);
try {
$result = $this->execute_pipeline( $run_id, $item_key, $idea, $cost_tracker, $result );
} catch ( PRAutoBlogger_Cost_Ceiling_Exception $e ) {
// Ceiling breach: hold the article as draft — never force-complete.
PRAutoBlogger_Authority_Pipeline_Stages::hold_as_draft(
$run_id,
$item_key,
'<p>[Article generation halted by per-run cost ceiling.]</p>',
$idea,
$e->getMessage(),
'halted',
$run_id
);
$result['status'] = 'halted';
PRAutoBlogger_Logger::instance()->error(
sprintf( 'Authority pipeline halted for "%s" (cost ceiling): %s', $idea->get_topic(), $e->getMessage() ),
'authority-pipeline'
);
}
$result['cost'] = $cost_tracker->get_current_run_cost();
return $result;
}
// ── Private pipeline body ────────────────────────────────────────────
/**
* Execute the 6-stage pipeline, returning the result array.
*
* Throws PRAutoBlogger_Cost_Ceiling_Exception on budget breach —
* caller (run()) converts to HOLD.
*
* @param string $run_id Run UUID.
* @param string $item_key Article-scoped item key.
* @param PRAutoBlogger_Article_Idea $idea The article idea.
* @param PRAutoBlogger_Cost_Tracker $cost_tracker Pipeline cost tracker.
* @param array $result Result accumulator.
* @return array{generated: int, published: int, rejected: int, cost: float, status: string}
* @throws PRAutoBlogger_Cost_Ceiling_Exception When the cost ceiling is breached.
*/
private function execute_pipeline(
string $run_id,
string $item_key,
PRAutoBlogger_Article_Idea $idea,
PRAutoBlogger_Cost_Tracker $cost_tracker,
array $result
): array {
// Stage 1: Research fan-out.
$fanout = $this->fanout ?? new PRAutoBlogger_Research_Fanout();
$fan_results = PRAutoBlogger_Authority_Pipeline_Stages::run_research( $run_id, $item_key, $idea, $cost_tracker, $fanout );
if ( empty( $fan_results ) ) {
PRAutoBlogger_Authority_Pipeline_Stages::hold_as_draft(
$run_id,
$item_key,
'',
$idea,
'Research quorum not met — holding for re-run.',
'quorum-miss',
$run_id
);
$result['status'] = 'held-quorum';
return $result;
}
// Stage 2: Curate.
$judge = $this->judge ?? new PRAutoBlogger_Research_Judge();
$kept_sources = PRAutoBlogger_Authority_Pipeline_Stages::run_curate( $run_id, $item_key, $fan_results, $judge );
// Stage 3: Draft.
$draft_content = PRAutoBlogger_Authority_Pipeline_Stages::run_draft( $run_id, $item_key, $idea, $cost_tracker, $this->generator );
$result['generated'] = 1;
// Stage 4: Editorial loop.
$llm = new PRAutoBlogger_OpenRouter_Provider();
$chief_editor = new PRAutoBlogger_Chief_Editor( $llm, $cost_tracker );
$rev_caller = new PRAutoBlogger_Editorial_Revision_Caller( $llm, $cost_tracker );
$editorial = $this->editorial ?? new PRAutoBlogger_Editorial_Loop( $chief_editor, $rev_caller );
$editorial_out = PRAutoBlogger_Authority_Pipeline_Stages::run_editorial(
$run_id,
$item_key,
$draft_content,
$idea,
$cost_tracker,
$editorial
);
$final_content = $editorial_out['content'];
$escalated = $editorial_out['escalated'];
if ( $escalated ) {
PRAutoBlogger_Authority_Pipeline_Stages::hold_as_draft(
$run_id,
$item_key,
$final_content,
$idea,
'Editorial loop exhausted max rounds — held for human review.',
'escalated',
$run_id
);
$result['status'] = 'held-escalated';
return $result;
}
// Stage 3b: Save draft (needed for SEO stage and citation gate).
$publisher = new PRAutoBlogger_Publisher();
$stub_review = new PRAutoBlogger_Editorial_Review(
array(
'verdict' => 'approved',
'notes' => 'Authority pipeline editorial approved.',
'revised_content' => null,
'quality_score' => 0.85,
'seo_score' => 0.80,
'issues' => array(),
)
);
$auto_publish = in_array( get_option( 'prautoblogger_auto_publish', '0' ), array( '1', 'yes' ), true );
$post_id = $publisher->save_as_draft( $final_content, $idea, $stub_review, $run_id, null );
PRAutoBlogger_Run_Stage_State::start( $run_id, 'publish', (string) PRAutoBlogger_Stage_Display_Map::default_agent_role( 'publish' ), $item_key );
// Stage 5: SEO.
$citation_score = PRAutoBlogger_Authority_Pipeline_Stages::run_seo( $run_id, $item_key, $post_id, $kept_sources, array() );
// Stage 6: Publish gate.
$threshold = (float) get_option( self::OPTION_THRESHOLD, self::DEFAULT_THRESHOLD );
if ( $citation_score >= $threshold && ! $escalated ) {
if ( $auto_publish ) {
wp_publish_post( $post_id );
// Image pipeline for published articles.
PRAutoBlogger_Post_Assembler::attach_generated_images(
$post_id,
$idea,
array(
'post_title' => $idea->get_suggested_title(),
'post_content' => $final_content,
),
$cost_tracker
);
$result['published'] = 1;
}
PRAutoBlogger_Run_Stage_State::done( $run_id, 'publish', '', $item_key );
PRAutoBlogger_Audit_Writer::record_decision(
$run_id,
'publish-gate',
'approved',
sprintf( 'citation_score=%.4f >= threshold=%.4f', $citation_score, $threshold ),
$citation_score
);
$result['status'] = 'published';
} else {
// Citation gate: hold (imagery suppressed).
update_post_meta( $post_id, '_prautoblogger_imagery_suppressed', '1' );
PRAutoBlogger_Run_Stage_State::done( $run_id, 'publish', '', $item_key );
PRAutoBlogger_Audit_Writer::record_decision(
$run_id,
'publish-gate',
'held',
sprintf( 'citation_score=%.4f < threshold=%.4f — held as draft.', $citation_score, $threshold ),
$citation_score
);
$result['status'] = 'held-citation';
}
return $result;
}
}

View File

@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Per-category generation tier router for the Authority pipeline.
*
* What: Resolves whether a given article idea should enter the Authority
* pipeline or fall through to the Economy single-pass. The master
* switch (`prautoblogger_authority_pipeline_enabled`) defaults FALSE,
* which guarantees byte-identical behaviour to today — no articles
* ever touch the Authority path unless an operator explicitly enables
* the flag. When the flag is ON, per-category overrides in
* `prautoblogger_category_tiers` demote specific categories to
* Economy; every other category (unclassified, new, missing) stays
* at the Authority default.
*
* Who triggers it: PRAutoBlogger_Article_Worker::generate() (P2b.4).
* Dependencies: WordPress get_option(), PRAutoBlogger_Article_Idea.
*
* @see providers/interface-tier-router.php — Interface this class implements.
* @see core/class-article-worker.php — Call site (3-line tier check).
* @see core/class-authority-pipeline.php — Authority path executed on 'authority'.
* @see ARCHITECTURE.md — Phase 2b tier routing.
*/
class PRAutoBlogger_Tier_Router implements PRAutoBlogger_Tier_Router_Interface {
/** Option key for the master Authority pipeline feature flag. */
public const OPTION_MASTER_FLAG = 'prautoblogger_authority_pipeline_enabled';
/** Option key for the per-category tier map (serialized array). */
public const OPTION_CATEGORY_TIERS = 'prautoblogger_category_tiers';
/** Default tier when a category has no explicit override: Authority. */
private const DEFAULT_TIER = 'authority';
/**
* Resolve the generation tier for an article idea.
*
* Returns 'economy' immediately when the master flag is OFF — this is
* the production default and must guarantee zero behaviour change
* relative to the pre-P2b.4 codebase.
*
* When the master flag is ON, looks up the idea's article_type in the
* per-category tier map. Values of 'economy' explicitly demote that
* category. Any other value, or no entry at all, keeps the Authority
* default.
*
* Side effects: two get_option() calls (cached by WordPress object cache).
*
* @param PRAutoBlogger_Article_Idea $idea The article idea to route.
* @return string 'authority' or 'economy'
*/
public function resolve( PRAutoBlogger_Article_Idea $idea ): string {
if ( ! $this->is_master_flag_on() ) {
return 'economy';
}
return $this->resolve_from_category( $idea->get_article_type() );
}
// ── Private helpers ──────────────────────────────────────────────────
/**
* Whether the Authority pipeline master switch is enabled.
*
* The option defaults to false (disabled). Operators must explicitly
* set it to '1' or true via wp_options / WP admin to enable the path.
*
* @return bool
*/
private function is_master_flag_on(): bool {
$flag = get_option( self::OPTION_MASTER_FLAG, false );
return in_array( $flag, array( '1', true, 1 ), true );
}
/**
* Resolve tier from the per-category tier map.
*
* The map is a serialized PHP array (stored via WordPress options) of
* `['category_slug' => 'economy']`. Only 'economy' is a meaningful
* explicit value — anything else (including 'authority') is treated
* as "keep the default".
*
* Unrecognised or empty maps → default Authority tier (additive safety).
*
* @param string $article_type Article type / category slug from the idea.
* @return string 'authority' or 'economy'
*/
private function resolve_from_category( string $article_type ): string {
$raw = get_option( self::OPTION_CATEGORY_TIERS, array() );
$map = is_array( $raw ) ? $raw : array();
$tier = $map[ $article_type ] ?? self::DEFAULT_TIER;
// Only 'economy' is a valid explicit demote; any other value defaults.
return 'economy' === $tier ? 'economy' : 'authority';
}
}