diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6cb3787..0086410 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 62eea8f..95ab09a 100644 --- a/CHANGELOG.md +++ b/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) diff --git a/CONTEXT.md b/CONTEXT.md index 6ba5172..a07027b 100644 --- a/CONTEXT.md +++ b/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. | diff --git a/CONVENTIONS.md b/CONVENTIONS.md index bd98c92..a2d12a5 100644 --- a/CONVENTIONS.md +++ b/CONVENTIONS.md @@ -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. + diff --git a/includes/core/class-article-worker.php b/includes/core/class-article-worker.php index d65426d..bbceb54 100644 --- a/includes/core/class-article-worker.php +++ b/includes/core/class-article-worker.php @@ -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 ) { diff --git a/includes/core/class-authority-pipeline-stages.php b/includes/core/class-authority-pipeline-stages.php new file mode 100644 index 0000000..f8878bb --- /dev/null +++ b/includes/core/class-authority-pipeline-stages.php @@ -0,0 +1,197 @@ + 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 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 ); + } +} diff --git a/includes/core/class-authority-pipeline.php b/includes/core/class-authority-pipeline.php new file mode 100644 index 0000000..249017b --- /dev/null +++ b/includes/core/class-authority-pipeline.php @@ -0,0 +1,273 @@ += 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, + '

[Article generation halted by per-run cost ceiling.]

', + $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; + } +} diff --git a/includes/core/class-tier-router.php b/includes/core/class-tier-router.php new file mode 100644 index 0000000..43f7035 --- /dev/null +++ b/includes/core/class-tier-router.php @@ -0,0 +1,100 @@ +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'; + } +} diff --git a/includes/providers/interface-authority-pipeline.php b/includes/providers/interface-authority-pipeline.php new file mode 100644 index 0000000..f544357 --- /dev/null +++ b/includes/providers/interface-authority-pipeline.php @@ -0,0 +1,41 @@ + 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( "

approved content

", false ); + $generator = $this->make_generator_stub( "

draft content

" ); + $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( "

approved content

", false ); + $generator = $this->make_generator_stub( "

draft content

" ); + $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( "

draft content

" ); + $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 "

draft content

"; + } + ); + + $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 "

approved content

"; + } + ); + $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" ), + ); + } +} diff --git a/tests/unit/Core/TierRouterTest.php b/tests/unit/Core/TierRouterTest.php new file mode 100644 index 0000000..19a6096 --- /dev/null +++ b/tests/unit/Core/TierRouterTest.php @@ -0,0 +1,123 @@ +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 ) ); + } +}