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:
@@ -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-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)
|
||||
# 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-pipeline-status.php # Status-transient + summary helpers (extracted from runner/worker)
|
||||
│ │ ├── 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-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)
|
||||
# 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)
|
||||
│ │ ├── interface-image-provider.php # Contract for any image generation provider (incl. batch)
|
||||
│ │ ├── 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_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_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
|
||||
|
||||
@@ -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_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. |
|
||||
| `_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
|
||||
`_prautoblogger_image_role` (`featured`/`og`/`square`); OG/square variants also
|
||||
|
||||
21
CHANGELOG.md
21
CHANGELOG.md
@@ -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/),
|
||||
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
|
||||
|
||||
### Fixed (QA P1/P2 sweep — post-a39fb75)
|
||||
|
||||
12
CONTEXT.md
12
CONTEXT.md
@@ -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_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). |
|
||||
|
||||
## 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. |
|
||||
|
||||
@@ -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_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 |
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
* Plugin Name: 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.
|
||||
* Version: 0.30.0
|
||||
* Version: 0.31.0
|
||||
* Requires at least: 6.0
|
||||
* Requires PHP: 7.4
|
||||
* Author: PeptideRepo
|
||||
@@ -35,7 +35,7 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
| Defined here so every file in the plugin can reference paths, versions,
|
||||
| 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_DEFAULT_RUN_CEILING_USD', 0.50 );
|
||||
define( 'PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS', 14 );
|
||||
|
||||
402
tests/unit/Core/AuthorityPipelineTest.php
Normal file
402
tests/unit/Core/AuthorityPipelineTest.php
Normal 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" ),
|
||||
);
|
||||
}
|
||||
}
|
||||
123
tests/unit/Core/TierRouterTest.php
Normal file
123
tests/unit/Core/TierRouterTest.php
Normal 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 ) );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user