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

@@ -229,6 +229,9 @@ prautoblogger/
│ │ ├── class-editorial-loop.php # P2b.2: bounded editorial loop — editor↔writer ≤editorial_max_rounds — Authority only (v0.29.0) │ │ ├── class-editorial-loop.php # P2b.2: bounded editorial loop — editor↔writer ≤editorial_max_rounds — Authority only (v0.29.0)
│ │ ├── class-editorial-revision-caller.php # P2b.2: writer revision LLM step (extracted from editorial-loop for 300-line rule) (v0.29.0) │ │ ├── class-editorial-revision-caller.php # P2b.2: writer revision LLM step (extracted from editorial-loop for 300-line rule) (v0.29.0)
│ │ ├── class-seo-stage.php # P2b.3: SEO stage — writes _prab_* post-meta (JSON-LD contract) + computes citation_score — Authority only (v0.30.0) │ │ ├── class-seo-stage.php # P2b.3: SEO stage — writes _prab_* post-meta (JSON-LD contract) + computes citation_score — Authority only (v0.30.0)
# P2b.4: class-authority-pipeline.php Authority pipeline orchestrator 6-stage (v0.31.0)
# P2b.4: class-authority-pipeline-stages.php stage helpers (v0.31.0)
# P2b.4: class-tier-router.php per-category tier router (v0.31.0)
│ │ ├── class-audit-writer.php # run_sources / run_decisions insert layer │ │ ├── class-audit-writer.php # run_sources / run_decisions insert layer
│ │ ├── class-pipeline-status.php # Status-transient + summary helpers (extracted from runner/worker) │ │ ├── class-pipeline-status.php # Status-transient + summary helpers (extracted from runner/worker)
│ │ ├── class-logger.php # Structured logging singleton (error/warning/info/debug) │ │ ├── class-logger.php # Structured logging singleton (error/warning/info/debug)
@@ -250,6 +253,8 @@ prautoblogger/
│ │ ├── interface-research-judge.php # P2b.1: contract for the curate stage judge (v0.28.0) │ │ ├── interface-research-judge.php # P2b.1: contract for the curate stage judge (v0.28.0)
│ │ ├── interface-editorial-loop.php # P2b.2: contract for bounded editorial loop (v0.29.0) │ │ ├── interface-editorial-loop.php # P2b.2: contract for bounded editorial loop (v0.29.0)
│ │ ├── interface-seo-stage.php # P2b.3: contract for the SEO stage (v0.30.0) │ │ ├── interface-seo-stage.php # P2b.3: contract for the SEO stage (v0.30.0)
# P2b.4: interface-tier-router.php contract for tier router (v0.31.0)
# P2b.4: interface-authority-pipeline.php contract for Authority pipeline (v0.31.0)
│ │ ├── class-reddit-provider.php # Reddit data collection orchestrator (RSS primary) │ │ ├── class-reddit-provider.php # Reddit data collection orchestrator (RSS primary)
│ │ ├── interface-image-provider.php # Contract for any image generation provider (incl. batch) │ │ ├── interface-image-provider.php # Contract for any image generation provider (incl. batch)
│ │ ├── class-open-router-image-provider.php # OpenRouter image gen (single + batch dispatch) │ │ ├── class-open-router-image-provider.php # OpenRouter image gen (single + batch dispatch)
@@ -696,6 +701,8 @@ All prefixed with `prautoblogger_`:
| `prautoblogger_board_published_window_days` | Days back to show in the Published column (default 7). | | `prautoblogger_board_published_window_days` | Days back to show in the Published column (default 7). |
| `prautoblogger_editorial_max_rounds` | Authority-tier editorial loop: max editor↔writer rounds before Review Queue escalation (int, default 3, range 1–10). P2b.2 (v0.29.0). | | `prautoblogger_editorial_max_rounds` | Authority-tier editorial loop: max editor↔writer rounds before Review Queue escalation (int, default 3, range 1–10). P2b.2 (v0.29.0). |
| `prautoblogger_citation_score_threshold` | Authority-tier SEO stage: minimum citation_score to pass the publish gate (float, default 0.0 — intentionally uncalibrated; calibrate after first ~10 Authority runs). P2b.3 (v0.30.0). | | `prautoblogger_citation_score_threshold` | Authority-tier SEO stage: minimum citation_score to pass the publish gate (float, default 0.0 — intentionally uncalibrated; calibrate after first ~10 Authority runs). P2b.3 (v0.30.0). |
| `prautoblogger_authority_pipeline_enabled` | Master switch for Authority-tier pipeline (bool, default false). OFF = all generation uses Economy path (zero behaviour change). P2b.4 v0.31.0. |
| `prautoblogger_category_tiers` | Per-category tier overrides: serialized array [category_slug => economy]. Economy is an explicit demote; default is Authority. P2b.4 v0.31.0. |
### Post Meta ### Post Meta
@@ -723,6 +730,7 @@ Stored on every PRAutoBlogger-generated post:
| `_prab_review_mode` | `editorial-system` (automated SEO stage) or `human` (set in P2b.4 on Review Queue approval). | | `_prab_review_mode` | `editorial-system` (automated SEO stage) or `human` (set in P2b.4 on Review Queue approval). |
| `_prab_reviewed_at` | ISO 8601 datetime of when the SEO stage ran (P2b.3 v0.30.0). | | `_prab_reviewed_at` | ISO 8601 datetime of when the SEO stage ran (P2b.3 v0.30.0). |
| `_prab_citation_score` | Float (stored as string): average quality_score of kept sources. Publish gate in P2b.4. | | `_prab_citation_score` | Float (stored as string): average quality_score of kept sources. Publish gate in P2b.4. |
| `_prautoblogger_imagery_suppressed` | Set to 1 on articles held by citation gate or editorial escalation (P2b.4 v0.31.0). Suppresses image generation until human-approved. |
Composed-variant **attachment** meta (v0.17.0): every pipeline attachment gets Composed-variant **attachment** meta (v0.17.0): every pipeline attachment gets
`_prautoblogger_image_role` (`featured`/`og`/`square`); OG/square variants also `_prautoblogger_image_role` (`featured`/`og`/`square`); OG/square variants also

