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:
@@ -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 ) {
|
||||
|
||||
197
includes/core/class-authority-pipeline-stages.php
Normal file
197
includes/core/class-authority-pipeline-stages.php
Normal 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 );
|
||||
}
|
||||
}
|
||||
273
includes/core/class-authority-pipeline.php
Normal file
273
includes/core/class-authority-pipeline.php
Normal 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;
|
||||
}
|
||||
}
|
||||
100
includes/core/class-tier-router.php
Normal file
100
includes/core/class-tier-router.php
Normal 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';
|
||||
}
|
||||
}
|
||||
41
includes/providers/interface-authority-pipeline.php
Normal file
41
includes/providers/interface-authority-pipeline.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
/**
|
||||
* Authority Pipeline Interface
|
||||
*
|
||||
* Contract for the full Authority-tier article generation pipeline.
|
||||
*
|
||||
* @package PRAutoBlogger
|
||||
* @since 0.31.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full Authority-tier article generation pipeline.
|
||||
*
|
||||
* Implementations must orchestrate: research → curate → draft →
|
||||
* editorial → seo → publish gate, with cost-governor reserve/settle
|
||||
* on every stage and cost-ceiling halt → HOLD semantics.
|
||||
*
|
||||
* @see core/class-authority-pipeline.php — Production implementation.
|
||||
*/
|
||||
interface PRAutoBlogger_Authority_Pipeline_Interface {
|
||||
|
||||
/**
|
||||
* Run the full Authority-tier pipeline for one article.
|
||||
*
|
||||
* @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} Pipeline result.
|
||||
*/
|
||||
public function run(
|
||||
string $run_id,
|
||||
PRAutoBlogger_Article_Idea $idea,
|
||||
PRAutoBlogger_Cost_Tracker $cost_tracker
|
||||
): array;
|
||||
}
|
||||
35
includes/providers/interface-tier-router.php
Normal file
35
includes/providers/interface-tier-router.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
/**
|
||||
* Tier Router Interface
|
||||
*
|
||||
* Contract for resolving the generation tier (authority / economy) for an article idea.
|
||||
*
|
||||
* @package PRAutoBlogger
|
||||
* @since 0.31.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves Authority vs Economy generation tier for a given article idea.
|
||||
*
|
||||
* Implementations read the per-category tier map and the master feature flag.
|
||||
* When the master flag is OFF, resolve() MUST return 'economy' unconditionally
|
||||
* to guarantee zero behaviour change in production.
|
||||
*
|
||||
* @see core/class-tier-router.php — Production implementation.
|
||||
*/
|
||||
interface PRAutoBlogger_Tier_Router_Interface {
|
||||
|
||||
/**
|
||||
* Resolve the generation tier for an article idea.
|
||||
*
|
||||
* @param PRAutoBlogger_Article_Idea $idea The article idea to route.
|
||||
* @return string 'authority' or 'economy'
|
||||
*/
|
||||
public function resolve( PRAutoBlogger_Article_Idea $idea ): string;
|
||||
}
|
||||
Reference in New Issue
Block a user