View File

@@ -5,6 +5,27 @@ All notable changes to PRAutoBlogger will be documented in this file.
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project uses [Semantic Versioning](https://semver.org/). and this project uses [Semantic Versioning](https://semver.org/).
## [0.31.0] - 2026-06-24
### Added
- **P2b.4 — tier routing + Authority pipeline orchestrator + citation/imagery gates**
- `PRAutoBlogger_Tier_Router` (`core/class-tier-router.php`, 100 lines): resolves Authority vs Economy per-category. Master switch `prautoblogger_authority_pipeline_enabled` defaults FALSE — flag OFF means ALL generation uses the Economy single-pass path, byte-identical to pre-P2b.4.
- `PRAutoBlogger_Tier_Router_Interface` (`providers/interface-tier-router.php`).
- `PRAutoBlogger_Authority_Pipeline` (`core/class-authority-pipeline.php`, 247 lines): 6-stage orchestrator: research -> curate -> draft -> editorial -> seo -> publish gate. Cost-governor reserve/settle on every stage; `PRAutoBlogger_Cost_Ceiling_Exception` -> HOLD as draft (never force-complete). Citation gate: `citation_score >= threshold` to publish (default threshold 0.0, gate always passes until calibrated).
- `PRAutoBlogger_Authority_Pipeline_Stages` (`core/class-authority-pipeline-stages.php`, 195 lines): stage helpers extracted for 300-line rule.
- `PRAutoBlogger_Authority_Pipeline_Interface` (`providers/interface-authority-pipeline.php`).
- **Article_Worker wired**: 3-line tier check before Economy path. Economy path unchanged.
- **Imagery gate**: held articles get `_prautoblogger_imagery_suppressed = 1`; image pipeline skipped. Published articles proceed normally.
- New options: `prautoblogger_authority_pipeline_enabled` (bool, default false), `prautoblogger_category_tiers` (serialized array, default empty = all Authority).
- PHPUnit: `TierRouterTest` (6 tests) + `AuthorityPipelineTest` (6 tests). All 544 tests GREEN on VPS PHP 8.3.
### Updated
- `ARCHITECTURE.md` — new files, options, post-meta, generation flow note (additions only).
- `CONVENTIONS.md` — tier routing + master flag pattern.
- `CONTEXT.md` — P2b.4 glossary section.
- `prautoblogger.php` — version bumped to 0.31.0.
- `uninstall.php` — new options covered by existing wildcard; `_prab_*` purge already present.
## [0.30.0] - 2026-06-24 ## [0.30.0] - 2026-06-24
### Fixed (QA P1/P2 sweep — post-a39fb75) ### Fixed (QA P1/P2 sweep — post-a39fb75)

View File

@@ -210,3 +210,15 @@ into the live Economy path until P2b.4).
| **_prab_reviewed_by** | WP user ID of the Review Queue approver. **NOT written by P2b.3**. Set exclusively by P2b.4 on human approval. | | **_prab_reviewed_by** | WP user ID of the Review Queue approver. **NOT written by P2b.3**. Set exclusively by P2b.4 on human approval. |
| **_prab_citations** | JSON-encoded array of kept research sources: `[{url, title, doi?, quality_score?}]`. Derived from the `$kept_sources` array passed by the curate stage. | | **_prab_citations** | JSON-encoded array of kept research sources: `[{url, title, doi?, quality_score?}]`. Derived from the `$kept_sources` array passed by the curate stage. |
| **_prab_about_peptides** | JSON-encoded array of related peptide post IDs. Populated from P2b.4 peptide linkage; defaults to `[]` in P2b.3 (the SEO stage accepts the array as a parameter for future-compatibility). | | **_prab_about_peptides** | JSON-encoded array of related peptide post IDs. Populated from P2b.4 peptide linkage; defaults to `[]` in P2b.3 (the SEO stage accepts the array as a parameter for future-compatibility). |
## P2b.4 Glossary (Tier Router + Authority Pipeline Orchestrator)
| Term | Definition |
|------|-----------|
| **Tier routing** | The process of routing an article idea to Authority or Economy (single-pass) path. Implemented in `PRAutoBlogger_Tier_Router::resolve()`. |
| **Master flag** | `prautoblogger_authority_pipeline_enabled` (default `false`). When OFF, ALL generation uses Economy path — zero behaviour change from pre-P2b.4. Must be explicitly enabled by an operator. |
| **Category tier map** | `prautoblogger_category_tiers` — serialized array `[category_slug => economy]`. Only `economy` is a meaningful explicit demote; unclassified categories default to Authority. |
| **Authority Pipeline** | `PRAutoBlogger_Authority_Pipeline` — 6-stage orchestrator: research -> curate -> draft -> editorial -> seo -> publish gate. Wires together P2b.1+P2b.2+P2b.3 subsystems. |
| **Citation gate** | Publish gate in the Authority pipeline: `citation_score >= threshold` to publish; else hold as draft with imagery suppressed. Default threshold `0.0` (gate always passes until calibrated). |
| **Imagery gate** | `_prautoblogger_imagery_suppressed = 1` written on held articles. Signals image pipeline to skip generation. Not set on published articles. |
| **Cost ceiling halt** | `PRAutoBlogger_Cost_Ceiling_Exception` caught at Authority pipeline top level. Article saved as draft with status `halted`; never force-completed. |

View File

@@ -828,3 +828,37 @@ post-meta writes and DB audit rows. It is the contract point between PRAB (write
| `_prab_reviewed_at` | string | ISO 8601 datetime | | `_prab_reviewed_at` | string | ISO 8601 datetime |
| `_prab_reviewed_by` | int | WP user ID — written only by P2b.4 on human approval | | `_prab_reviewed_by` | int | WP user ID — written only by P2b.4 on human approval |
| `_prab_citation_score` | string (float) | Avg quality_score of kept sources; publish gate in P2b.4 | | `_prab_citation_score` | string (float) | Avg quality_score of kept sources; publish gate in P2b.4 |
---
## Tier Routing + Master Flag Pattern (P2b.4, v0.31.0)
### Design rules
The master flag `prautoblogger_authority_pipeline_enabled` (default `false`) gates
the entire Authority pipeline. When OFF, `PRAutoBlogger_Tier_Router::resolve()` MUST
return `economy` unconditionally — no category-map reads, no secondary checks. This
guarantees zero behaviour change in production on deploy.
### Key patterns
- **Master flag check first**: always the first branch in `resolve()`. If false, return
economy immediately without reading the category map.
- **Economy is the safe default**: if the category map is empty, absent, or corrupt,
the tier defaults to authority (the additive default when flag is ON); but the flag
being OFF is the primary safety gate.
- **Per-category demotion**: the `prautoblogger_category_tiers` option stores a
serialized PHP array `[category_slug => economy]`. Only economy is a meaningful
explicit value — other values fall through to the Authority default.
- **Article_Worker integration**: three-line check before the Economy path:
```php
$tier = ( new PRAutoBlogger_Tier_Router() )->resolve( $idea );
if ( authority === $tier ) {
return ( new PRAutoBlogger_Authority_Pipeline( $cost_tracker ) )->run( $run_id, $idea, $cost_tracker );
}
```
The Economy path below the check is UNCHANGED — the branch is additive-only.
- **Imagery gate**: held articles (citation gate miss, escalation, or cost ceiling) get
`_prautoblogger_imagery_suppressed = 1` post-meta. Image pipeline checks this key
before running. Publish-gate-passed articles never have this key set.

View File

@@ -83,6 +83,14 @@ class PRAutoBlogger_Article_Worker {
return $result; 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. // Initialize Opik trace context for this article generation.
$opik = $this->should_trace_with_opik(); $opik = $this->should_trace_with_opik();
if ( $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';
}
}

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

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

View File

@@ -9,7 +9,7 @@
* Plugin Name: PRAutoBlogger * Plugin Name: PRAutoBlogger
* Plugin URI: https://peptiderepo.com/prautoblogger * Plugin URI: https://peptiderepo.com/prautoblogger
* Description: Monitors social media for trending topics, generates SEO-friendly blog posts using AI, and publishes them on a daily schedule with full cost tracking and self-improvement metrics. * Description: Monitors social media for trending topics, generates SEO-friendly blog posts using AI, and publishes them on a daily schedule with full cost tracking and self-improvement metrics.
* Version: 0.30.0 * Version: 0.31.0
* Requires at least: 6.0 * Requires at least: 6.0
* Requires PHP: 7.4 * Requires PHP: 7.4
* Author: PeptideRepo * Author: PeptideRepo
@@ -35,7 +35,7 @@ if ( ! defined( 'ABSPATH' ) ) {
| Defined here so every file in the plugin can reference paths, versions, | Defined here so every file in the plugin can reference paths, versions,
| and limits without magic strings. | and limits without magic strings.
*/ */
define( 'PRAUTOBLOGGER_VERSION', '0.30.0' ); define( 'PRAUTOBLOGGER_VERSION', '0.31.0' );
define( 'PRAUTOBLOGGER_DB_VERSION', '1.4.0' ); // No schema change in v0.30.0 (Seo_Stage is additive code only). define( 'PRAUTOBLOGGER_DB_VERSION', '1.4.0' ); // No schema change in v0.30.0 (Seo_Stage is additive code only).
define( 'PRAUTOBLOGGER_DEFAULT_RUN_CEILING_USD', 0.50 ); define( 'PRAUTOBLOGGER_DEFAULT_RUN_CEILING_USD', 0.50 );
define( 'PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS', 14 ); define( 'PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS', 14 );

View File

@@ -0,0 +1,402 @@
<?php
/**
* Tests for PRAutoBlogger_Authority_Pipeline and tier routing integration.
*
* Covers: Economy path taken when flag OFF (proven via TierRouter unit tests),
* cost ceiling halt -> draft hold, citation gate (below/at threshold),
* imagery suppressed on held articles, and stage order via mock calls.
*
* All dependencies that would make HTTP calls are mocked or stubbed.
*
* @package PRAutoBlogger\Tests\Core
*/
namespace PRAutoBlogger\Tests\Core;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class AuthorityPipelineTest extends BaseTestCase {
/** @var object Mock $wpdb. */
private object $wpdb;
/** @var array Rows inserted to run_decisions. */
private array $decision_rows = array();
/** @var array Post meta written. */
private array $meta_written = array();
/** @var array Posts that had wp_publish_post() called. */
private array $published_posts = array();
protected function setUp(): void {
parent::setUp();
$this->decision_rows = array();
$this->meta_written = array();
$this->published_posts = array();
$this->wpdb = $this->create_mock_wpdb();
$GLOBALS["wpdb"] = $this->wpdb;
$this->wpdb->prefix = "wp_";
$this->wpdb->method( "prepare" )->willReturnCallback(
static fn( $sql, ...$args ) => $sql . " /* " . implode( ",", array_map( "strval", $args ) ) . " */"
);
$test = $this;
$this->wpdb->method( "insert" )->willReturnCallback(
function ( $table, $data ) use ( $test ) {
if ( false !== strpos( $table, "run_decisions" ) ) {
$test->decision_rows[] = $data;
}
return 1;
}
);
$this->wpdb->method( "get_var" )->willReturnCallback(
static function ( $sql ) {
if ( false !== strpos( $sql, "SHOW TABLES" ) ) {
return "wp_prautoblogger_run_decisions";
}
return null;
}
);
$this->wpdb->method( "query" )->willReturn( 1 );
$this->wpdb->method( "get_row" )->willReturn( null );
$this->wpdb->method( "get_col" )->willReturn( array() );
\PRAutoBlogger_Audit_Writer::flush_cache();
\PRAutoBlogger_Run_Stage_State::flush_cache();
// Common WP function stubs.
Functions\when( "current_time" )->justReturn( "2026-06-24 00:00:00" );
Functions\when( "wp_json_encode" )->alias( "json_encode" );
Functions\when( "gmdate" )->alias( "gmdate" );
Functions\when( "__" )->alias( static fn( $s ) => $s );
Functions\when( "sanitize_text_field" )->alias( static fn( $s ) => $s );
Functions\when( "wp_kses_post" )->alias( static fn( $s ) => $s );
Functions\when( "wp_strip_all_tags" )->alias( "strip_tags" );
Functions\when( "apply_filters" )->alias( static fn( $tag, $val ) => $val );
Functions\when( "do_action" )->justReturn( null );
Functions\when( "wp_insert_post" )->justReturn( 42 );
Functions\when( "wp_set_post_terms" )->justReturn( array() );
Functions\when( "wp_set_post_categories" )->justReturn( array() );
Functions\when( "wp_set_post_tags" )->justReturn( array() );
Functions\when( "post_type_exists" )->justReturn( false );
Functions\when( "get_users" )->justReturn( array( 1 ) );
Functions\when( "get_term_by" )->justReturn( false );
Functions\when( "wp_insert_term" )->justReturn( array( "term_id" => 1 ) );
Functions\when( "absint" )->alias( "intval" );
Functions\when( "is_wp_error" )->justReturn( false );
Functions\when( "get_post_meta" )->justReturn( "" );
$test = $this;
Functions\when( "wp_publish_post" )->alias(
function ( $id ) use ( $test ) {
$test->published_posts[] = $id;
}
);
Functions\when( "update_post_meta" )->alias(
function ( $post_id, $key, $value ) use ( $test ) {
$test->meta_written[ $key ] = $value;
return true;
}
);
$this->stub_get_option( array(
"prautoblogger_log_level" => "error",
"prautoblogger_auto_publish" => "0",
"prautoblogger_citation_score_threshold" => 0.0,
"prautoblogger_writing_pipeline" => "single_pass",
"prautoblogger_writing_model" => "openai/gpt-4o-mini",
"prautoblogger_default_author" => 1,
) );
}
protected function tearDown(): void {
\PRAutoBlogger_Audit_Writer::flush_cache();
\PRAutoBlogger_Run_Stage_State::flush_cache();
unset( $GLOBALS["wpdb"] );
parent::tearDown();
}
// -- Cost ceiling halt ------------------------------------------------
/**
* When the research stage throws PRAutoBlogger_Cost_Ceiling_Exception,
* the pipeline must HOLD the article as draft (status="halted") and
* must NOT force-complete or publish it.
*/
public function test_cost_ceiling_halt_holds_article(): void {
$fanout = $this->make_fanout_throwing_ceiling();
$tracker = $this->make_cost_tracker_stub();
$pipeline = new \PRAutoBlogger_Authority_Pipeline( $tracker, $fanout );
$result = $pipeline->run( "run-ceiling", $this->make_idea(), $tracker );
$this->assertSame( "halted", $result["status"] );
$this->assertSame( 0, $result["published"] );
$this->assertEmpty( $this->published_posts );
}
// -- Citation gate ----------------------------------------------------
/**
* When citation_score (computed by Seo_Stage from kept_sources) is below
* the threshold, the article must be held as draft, not published.
* auto_publish=1 is set so the only thing blocking publish is the gate.
*/
public function test_citation_gate_holds_below_threshold(): void {
$this->stub_get_option( array(
"prautoblogger_log_level" => "error",
"prautoblogger_auto_publish" => "1",
"prautoblogger_citation_score_threshold" => 0.8,
"prautoblogger_writing_pipeline" => "single_pass",
"prautoblogger_writing_model" => "openai/gpt-4o-mini",
"prautoblogger_default_author" => 1,
) );
$low_quality_sources = array(
array( "url" => "https://a.com", "title" => "A", "quality_score" => 0.3 ),
);
$fanout = $this->make_fanout_stub( $this->make_fanout_results( $low_quality_sources ) );
$judge = $this->make_judge_stub( $low_quality_sources );
$loop = $this->make_editorial_loop_stub( "<p>approved content</p>", false );
$generator = $this->make_generator_stub( "<p>draft content</p>" );
$tracker = $this->make_cost_tracker_stub();
$pipeline = new \PRAutoBlogger_Authority_Pipeline( $tracker, $fanout, $judge, $loop, $generator );
$result = $pipeline->run( "run-citation-low", $this->make_idea(), $tracker );
$this->assertSame( "held-citation", $result["status"] );
$this->assertSame( 0, $result["published"] );
$this->assertEmpty( $this->published_posts );
$this->assertArrayHasKey( "_prautoblogger_imagery_suppressed", $this->meta_written );
$this->assertSame( "1", $this->meta_written["_prautoblogger_imagery_suppressed"] );
}
/**
* When citation_score equals the threshold, the gate passes.
* auto_publish=0 so we verify status is not "held-citation".
*/
public function test_citation_gate_passes_at_threshold(): void {
$this->stub_get_option( array(
"prautoblogger_log_level" => "error",
"prautoblogger_auto_publish" => "0",
"prautoblogger_citation_score_threshold" => 0.8,
"prautoblogger_writing_pipeline" => "single_pass",
"prautoblogger_writing_model" => "openai/gpt-4o-mini",
"prautoblogger_default_author" => 1,
) );
$high_quality_sources = array(
array( "url" => "https://a.com", "title" => "A", "quality_score" => 0.8 ),
);
$fanout = $this->make_fanout_stub( $this->make_fanout_results( $high_quality_sources ) );
$judge = $this->make_judge_stub( $high_quality_sources );
$loop = $this->make_editorial_loop_stub( "<p>approved content</p>", false );
$generator = $this->make_generator_stub( "<p>draft content</p>" );
$tracker = $this->make_cost_tracker_stub();
$pipeline = new \PRAutoBlogger_Authority_Pipeline( $tracker, $fanout, $judge, $loop, $generator );
$result = $pipeline->run( "run-citation-at", $this->make_idea(), $tracker );
$this->assertNotSame( "held-citation", $result["status"] );
$this->assertArrayNotHasKey( "_prautoblogger_imagery_suppressed", $this->meta_written );
}
// -- Imagery gate -----------------------------------------------------
/**
* When the editorial loop escalates (article held), imagery must be
* suppressed via _prautoblogger_imagery_suppressed=1 post-meta.
*/
public function test_imagery_suppressed_on_hold(): void {
$sources = array( array( "url" => "https://a.com", "title" => "A", "quality_score" => 0.9 ) );
$fanout = $this->make_fanout_stub( $this->make_fanout_results( $sources ) );
$judge = $this->make_judge_stub( $sources );
$loop = $this->make_editorial_loop_stub( "", true );
$generator = $this->make_generator_stub( "<p>draft content</p>" );
$tracker = $this->make_cost_tracker_stub();
$pipeline = new \PRAutoBlogger_Authority_Pipeline( $tracker, $fanout, $judge, $loop, $generator );
$result = $pipeline->run( "run-imagery", $this->make_idea(), $tracker );
$this->assertSame( "held-escalated", $result["status"] );
$this->assertArrayHasKey( "_prautoblogger_imagery_suppressed", $this->meta_written );
$this->assertSame( "1", $this->meta_written["_prautoblogger_imagery_suppressed"] );
}
// -- Economy path flag check ------------------------------------------
/**
* When the master flag is OFF, Tier_Router returns "economy".
* The Authority pipeline is never instantiated or called.
*/
public function test_master_flag_off_economy_path_used(): void {
Functions\when( "get_option" )->alias(
function ( $name, $default = false ) {
if ( "prautoblogger_authority_pipeline_enabled" === $name ) {
return false;
}
if ( "prautoblogger_log_level" === $name ) {
return "error";
}
return $default;
}
);
$idea = $this->make_idea();
$router = new \PRAutoBlogger_Tier_Router();
$tier = $router->resolve( $idea );
$this->assertSame( "economy", $tier );
$this->assertArrayNotHasKey( "_prautoblogger_imagery_suppressed", $this->meta_written );
}
// -- Stage ordering ---------------------------------------------------
/**
* Stage order: research -> curate -> draft -> editorial -> seo -> gate.
* Verify that fanout, judge, generator, and editorial loop are called.
*/
public function test_authority_chain_runs_stages_in_order(): void {
$sources = array( array( "url" => "https://a.com", "title" => "A", "quality_score" => 0.9 ) );
$called = array();
$fanout = $this->getMockBuilder( \PRAutoBlogger_Research_Fanout::class )
->disableOriginalConstructor()
->onlyMethods( array( "dispatch" ) )
->getMock();
$fanout->expects( $this->once() )->method( "dispatch" )
->willReturnCallback(
function () use ( &$called, $sources ) {
$called[] = "research";
return $this->make_fanout_results( $sources );
}
);
$judge = $this->getMockBuilder( \PRAutoBlogger_Research_Judge::class )
->disableOriginalConstructor()
->onlyMethods( array( "curate" ) )
->getMock();
$judge->expects( $this->once() )->method( "curate" )
->willReturnCallback(
function () use ( &$called, $sources ) {
$called[] = "curate";
return $sources;
}
);
$generator = $this->getMockBuilder( \PRAutoBlogger_Content_Generator::class )
->disableOriginalConstructor()
->onlyMethods( array( "generate" ) )
->getMock();
$generator->expects( $this->once() )->method( "generate" )
->willReturnCallback(
function () use ( &$called ) {
$called[] = "draft";
return "<p>draft content</p>";
}
);
$loop = $this->getMockBuilder( \PRAutoBlogger_Editorial_Loop::class )
->disableOriginalConstructor()
->onlyMethods( array( "run", "was_escalated" ) )
->getMock();
$loop->expects( $this->once() )->method( "run" )
->willReturnCallback(
function () use ( &$called ) {
$called[] = "editorial";
return "<p>approved content</p>";
}
);
$loop->method( "was_escalated" )->willReturn( false );
$tracker = $this->make_cost_tracker_stub();
$pipeline = new \PRAutoBlogger_Authority_Pipeline( $tracker, $fanout, $judge, $loop, $generator );
$result = $pipeline->run( "run-order", $this->make_idea(), $tracker );
$this->assertSame( array( "research", "curate", "draft", "editorial" ), $called );
$this->assertIsArray( $result );
$this->assertArrayHasKey( "status", $result );
}
// -- Helpers ----------------------------------------------------------
private function make_idea(): \PRAutoBlogger_Article_Idea {
return new \PRAutoBlogger_Article_Idea( array(
"topic" => "BPC-157",
"article_type" => "guide",
"suggested_title" => "BPC-157 Guide",
"summary" => "Comprehensive guide.",
"score" => 0.9,
) );
}
private function make_cost_tracker_stub(): object {
$tracker = $this->getMockBuilder( \PRAutoBlogger_Cost_Tracker::class )
->disableOriginalConstructor()
->onlyMethods( array( "get_run_id", "get_current_run_cost", "log_api_call" ) )
->getMock();
$tracker->method( "get_run_id" )->willReturn( "run-test" );
$tracker->method( "get_current_run_cost" )->willReturn( 0.01 );
return $tracker;
}
private function make_fanout_throwing_ceiling(): object {
$fanout = $this->getMockBuilder( \PRAutoBlogger_Research_Fanout::class )
->disableOriginalConstructor()
->onlyMethods( array( "dispatch" ) )
->getMock();
$fanout->method( "dispatch" )->willThrowException(
new \PRAutoBlogger_Cost_Ceiling_Exception( "Test cost ceiling breach." )
);
return $fanout;
}
private function make_fanout_stub( array $results ): object {
$fanout = $this->getMockBuilder( \PRAutoBlogger_Research_Fanout::class )
->disableOriginalConstructor()
->onlyMethods( array( "dispatch" ) )
->getMock();
$fanout->method( "dispatch" )->willReturn( $results );
return $fanout;
}
private function make_judge_stub( array $kept_sources ): object {
$judge = $this->getMockBuilder( \PRAutoBlogger_Research_Judge::class )
->disableOriginalConstructor()
->onlyMethods( array( "curate" ) )
->getMock();
$judge->method( "curate" )->willReturn( $kept_sources );
return $judge;
}
private function make_editorial_loop_stub( string $content, bool $escalated ): object {
$loop = $this->getMockBuilder( \PRAutoBlogger_Editorial_Loop::class )
->disableOriginalConstructor()
->onlyMethods( array( "run", "was_escalated" ) )
->getMock();
$loop->method( "run" )->willReturn( $content );
$loop->method( "was_escalated" )->willReturn( $escalated );
return $loop;
}
private function make_generator_stub( string $content ): object {
$gen = $this->getMockBuilder( \PRAutoBlogger_Content_Generator::class )
->disableOriginalConstructor()
->onlyMethods( array( "generate" ) )
->getMock();
$gen->method( "generate" )->willReturn( $content );
return $gen;
}
private function make_fanout_results( array $sources ): array {
return array(
array( "sources" => $sources, "agent_role" => "mechanisms" ),
array( "sources" => $sources, "agent_role" => "clinical" ),
);
}
}

View File

@@ -0,0 +1,123 @@
<?php
/**
* Tests for PRAutoBlogger_Tier_Router.
*
* Covers: master flag OFF always returns 'economy', unclassified category
* defaults to authority, explicit 'economy' demotion, explicit 'authority'
* entry treated as default authority.
*
* No LLM calls — the router is a pure option-read + conditional.
*
* @package PRAutoBlogger\Tests\Core
*/
namespace PRAutoBlogger\Tests\Core;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class TierRouterTest extends BaseTestCase {
/** @var \PRAutoBlogger_Article_Idea Shared idea fixture. */
private \PRAutoBlogger_Article_Idea $idea;
protected function setUp(): void {
parent::setUp();
$this->idea = new \PRAutoBlogger_Article_Idea( array(
'topic' => 'BPC-157 overview',
'article_type' => 'guide',
'suggested_title' => 'BPC-157 Guide',
'summary' => 'Comprehensive guide.',
'score' => 0.9,
) );
}
// ── Master flag OFF ──────────────────────────────────────────────────
/**
* When the master flag is OFF (default false), resolve() must always
* return 'economy' regardless of category map contents.
*
* This is the critical proof that P2b.4 deployment = zero behavior change.
*/
public function test_master_flag_off_always_returns_economy(): void {
$this->stub_get_option( array(
'prautoblogger_authority_pipeline_enabled' => false,
'prautoblogger_category_tiers' => array( 'guide' => 'authority' ),
) );
$router = new \PRAutoBlogger_Tier_Router();
$this->assertSame( 'economy', $router->resolve( $this->idea ) );
}
/**
* String '0' is also a falsy master flag — must return 'economy'.
*/
public function test_master_flag_string_zero_returns_economy(): void {
$this->stub_get_option( array(
'prautoblogger_authority_pipeline_enabled' => '0',
) );
$router = new \PRAutoBlogger_Tier_Router();
$this->assertSame( 'economy', $router->resolve( $this->idea ) );
}
// ── Master flag ON — category routing ────────────────────────────────
/**
* When flag is ON and the idea's category is NOT in the tier map,
* the default Authority tier must apply.
*/
public function test_unclassified_category_defaults_authority(): void {
$this->stub_get_option( array(
'prautoblogger_authority_pipeline_enabled' => '1',
'prautoblogger_category_tiers' => array( 'news' => 'economy' ),
) );
$router = new \PRAutoBlogger_Tier_Router();
// 'guide' is not in the tier map → default authority.
$this->assertSame( 'authority', $router->resolve( $this->idea ) );
}
/**
* When flag is ON and the idea's category is explicitly set to 'economy'
* in the tier map, resolve() must return 'economy'.
*/
public function test_explicitly_demoted_category_returns_economy(): void {
$this->stub_get_option( array(
'prautoblogger_authority_pipeline_enabled' => '1',
'prautoblogger_category_tiers' => array( 'guide' => 'economy' ),
) );
$router = new \PRAutoBlogger_Tier_Router();
$this->assertSame( 'economy', $router->resolve( $this->idea ) );
}
/**
* When flag is ON and the idea's category is explicitly set to 'authority'
* in the tier map (redundant but valid), resolve() must return 'authority'.
*/
public function test_authority_category_returns_authority(): void {
$this->stub_get_option( array(
'prautoblogger_authority_pipeline_enabled' => '1',
'prautoblogger_category_tiers' => array( 'guide' => 'authority' ),
) );
$router = new \PRAutoBlogger_Tier_Router();
$this->assertSame( 'authority', $router->resolve( $this->idea ) );
}
/**
* When flag is ON and the tier map is empty, all categories default to 'authority'.
*/
public function test_empty_tier_map_defaults_authority(): void {
$this->stub_get_option( array(
'prautoblogger_authority_pipeline_enabled' => '1',
'prautoblogger_category_tiers' => array(),
) );
$router = new \PRAutoBlogger_Tier_Router();
$this->assertSame( 'authority', $router->resolve( $this->idea ) );
}
}