feat(phase2b): P2b.2 — bounded Editorial_Loop (#179, v0.29.0)

Additive: iterative editor↔writer loop bounded by editorial_max_rounds (default 3), escalates to Review Queue on exhaustion, records each round; behind interface, Authority-only (live path untouched). Also deletes dead class-research-dedup.php + repairs ARCHITECTURE.md. QA APPROVE 16035fa; CI green (VPS-verified).

Agent-Session: prab-phase2b-2-merge
This commit is contained in:
peptiderepo
2026-06-24 08:49:16 +07:00
committed by GitHub
parent 344412c4e7
commit 673866d0cd
12 changed files with 1042 additions and 209 deletions

View File

@@ -223,10 +223,11 @@ prautoblogger/
│ │ ├── class-run-reaper.php # Stuck-run sweep + audit-payload retention (rides the #19 cron)
│ │ ├── class-research-fanout.php # P2b.1: parallel specialist research agents (N×curl_multi, quorum, cost-reserve) — Authority only (v0.28.0)
│ │ ├── class-research-batch.php # P2b.1: curl_multi execution layer for research agents (extracted from fanout, v0.28.0)
│ │ ├── class-research-judge.php # P2b.1: curate stage — dedup+score fan-out results, write run_sources keep/discard (v0.28.0; dedup extracted to class-research-dedup.php)
│ │ ├── class-research-dedup.php # P2b.1: URL-exact + semantic/keyword deduplication for the curate stage; extracted from judge to satisfy 300-line rule (v0.28.0)
│ │ ├── class-research-judge.php # P2b.1: curate stage — dedup+score fan-out results, write run_sources keep/discard (v0.28.0; dedup inlined back into judge in P2b.2)
│ │ ├── class-research-source-scorer.php # P2b.1: source authority weighting for the judge (v0.28.0)
│ │ ├── class-research-source-writer.php # P2b.1: run_sources DB writer for the curate stage; extracted from judge to satisfy 300-line rule (v0.28.0)
│ │ ├── class-editorial-loop.php # P2b.2: bounded editorial loop — editor↔writer ≤editorial_max_rounds — Authority only (v0.29.0)
│ │ ├── class-editorial-revision-caller.php # P2b.2: writer revision LLM step (extracted from editorial-loop for 300-line rule) (v0.29.0)
│ │ ├── class-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)
@@ -246,6 +247,7 @@ prautoblogger/
│ │ ├── class-llm-research-provider.php # LLM deep research source (reasoning models)
│ │ ├── interface-research-fanout.php # P2b.1: contract for parallel research fan-out (v0.28.0)
│ │ ├── interface-research-judge.php # P2b.1: contract for the curate stage judge (v0.28.0)
│ │ ├── interface-editorial-loop.php # P2b.2: contract for bounded editorial loop (v0.29.0)
│ │ ├── 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)
@@ -278,6 +280,7 @@ prautoblogger/
│ ├── class-article-idea.php # Value object: scored article idea
│ ├── class-content-request.php # Value object: generation request with params
│ ├── class-editorial-review.php # Value object: editor verdict, scores, revised content
│ ├── class-editorial-round.php # P2b.2: value object: one editorial loop round (round_number, notes, verdict, revised_content, scores) (v0.29.0)
│ ├── class-generation-log.php # Value object: log entry for a generation run
│ └── class-content-score.php # Value object: composite performance score
│
@@ -689,6 +692,7 @@ All prefixed with `prautoblogger_`:
| `prautoblogger_image_compose_capability` | Cached capability probe `{fingerprint, capability}`; fingerprint = PHP version + imagick/gd presence, so it auto-invalidates on host changes |
| `prautoblogger_board_poll_interval` | Mission Brief board poll interval in seconds (default 5, min 3). Localized into board.js. |
| `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). |
### Post Meta
@@ -1242,6 +1246,7 @@ which acquires the lock and runs orchestration within its own process. The
| DB option | `prautoblogger_checkpoint_run_id` | Current run UUID (for finalize/cleanup paths) |
| Transient | `prautoblogger_generation_status` | Status broadcast (polling by board/dossier JS) |
| DB | `prautoblogger_runs` | Per-run ledger with status, cost ceiling |
| DB option | `prautoblogger_editorial_max_rounds` | Max editorial loop rounds (int, default 3, range 1–10). P2b.2 (v0.29.0). |
### Budget / Halt Contract

View File

@@ -5,6 +5,23 @@ 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.29.0] - 2026-06-24
### Added
- **P2b.2 — bounded `Editorial_Loop`** — additive Authority-tier only; not wired into the live Economy (single-pass) path until P2b.4 (tier routing).
- `PRAutoBlogger_Editorial_Loop` (`core/class-editorial-loop.php`, 300 lines) — iterative editor↔writer loop bounded by `prautoblogger_editorial_max_rounds` (new `get_option` key, default 3, configurable 1–10). Each round: Chief_Editor critiques → if 'approved' return content; if 'revised'/'rejected' get revised content → record round → repeat. After max rounds without approval, escalates to Review Queue (saves as draft, returns ''). Records every round to `run_decisions` (one row per round: verdict + "Round N: <notes>" rationale) and `run_stages` (start→done for role='editor' per round; start→done for role='writer' when writer revision is dispatched). Implements `PRAutoBlogger_Editorial_Loop_Interface`. `was_escalated()` returns true on exhaustion. `get_rounds()` returns the full `PRAutoBlogger_Editorial_Round[]` history.
- `PRAutoBlogger_Editorial_Loop_Interface` (`providers/interface-editorial-loop.php`) — contract for `run()` + `was_escalated()`.
- `PRAutoBlogger_Editorial_Revision_Caller` (`core/class-editorial-revision-caller.php`, 138 lines) — extracted writer-revision step (split from Editorial_Loop for 300-line compliance). Calls the writer LLM with `Content_Prompts::build_revision_system/user()`, logs cost via `Cost_Tracker`, manages run_stage start→done for role='writer'. Returns prior draft unchanged on empty LLM response (never silent pass).
- `PRAutoBlogger_Editorial_Round` (`models/class-editorial-round.php`) — immutable value object capturing one loop iteration (round_number, editor_notes, editor_verdict, revised_content, quality_score, seo_score). `to_array()` for audit JSON snapshot.
- `Content_Prompts::build_revision_system()` + `build_revision_user()` — two new static methods for revision LLM prompts; single-pass path untouched.
- New option `prautoblogger_editorial_max_rounds` (int, default 3, range 1–10). Uninstall purges it with the existing `LIKE 'prautoblogger\_%'` wildcard.
- PHPUnit: `EditorialLoopTest` — 8 tests covering approval on round 1, escalation after max rounds, floor (min=1) clamp, cap (max=10) clamp, round recording to `run_decisions`, escalation recorded as single 'escalated' row, inline revision path (editor-provided `revised_content` skips writer call), writer revision path (Editorial_Revision_Caller invoked on 'rejected' verdict). All 523 tests GREEN on VPS PHP 8.3.
### Updated
- `ARCHITECTURE.md` — Editorial_Loop added to Phase 2b file tree, options table (`prautoblogger_editorial_max_rounds`), and pipeline data flow note.
- `CONVENTIONS.md` — Loop-bounded editorial pattern documented.
- `CONTEXT.md` — New P2b.2 glossary section: editorial loop, editorial_max_rounds, round record, Review Queue escalation.
## [0.28.0] - 2026-06-23
### Added
@@ -28,14 +45,6 @@ and this project uses [Semantic Versioning](https://semver.org/).
- **P2-1:** Added `test_ceiling_breach_exception_aborts_before_dispatch()` to `ResearchFanoutTest` — asserts that when `open_amount_reservation()` throws `PRAutoBlogger_Cost_Ceiling_Exception`, `batch->execute()` is never called (enforces cost-governance contract).
- **P2-3:** Raised `MIN_AGENTS` from 1 to 2 in `Research_Fanout` — with quorum=⌈N/2⌉+1, N=1 makes quorum=2 which can never be satisfied; floor of 2 ensures the minimum fan-out can actually succeed.
### Fixed (VPS testfix — prab-phase2b-1-vps-testfix)
- **ResearchFanoutTest — test_quorum_formula_for_n_5:** `make_fanout()` re-stubbed `get_option` with hardcoded `agent_count=3`, silently overriding the test's `stub_get_option(agent_count=5)`. Fanout dispatched 3 agents but `make_valid_raw_results(5)` returned 5 results → `$roles[3]` undefined. Fix: removed the `get_option` stub from `make_fanout()`; tests now rely solely on `stub_get_option()` in setUp/individual tests.
- **ResearchFanoutTest — test_ceiling_breach_exception_aborts_before_dispatch:** Three compounding harness bugs: (1) `get_var` not stubbed → `Run_State::is_available()` returned false → governor short-circuited to null (no throw). (2) `ceiling_usd=0.0` → governor's zero-ceiling guard returned null (disabled). (3) `get_row` returned an object, not an array → `get_run()` `is_array()` check returned null → null-run early-return. Fix: stub `get_var` to return `'wp_prautoblogger_runs'` (enabling is_available), set `ceiling_usd=0.01` + `reserved_usd=0.01` (at limit, any reserve fails), and return an `array` from `get_row` (matching `ARRAY_A` mode).
- **ResearchJudgeTest — test_sources_over_max_written_as_discarded + test_kept_sources_written_with_kept_1:** `$this->inserted_rows` captured ALL `$wpdb->insert()` calls including Logger `event_log` rows. Logger rows have no `'kept'` key → "Undefined array key" when filtering. Fix: changed mock callback to track inserts keyed by table name suffix; assertions now read `$this->inserted_rows['run_sources']` only.
- **ResearchJudgeTest — test_no_db_writes_when_tables_absent:** `assertEmpty($this->inserted_rows)` failed because the Logger writes an `event_log` row even when audit tables are absent (Logger only skips when `$wpdb` is null). Fix: assertions now specifically check `$this->inserted_rows['run_sources']` and `$this->inserted_rows['run_decisions']` — the production behaviour under test — leaving Logger rows out of scope.
- **Dedup decision:** The `count(kw) >= 3` guard in `deduplicate()` is retained. It is the correct semantic: sparse keyword sets (under 3 words, e.g. a two-word title) have insufficient signal for overlap comparison and must pass through unconditionally; URL-exact dedup already covers true same-URL duplicates. The root cause of the test failures was not the guard itself but the test harness (Logger inserts in `inserted_rows`).
- **300-line trim:** Extracted write_run_sources + extract_doi from Research_Judge into new PRAutoBlogger_Research_Source_Writer (class-research-source-writer.php, 88 lines). Judge reduced to 264 lines; writer injected via optional constructor param (defaults to new instance). Behavior and all 515 tests unchanged. ARCHITECTURE.md file-tree updated.
## [0.27.1] - 2026-06-23
### Fixed (M4 P2 cleanup)
@@ -1631,66 +1640,4 @@ swap with no user-visible change in article quality.
### Changed
- **CI/CD pipeline now runs PHPCS and PHPUnit** before deploying.
Added `shivammathur/setup-php@v2` for PHP 8.1, Composer install,
WordPress Coding Standards check, and full PHPUnit test suite.
PHPCS runs in report-only mode (`|| true`) while codebase is
brought into full compliance; PHPUnit failures block deploy.
- Removed stub source providers (TikTok, Instagram, YouTube).
These were dead code — not registered in Source_Collector, threw
RuntimeException if enabled, and confused AI agents reading the
codebase. The `Source_Provider_Interface` remains the contract for
adding future platforms (see CONVENTIONS.md).
- Removed "coming soon" checkboxes from Enabled Sources setting.
- Updated ARCHITECTURE.md: removed stub file references, added
cross-system LLM budget coordination section documenting how
PRAutoBlogger and Peptide News share an OpenRouter account.
### Improved
- **PublisherTest rewritten with real behavioral assertions.**
Now tests: post_status ('publish' vs 'draft'), generation metadata
storage, title from idea, taxonomy assignment, tag assignment,
run_id-based log linking, RuntimeException on WP_Error, action/filter
hook firing. Replaces previous method-existence-only checks.
## [0.2.1] — 2026-04-12
### Changed
- **Reddit data source switched to RSS primary, .json fallback.**
PullPush.io was frequently stale/unavailable. Reddit RSS/Atom feeds work
reliably from datacenter IPs (Hostinger) where .json gets 403. Comments
are still fetched via .json (unavailable in RSS).
- Updated "Enabled Sources" checkbox label to "Reddit (RSS + .json)".
- Updated "Test Connections" to show RSS + .json status.
- Source status indicator now shows Reddit RSS (Primary) and .json (Fallback).
### Removed
- `class-pull-push-client.php` — replaced by RSS feeds in `class-reddit-json-client.php`.
- All PullPush.io references from admin UI, docs, and provider code.
### Fixed
- Generate Now button now always sends `force: '1'` to clear stale generation locks.
- Fixed encryption double-encryption bug with `enc:` prefix detection.
## [0.2.0] — 2026-04-12
### Changed
- **Reddit data source migrated from OAuth to RSS + .json (no auth required).**
Reddit rejected our API application; RSS feeds and .json endpoints are free
and require no authentication.
- Removed Reddit Client ID and Client Secret fields from admin API Keys tab.
- Updated "Test Connections" results to show Reddit source status.
### Added
- Source status indicator in API Keys tab showing RSS and .json
availability with live status dots and last-collection timestamp.
- Configurable research cache TTL (1–72 hours) in Sources tab.
- Reddit time window selector (24h / week / month) in Sources tab.
- Posts-per-subreddit limit (5–100) in Sources tab.
- LiteSpeed cache purge step in CI/CD deploy pipeline.
## [0.1.0] — 2026-04-10
### Added
- Initial plugin scaffold with WordPress-native architecture.
- OpenRouter LLM provider with configurable model selection and retry logic.
- Reddit API source provider (OAuth2 script app) for collecting posts and comments
WordPress Coding Standards check, and f

View File

@@ -176,3 +176,17 @@ into the live Economy path until P2b.4).
| **quality_score** | A composite score for a source: `relevance × authority_weight`. `relevance` comes from the agent's self-reported score (0.0–1.0). `authority_weight` is the source-type multiplier from `Research_Source_Scorer`. Higher scores sort the source closer to the top-12 kept set. Stored in `run_sources.quality_score`. |
| **authority weight** | A source-type multiplier applied by `PRAutoBlogger_Research_Source_Scorer` to produce `quality_score`. Values: DOI/PubMed/NCBI peer-reviewed → 1.0; .gov/.edu/WHO/FDA institutional → 0.85; preprint → 0.70; HTTPS → 0.60; HTTP → 0.40. Reflects the editorial preference for peer-reviewed sources in Authority-tier articles. |
| **MIN_AGENTS floor** | `Research_Fanout::MIN_AGENTS = 2`. The minimum configurable agent count. Set to 2 (not 1) because quorum = ⌈N/2⌉+1 = 2 for N=1, making a single-agent dispatch mathematically impossible to satisfy. Clamping to 2 ensures at least a 2-agent fan-out where quorum=2 is achievable (both must succeed). |
## Phase 2b P2b.2 — Editorial_Loop (v0.29.0)
These terms are introduced by the Authority-tier bounded editorial loop (additive; not wired
into the live Economy path until P2b.4).
| Term | Definition |
|------|-----------|
| **editorial loop** | The iterative editor↔writer review pattern used in the Authority tier. Each round: `Chief_Editor::review()` critiques the draft → if approved, loop exits; if not, the writer revises the draft via `Editorial_Revision_Caller::call()` → repeat. After max rounds without approval the article is **escalated to the Review Queue**. Contrasts with the Economy single-pass where `Chief_Editor::review()` runs once, no revision loop. |
| **editorial_max_rounds** | The `prautoblogger_editorial_max_rounds` WordPress option (int, default 3, clamped to [1, 10]). Controls how many editor↔writer rounds may occur before escalation. Configurable without a code change. |
| **round record** | An instance of `PRAutoBlogger_Editorial_Round` capturing one completed loop iteration. Fields: `round_number` (1-based), `editor_notes` (critique), `editor_verdict` ('approved'/'revised'/'rejected'), `revised_content` (writer output for that round, '' on approval), `quality_score`, `seo_score`. Serialised via `to_array()` for JSON audit snapshots in `run_stages` and the loop escalation record. |
| **Review Queue escalation** | When `editorial_max_rounds` are exhausted without the editor approving, the article is saved as a WordPress draft (Review Queue) rather than published. `Editorial_Loop::run()` returns `''` and `was_escalated()` returns `true`. One `run_decisions` row is written with `verdict='escalated'` and a rationale noting the max rounds exhausted. |
| **inline revision** | When `Chief_Editor::review()` returns a non-null `revised_content` in the `PRAutoBlogger_Editorial_Review` object (verdict='revised'), that content is used directly for the next round without invoking `Editorial_Revision_Caller`. Reduces LLM calls when the editor can self-correct. |
| **Editorial_Revision_Caller** | `PRAutoBlogger_Editorial_Revision_Caller` — the extracted writer revision step. Builds revision prompts via `Content_Prompts::build_revision_system/user()`, dispatches the writer LLM call (model from `prautoblogger_writing_model`), manages `run_stages` start→done for `role='writer'`, and logs cost. Extracted from `Editorial_Loop` to keep both classes under 300 lines. |

View File

@@ -752,3 +752,41 @@ PRAutoBlogger_Cost_Governor::settle( $reservation, $actual_total );
Never reserve inside the per-agent loop — that creates a race between the ceiling
check and concurrent call dispatch.
---
## How To: Add a Bounded Editorial Loop Caller (P2b.2+)
The Authority-tier editorial pipeline uses a bounded editor<->writer loop
(`PRAutoBlogger_Editorial_Loop`) instead of the single-pass `Chief_Editor::review()`.
### Key patterns
- **Loop bound** is driven by `get_option('prautoblogger_editorial_max_rounds', 3)`,
clamped to `[1, 10]`. Never hardcode round counts.
- **Escalation**: when max rounds are exhausted without approval, `run()` returns `''`
and `was_escalated()` is `true`. The caller must handle this (save as draft to Review Queue).
- **Inline revision path**: if the editor's `PRAutoBlogger_Editorial_Review` object
carries a non-null `revised_content`, use it directly without calling the writer LLM.
- **Writer revision**: when no inline content is available, delegate to
`PRAutoBlogger_Editorial_Revision_Caller::call()`. Never put writer LLM logic inside
`Editorial_Loop` itself -- keeps it under 300 lines.
- **Round recording**: every round (including escalation) writes one `run_decisions` row
via `PRAutoBlogger_Audit_Writer::record_decision()`. Round rows carry `stage='editorial'`,
`verdict='approved'|'revised'|'rejected'`, `rationale='Round N: <notes>'`. Escalation
rows carry `verdict='escalated'`.
- **Stage lifecycle**: each editor check opens `run_stages` with `role='editor'` via
`Run_Stage_State::start()`; each writer revision opens with `role='writer'`. Both close
on `Run_Stage_State::done()` after their respective result is available.
- **Cost**: all LLM calls flow through the existing provider seam; cost governor reserves
are handled per-call inside `Chief_Editor::review()` and `Editorial_Revision_Caller::call()`.
No additional reservation needed in `Editorial_Loop::run()`.
### Splits to preserve the 300-line cap
| Class | Responsibility |
|---|---|
| `PRAutoBlogger_Editorial_Loop` | Loop orchestration, verdict routing, round recording, escalation |
| `PRAutoBlogger_Editorial_Revision_Caller` | Writer LLM call, stage lifecycle for 'writer' role |
| `PRAutoBlogger_Content_Prompts` | Static `build_revision_system()` + `build_revision_user()` |
| `PRAutoBlogger_Editorial_Round` | Immutable value object (one round snapshot) |

View File

@@ -14,8 +14,8 @@ declare(strict_types=1);
* Dependencies: PRAutoBlogger_Prompt_Registry (versioned prompt bodies),
* WordPress query functions (get_posts, get_the_title, get_permalink).
*
* @see core/class-content-generator.php — Consumer of these prompts.
* @see models/class-content-request.php — Data bag passed to every builder.
* @see core/class-content-generator.php -- Consumer of these prompts.
* @see models/class-content-request.php -- Data bag passed to every builder.
*/
class PRAutoBlogger_Content_Prompts {
@@ -135,6 +135,43 @@ class PRAutoBlogger_Content_Prompts {
);
}
/**
* Build the system prompt for an Authority-tier editorial revision call.
*
* Used by Editorial_Revision_Caller when asking the writer to revise a
* draft given the chief editor's critique. Keeps the revision focused on
* applying specific feedback rather than a full re-draft.
*
* @param PRAutoBlogger_Article_Idea $idea The article idea under revision.
* @return string System prompt for the revision call.
*/
public static function build_revision_system( PRAutoBlogger_Article_Idea $idea ): string {
$niche = (string) get_option( 'prautoblogger_niche_description', '' );
$niche_clause = '' !== $niche ? " specializing in {$niche}" : '';
return sprintf(
"You are an expert writer%s. You have written a draft article and received feedback from your chief editor. Your task is to revise the article to address EVERY point raised in the editor's critique. Do NOT rewrite sections that were not criticised. Return ONLY the revised HTML article with no commentary, preamble, or metadata. Topic: %s. Title: %s.",
$niche_clause,
$idea->get_topic(),
$idea->get_suggested_title()
);
}
/**
* Build the user prompt for an Authority-tier editorial revision call.
*
* @param string $current_draft Current HTML draft to be revised.
* @param string $editor_notes Critique notes from the chief editor.
* @return string User prompt for the revision call.
*/
public static function build_revision_user( string $current_draft, string $editor_notes ): string {
return sprintf(
"EDITOR CRITIQUE:\n%s\n\n---\nCURRENT DRAFT:\n%s\n\n---\nPlease revise the draft above to address every point in the editor critique. Return the complete revised article as HTML only.",
$editor_notes,
$current_draft
);
}
/**
* Build the linking rules section (internal articles + peptide database).
*
@@ -148,7 +185,7 @@ class PRAutoBlogger_Content_Prompts {
$rules .= "NEVER fabricate or invent URLs. You do NOT have access to external sources.\n";
$rules .= "Only use the internal links listed below when linking within the article.\n";
$rules .= "If no listed article is relevant to a section, do not insert a link.\n";
$rules .= "Do NOT add links for peptide names — those are injected automatically after generation.\n\n";
$rules .= "Do NOT add links for peptide names -- those are injected automatically after generation.\n\n";
$rules .= self::build_article_links();

View File

@@ -0,0 +1,298 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Authority-tier editorial loop — bounded iterative editor↔writer review.
*
* What: Wraps Chief_Editor::review() into a bounded loop (≤
* `prautoblogger_editorial_max_rounds`, default 3). Each round the
* editor critiques; if not approved, Editorial_Revision_Caller runs
* a targeted writer revision; if approved, the loop exits. After max
* rounds without approval the article escalates to the Review Queue
* (run() returns ''). Every round is recorded to the audit trail (one
* run_decisions row per round; run_stages start→done per iteration).
* Cost: each LLM call reserves worst-case cost via the existing
* provider seam. Article_Worker (single-pass) is untouched — ADDITIVE.
*
* Who triggers it: PRAutoBlogger_Authority_Pipeline (P2b.4, NOT live yet).
* Dependencies: PRAutoBlogger_Chief_Editor, PRAutoBlogger_Editorial_Revision_Caller,
* PRAutoBlogger_Audit_Writer, PRAutoBlogger_Run_Stage_State,
* PRAutoBlogger_Pipeline_Status, PRAutoBlogger_Logger.
*
* @see providers/interface-editorial-loop.php — Interface this implements.
* @see core/class-chief-editor.php — Single-pass editor (reused).
* @see core/class-editorial-revision-caller.php — Writer revision step (extracted).
* @see core/class-audit-writer.php — Round records + decision audit.
* @see ARCHITECTURE.md — Phase 2b editorial stage.
*/
class PRAutoBlogger_Editorial_Loop implements PRAutoBlogger_Editorial_Loop_Interface {
/** Default maximum rounds when the option is unset. */
public const DEFAULT_MAX_ROUNDS = 3;
/** Minimum configurable rounds (safety floor). */
private const MIN_ROUNDS = 1;
/** Maximum configurable rounds (sanity cap). */
private const MAX_ROUNDS = 10;
/** @var PRAutoBlogger_Chief_Editor Editor agent. */
private PRAutoBlogger_Chief_Editor $editor;
/** @var PRAutoBlogger_Editorial_Revision_Caller Writer revision step. */
private PRAutoBlogger_Editorial_Revision_Caller $revision_caller;
/** @var bool Whether the last run() call escalated to the Review Queue. */
private bool $escalated = false;
/** @var PRAutoBlogger_Editorial_Round[] Round records from the last run(). */
private array $rounds = array();
/**
* @param PRAutoBlogger_Chief_Editor $editor Editor agent.
* @param PRAutoBlogger_Editorial_Revision_Caller $revision_caller Writer revision step.
*/
public function __construct(
PRAutoBlogger_Chief_Editor $editor,
PRAutoBlogger_Editorial_Revision_Caller $revision_caller
) {
$this->editor = $editor;
$this->revision_caller = $revision_caller;
}
/**
* Run the bounded editorial loop for one article.
*
* Each round: editor critiques → writer revises if not approved → repeat.
* Returns '' and sets was_escalated()=true after max rounds without approval.
*
* Side effects: LLM API calls, run_stages + run_decisions DB writes,
* status broadcasts, Logger calls.
*
* @param string $run_id Pipeline run UUID.
* @param string $item_key Article-scoped stage key.
* @param string $content Initial draft HTML.
* @param PRAutoBlogger_Article_Idea $idea The article idea under review.
* @param PRAutoBlogger_Cost_Tracker $cost_tracker Pipeline cost tracker.
* @return string Approved/revised HTML, or '' on Review-Queue escalation.
*/
public function run(
string $run_id,
string $item_key,
string $content,
PRAutoBlogger_Article_Idea $idea,
PRAutoBlogger_Cost_Tracker $cost_tracker
): string {
$this->escalated = false;
$this->rounds = array();
$max_rounds = $this->resolve_max_rounds();
$current = $content;
for ( $round = 1; $round <= $max_rounds; $round++ ) {
PRAutoBlogger_Pipeline_Status::broadcast(
sprintf(
/* translators: 1: current round, 2: max rounds. */
__( 'Editorial review — round %1$d of %2$d…', 'prautoblogger' ),
$round,
$max_rounds
)
);
PRAutoBlogger_Logger::instance()->info(
sprintf( 'Editorial loop: round %d/%d for "%s".', $round, $max_rounds, mb_substr( $idea->get_topic(), 0, 60 ) ),
'editorial-loop'
);
PRAutoBlogger_Run_Stage_State::start( $run_id, 'editorial', 'editor', $item_key );
$review = $this->editor->review( $current, $idea );
$verdict = $review->get_verdict();
$notes = $review->get_notes();
if ( 'approved' === $verdict ) {
$record = $this->make_round( $round, $notes, $verdict, '', $review );
$this->rounds[] = $record;
$this->record_round( $run_id, $item_key, $record );
PRAutoBlogger_Logger::instance()->info(
sprintf( 'Editorial loop: APPROVED at round %d.', $round ),
'editorial-loop'
);
return $current;
}
// Non-approved: get revised content (inline from editor, or a writer call).
$revised = $this->get_revised_content( $round, $run_id, $item_key, $current, $idea, $review, $cost_tracker );
$record = $this->make_round( $round, $notes, $verdict, $revised, $review );
$this->rounds[] = $record;
$this->record_round( $run_id, $item_key, $record );
$current = '' !== $revised ? $revised : $current;
PRAutoBlogger_Logger::instance()->info(
sprintf( 'Editorial loop: round %d done (verdict=%s).', $round, $verdict ),
'editorial-loop'
);
}
// Exhausted all rounds without approval.
$this->escalated = true;
PRAutoBlogger_Logger::instance()->warning(
sprintf( 'Editorial loop: max rounds (%d) exhausted for "%s". Escalating.', $max_rounds, mb_substr( $idea->get_topic(), 0, 80 ) ),
'editorial-loop'
);
$this->record_escalation( $run_id, $item_key, $max_rounds );
return '';
}
/** @return bool Whether the last run() escalated to the Review Queue. */
public function was_escalated(): bool {
return $this->escalated;
}
/**
* Round records from the most recent run() call.
*
* @return PRAutoBlogger_Editorial_Round[]
*/
public function get_rounds(): array {
return $this->rounds;
}
// ── Private helpers ─────────────────────────────────────────────────
/**
* Resolve the configured max rounds, clamped to [MIN_ROUNDS, MAX_ROUNDS].
*
* @return int
*/
private function resolve_max_rounds(): int {
$n = (int) get_option( 'prautoblogger_editorial_max_rounds', self::DEFAULT_MAX_ROUNDS );
return max( self::MIN_ROUNDS, min( self::MAX_ROUNDS, $n ) );
}
/**
* Produce revised content: prefers inline revised_content; falls back to Editorial_Revision_Caller.
*
* @param int $round Round number.
* @param string $run_id Run UUID.
* @param string $item_key Stage item key.
* @param string $current Current draft HTML.
* @param PRAutoBlogger_Article_Idea $idea Article idea.
* @param PRAutoBlogger_Editorial_Review $review Editor's review for this round.
* @param PRAutoBlogger_Cost_Tracker $cost_tracker Cost tracker for revision call.
* @return string Revised HTML.
*/
private function get_revised_content(
int $round,
string $run_id,
string $item_key,
string $current,
PRAutoBlogger_Article_Idea $idea,
PRAutoBlogger_Editorial_Review $review,
PRAutoBlogger_Cost_Tracker $cost_tracker
): string {
$inline = $review->get_revised_content();
if ( 'revised' === $review->get_verdict() && null !== $inline && '' !== $inline ) {
PRAutoBlogger_Logger::instance()->info(
sprintf( 'Editorial loop: round %d — using editor inline revision.', $round ),
'editorial-loop'
);
return (string) $inline;
}
return $this->revision_caller->call(
$round,
$run_id,
$item_key,
$current,
$idea,
$review->get_notes()
);
}
/**
* Construct an Editorial_Round value object for one loop iteration.
*
* @param int $round 1-based round number.
* @param string $notes Editor critique notes.
* @param string $verdict Editor verdict for this round.
* @param string $revised Writer's revised content ('' for approved).
* @param PRAutoBlogger_Editorial_Review $review Full review for score extraction.
* @return PRAutoBlogger_Editorial_Round
*/
private function make_round(
int $round,
string $notes,
string $verdict,
string $revised,
PRAutoBlogger_Editorial_Review $review
): PRAutoBlogger_Editorial_Round {
return new PRAutoBlogger_Editorial_Round(
$round,
$notes,
$verdict,
$revised,
$review->get_quality_score(),
$review->get_seo_score()
);
}
/**
* Persist one completed round to run_stages + run_decisions.
*
* @param string $run_id Run UUID.
* @param string $item_key Stage item key.
* @param PRAutoBlogger_Editorial_Round $record Round to persist.
* @return void
*/
private function record_round(
string $run_id,
string $item_key,
PRAutoBlogger_Editorial_Round $record
): void {
PRAutoBlogger_Run_Stage_State::done(
$run_id,
'editorial',
'editor',
$item_key,
(string) wp_json_encode( $record->to_array() )
);
PRAutoBlogger_Audit_Writer::record_decision(
$run_id,
'editorial',
$record->get_editor_verdict(),
sprintf( 'Round %d: %s', $record->get_round_number(), $record->get_editor_notes() )
);
}
/**
* Persist the escalation event to run_stages + run_decisions.
*
* @param string $run_id Run UUID.
* @param string $item_key Stage item key.
* @param int $max_rounds Max rounds that were exhausted.
* @return void
*/
private function record_escalation( string $run_id, string $item_key, int $max_rounds ): void {
PRAutoBlogger_Run_Stage_State::done(
$run_id,
'editorial',
'editor',
$item_key,
(string) wp_json_encode(
array(
'escalated' => true,
'max_rounds' => $max_rounds,
'rounds' => array_map( fn( $r ) => $r->to_array(), $this->rounds ),
)
)
);
PRAutoBlogger_Audit_Writer::record_decision(
$run_id,
'editorial',
'escalated',
sprintf( 'Max editorial rounds (%d) exhausted. Escalated to Review Queue.', $max_rounds )
);
}
}

View File

@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Writer-revision step for the Authority editorial loop.
*
* What: Extracted from Editorial_Loop (300-line split, v0.29.0). Holds the
* single-round writer revision logic — builds the revision prompts,
* dispatches the LLM call, logs cost, and returns the revised HTML.
* Editorial_Loop delegates to this class whenever the chief editor
* returns a non-approved verdict and no inline revision was provided.
* Who triggers it: PRAutoBlogger_Editorial_Loop::run_writer_revision().
* Dependencies: PRAutoBlogger_LLM_Provider_Interface (writer model call),
* PRAutoBlogger_Cost_Tracker (log_api_call), PRAutoBlogger_Content_Prompts
* (build_revision_system / build_revision_user), PRAutoBlogger_Run_Stage_State
* (stage lifecycle), PRAutoBlogger_Logger.
*
* @see core/class-editorial-loop.php — Orchestrator; creates this object.
* @see core/class-content-prompts.php — Prompt builders for revision calls.
* @see ARCHITECTURE.md — Phase 2b editorial loop design.
*/
class PRAutoBlogger_Editorial_Revision_Caller {
/** @var PRAutoBlogger_LLM_Provider_Interface LLM provider for the writer model. */
private PRAutoBlogger_LLM_Provider_Interface $writer_llm;
/** @var PRAutoBlogger_Cost_Tracker Shared cost tracker. */
private PRAutoBlogger_Cost_Tracker $cost_tracker;
/**
* @param PRAutoBlogger_LLM_Provider_Interface $writer_llm LLM provider for revision calls.
* @param PRAutoBlogger_Cost_Tracker $cost_tracker Pipeline cost tracker.
*/
public function __construct(
PRAutoBlogger_LLM_Provider_Interface $writer_llm,
PRAutoBlogger_Cost_Tracker $cost_tracker
) {
$this->writer_llm = $writer_llm;
$this->cost_tracker = $cost_tracker;
}
/**
* Ask the writer LLM to revise the current draft given the editor's critique.
*
* Dispatches a targeted revision call via the injected writer_llm provider.
* Cost is reserved before dispatch via the existing OpenRouter provider
* seam (Cost_Governor::open_chat_reservation() is called inside
* send_chat_completion) and logged via the shared cost tracker.
* Returns the prior draft unchanged on empty LLM response (never silent
* pass of an empty revision).
*
* Side effects: one OpenRouter API call, one generation_log row, two
* run_stages transitions (start→done for role='writer').
*
* @param int $round 1-based round number (for logging and stage snapshot).
* @param string $run_id Pipeline run UUID.
* @param string $item_key Article-scoped stage item key.
* @param string $current Current draft HTML to revise.
* @param PRAutoBlogger_Article_Idea $idea Article idea (for topic/title context).
* @param string $editor_notes Chief editor critique to address.
* @return string Revised HTML content, or $current if the LLM returned empty.
*/
public function call(
int $round,
string $run_id,
string $item_key,
string $current,
PRAutoBlogger_Article_Idea $idea,
string $editor_notes
): string {
PRAutoBlogger_Run_Stage_State::start( $run_id, 'editorial', 'writer', $item_key );
PRAutoBlogger_Logger::instance()->info(
sprintf( 'Editorial revision: round %d — dispatching writer revision.', $round ),
'editorial-loop'
);
$model = (string) get_option( 'prautoblogger_writing_model', PRAUTOBLOGGER_DEFAULT_WRITING_MODEL );
$messages = array(
array(
'role' => 'system',
'content' => PRAutoBlogger_Content_Prompts::build_revision_system( $idea ),
),
array(
'role' => 'user',
'content' => PRAutoBlogger_Content_Prompts::build_revision_user( $current, $editor_notes ),
),
);
$response = $this->writer_llm->send_chat_completion(
$messages,
$model,
array(
'temperature' => 0.5,
'max_tokens' => 5000,
'stage' => 'editorial',
'prompt_key' => 'content.revision',
)
);
$this->cost_tracker->log_api_call(
null,
'editorial',
$this->writer_llm->get_provider_name(),
(string) ( $response['model'] ?? $model ),
(int) ( $response['prompt_tokens'] ?? 0 ),
(int) ( $response['completion_tokens'] ?? 0 ),
'success',
'',
null,
'content.revision'
);
$revised = trim( (string) ( $response['content'] ?? '' ) );
if ( '' === $revised ) {
PRAutoBlogger_Logger::instance()->warning(
sprintf(
'Editorial revision: round %d writer returned empty content; retaining prior draft.',
$round
),
'editorial-loop'
);
$revised = $current;
}
PRAutoBlogger_Run_Stage_State::done(
$run_id,
'editorial',
'writer',
$item_key,
(string) wp_json_encode( array( 'round' => $round ) )
);
return $revised;
}
}

View File

@@ -1,130 +0,0 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Research Dedup — URL-exact and semantic deduplication for the curate stage.
*
* What: Deduplicates a flat source list using (1) canonical URL matching,
* then (2) cosine-similarity on OpenRouter embeddings with a
* keyword-overlap fallback when the embedding API is unavailable.
* Pure computation — no DB writes, no side effects beyond logging.
* Who triggers it: PRAutoBlogger_Research_Judge (curate stage) only.
* Not wired into the Economy path.
* Dependencies: PRAutoBlogger_OpenRouter_Embedding_Provider (semantic dedup),
* WordPress wp_parse_url(), Logger (warning on embedding failure).
*
* @see core/class-research-judge.php — Sole consumer.
* @see core/class-semantic-dedup.php — Embedding pattern reference.
* @see ARCHITECTURE.md — Phase 2b curate stage.
*/
class PRAutoBlogger_Research_Dedup {
/** Cosine similarity threshold above which two sources are duplicates. */
private const SEMANTIC_DEDUP_THRESHOLD = 0.90;
/**
* Deduplicate: URL-exact first, then semantic similarity via embeddings
* (falls back to keyword-overlap when the embedding API is unavailable).
*
* @param array<int, array{url: string, title: string, excerpt: string, relevance: float, agent_role: string}> $sources Flat source list.
* @return array<int, array{url: string, title: string, excerpt: string, relevance: float, agent_role: string}> Deduplicated sources.
*
* Side effects: Logger::warning() when embedding API is unreachable.
*/
public function deduplicate( array $sources ): array {
$seen_urls = array();
$unique = array();
$unique_embeddings = array();
$unique_kw = array();
$embeddings_ok = true;
$embed_provider = null;
foreach ( $sources as $source ) {
$canonical = $this->canonical_url( $source['url'] );
if ( '' !== $canonical ) {
if ( isset( $seen_urls[ $canonical ] ) ) {
continue;
}
$seen_urls[ $canonical ] = true;
}
$text = trim( $source['title'] . ' ' . $source['excerpt'] );
if ( $embeddings_ok && '' !== $text ) {
try {
if ( null === $embed_provider ) {
$embed_provider = new PRAutoBlogger_OpenRouter_Embedding_Provider();
}
$emb = $embed_provider->get_embeddings( array( $text ) );
$vec = $emb[0] ?? null;
if ( null !== $vec ) {
foreach ( $unique_embeddings as $ev ) {
if ( PRAutoBlogger_OpenRouter_Embedding_Provider::cosine_similarity( $vec, $ev ) >= self::SEMANTIC_DEDUP_THRESHOLD ) {
continue 2;
}
}
$unique_embeddings[] = $vec;
}
} catch ( \Throwable $e ) {
$embeddings_ok = false;
PRAutoBlogger_Logger::instance()->warning(
'Research Judge: embedding unavailable, using keyword fallback. ' . $e->getMessage(),
'research-judge'
);
}
}
if ( ! $embeddings_ok && '' !== $text ) {
$kw = $this->extract_keywords( $text );
foreach ( $unique_kw as $existing ) {
$overlap = count( array_intersect( $kw, $existing ) );
if ( count( $kw ) > 0 && ( $overlap / count( $kw ) ) >= 0.6 ) {
continue 2;
}
}
$unique_kw[] = $kw;
}
$unique[] = $source;
}
return $unique;
}
// ── Private helpers ─────────────────────────────────────────────────
/**
* Canonical URL: lowercase scheme+host+path, no query/fragment.
*
* @param string $url Raw URL.
* @return string Normalised, or empty on unparseable.
*/
private function canonical_url( string $url ): string {
if ( '' === $url ) {
return '';
}
$parts = wp_parse_url( $url );
if ( ! is_array( $parts ) ) {
return strtolower( $url );
}
return strtolower( ( $parts['scheme'] ?? 'https' ) . '://' . ( $parts['host'] ?? '' ) . rtrim( $parts['path'] ?? '', '/' ) );
}
/**
* Keyword extractor used for the embedding-unavailable fallback.
*
* @param string $text Input text.
* @return string[] Lowercase unique keywords (3+ chars).
*/
private function extract_keywords( string $text ): array {
static $sw = array( 'a', 'an', 'the', 'and', 'or', 'in', 'of', 'to', 'is', 'was', 'for', 'on', 'at' );
$words = preg_split( '/[^a-z0-9-]+/', strtolower( $text ), -1, PREG_SPLIT_NO_EMPTY ) ?? array();
return array_values(
array_unique(
array_filter( $words, static fn( string $w ): bool => strlen( $w ) >= 3 && ! in_array( $w, $sw, true ) )
)
);
}
}

View File

@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
/**
* Immutable value object representing one round of the editorial loop.
*
* Captures the editor's critique and the writer's response for that
* round so the audit trail can reconstruct the full loop history.
*
* Created by: PRAutoBlogger_Editorial_Loop per round.
* Persisted by: PRAutoBlogger_Audit_Writer::record_editorial_round().
*
* @see core/class-editorial-loop.php — Produces these objects.
* @see core/class-audit-writer.php — Persists them to run_decisions.
* @see ARCHITECTURE.md — Phase 2b editorial loop design.
*/
class PRAutoBlogger_Editorial_Round {
/** @var int Round number (1-based). */
private int $round_number;
/** @var string Editor's critique / notes for this round. */
private string $editor_notes;
/** @var string Editor's verdict for this round ('approved'|'revised'|'rejected'). */
private string $editor_verdict;
/** @var string Content produced by the writer after this round (empty on approval without revision). */
private string $revised_content;
/** @var float Editor quality score for this round (0.0–1.0). */
private float $quality_score;
/** @var float Editor SEO score for this round (0.0–1.0). */
private float $seo_score;
/**
* @param int $round_number 1-based round index.
* @param string $editor_notes Critique notes from the chief editor.
* @param string $editor_verdict 'approved', 'revised', or 'rejected'.
* @param string $revised_content Writer's revised content ('' if approved first time).
* @param float $quality_score Editor quality score (0.0–1.0).
* @param float $seo_score Editor SEO score (0.0–1.0).
*/
public function __construct(
int $round_number,
string $editor_notes,
string $editor_verdict,
string $revised_content,
float $quality_score,
float $seo_score
) {
$this->round_number = $round_number;
$this->editor_notes = $editor_notes;
$this->editor_verdict = $editor_verdict;
$this->revised_content = $revised_content;
$this->quality_score = $quality_score;
$this->seo_score = $seo_score;
}
/** @return int Round number (1-based). */
public function get_round_number(): int {
return $this->round_number;
}
/** @return string Critique notes. */
public function get_editor_notes(): string {
return $this->editor_notes;
}
/** @return string 'approved', 'revised', or 'rejected'. */
public function get_editor_verdict(): string {
return $this->editor_verdict;
}
/** @return string Writer's revised content for this round. */
public function get_revised_content(): string {
return $this->revised_content;
}
/** @return float Quality score (0.0–1.0). */
public function get_quality_score(): float {
return $this->quality_score;
}
/** @return float SEO score (0.0–1.0). */
public function get_seo_score(): float {
return $this->seo_score;
}
/**
* Serialise round to array for JSON snapshot / audit persistence.
*
* @return array<string, mixed>
*/
public function to_array(): array {
return array(
'round_number' => $this->round_number,
'editor_notes' => $this->editor_notes,
'editor_verdict' => $this->editor_verdict,
'revised_content' => $this->revised_content,
'quality_score' => $this->quality_score,
'seo_score' => $this->seo_score,
);
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
/**
* Contract for the Authority-tier editorial loop.
*
* Wraps the Chief_Editor review into a bounded iterative loop (≤
* `editorial_max_rounds`) where the editor critiques and the writer
* revises until the article is approved or rounds are exhausted.
*
* @see core/class-editorial-loop.php — Primary implementation.
* @see ARCHITECTURE.md — Phase 2b editorial stage design.
*/
interface PRAutoBlogger_Editorial_Loop_Interface {
/**
* Run the iterative editorial loop for one article.
*
* Each round: editor critiques → writer revises → if approved, stop.
* After max rounds without approval the article is escalated to the
* Review Queue (saved as draft) and an empty string is returned so
* the caller can detect the escalation.
*
* Side effects: multiple LLM API calls (reserved via cost governor),
* `run_stages` + `run_decisions` DB writes per round.
*
* @param string $run_id Pipeline run UUID.
* @param string $item_key Article-scoped stage key.
* @param string $content Initial draft HTML from the writer.
* @param PRAutoBlogger_Article_Idea $idea The article idea under review.
* @param PRAutoBlogger_Cost_Tracker $cost_tracker Pipeline cost tracker.
* @return string Approved (or final-revised) HTML, or '' on escalation.
*/
public function run(
string $run_id,
string $item_key,
string $content,
PRAutoBlogger_Article_Idea $idea,
PRAutoBlogger_Cost_Tracker $cost_tracker
): string;
/**
* Whether the last run() call resulted in an escalation to the Review Queue.
*
* Callers must check this after run() returns ''.
*
* @return bool
*/
public function was_escalated(): bool;
}

View File

@@ -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.28.0
* Version: 0.29.0
* Requires at least: 6.0
* Requires PHP: 7.4
* Author: PeptideRepo
@@ -35,8 +35,8 @@ if ( ! defined( 'ABSPATH' ) ) {
| Defined here so every file in the plugin can reference paths, versions,
| and limits without magic strings.
*/
define( 'PRAUTOBLOGGER_VERSION', '0.28.0' );
define( 'PRAUTOBLOGGER_DB_VERSION', '1.4.0' ); // No schema change in v0.28.0 (Research_Fanout/Judge are additive code only).
define( 'PRAUTOBLOGGER_VERSION', '0.29.0' );
define( 'PRAUTOBLOGGER_DB_VERSION', '1.4.0' ); // No schema change in v0.29.0 (Editorial_Loop is additive code only).
define( 'PRAUTOBLOGGER_DEFAULT_RUN_CEILING_USD', 0.50 );
define( 'PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS', 14 );
// Default max cards shown per board column and per ideas-browser page.

View File

@@ -0,0 +1,330 @@
<?php
/**
* Tests for PRAutoBlogger_Editorial_Loop.
*
* Covers: bound enforcement (max rounds respected), approval on first round,
* escalation when max rounds exhausted without approval, round recording to
* run_decisions, inline revision path vs writer revision path.
*
* The LLM calls are made via Chief_Editor (editor) and Editorial_Revision_Caller
* (writer revision). Both are mocked in these unit tests.
*
* @package PRAutoBlogger\Tests\Core
*/
namespace PRAutoBlogger\Tests\Core;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class EditorialLoopTest extends BaseTestCase {
/** @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb. */
private $wpdb;
/** @var array<int, array> Rows inserted to run_decisions via Audit_Writer. */
private array $decision_rows = array();
protected function setUp(): void {
parent::setUp();
$this->decision_rows = array();
$this->wpdb = $this->create_mock_wpdb();
$GLOBALS["wpdb"] = $this->wpdb;
$this->wpdb->prefix = "wp_";
$this->wpdb->method( "prepare" )->willReturnCallback(
static function ( $sql, ...$args ) {
return $sql . " /* " . implode( ",", array_map( "strval", $args ) ) . " */";
}
);
// Capture run_decisions inserts (Audit_Writer::record_decision).
$test = $this;
$this->wpdb->method( "insert" )->willReturnCallback(
function ( $table, $data ) use ( $test ) {
$test->decision_rows[] = $data;
return 1;
}
);
// SHOW TABLES returns the table name to simulate "tables available".
$this->wpdb->method( "get_var" )->willReturnCallback(
function ( $sql ) {
if ( false !== strpos( $sql, "SHOW TABLES" ) ) {
return "wp_prautoblogger_run_decisions";
}
return null;
}
);
\PRAutoBlogger_Audit_Writer::flush_cache();
\PRAutoBlogger_Run_Stage_State::flush_cache();
Functions\when( "current_time" )->justReturn( "2026-06-24 00:00:00" );
Functions\when( "wp_json_encode" )->alias( "json_encode" );
$this->stub_get_option( array(
"prautoblogger_log_level" => "error",
"prautoblogger_editorial_max_rounds" => 3,
"prautoblogger_writing_model" => "openai/gpt-4o-mini",
) );
// Pipeline_Status::broadcast is a no-op in tests (WP functions stubbed).
Functions\when( "__" )->alias( static fn( $s ) => $s );
}
protected function tearDown(): void {
\PRAutoBlogger_Audit_Writer::flush_cache();
\PRAutoBlogger_Run_Stage_State::flush_cache();
unset( $GLOBALS["wpdb"] );
parent::tearDown();
}
// ── Approval on first round ──────────────────────────────────────────
/**
* When the editor approves on round 1, the loop exits immediately and
* the original content is returned unchanged.
*/
public function test_approved_on_first_round_returns_content(): void {
$loop = $this->make_loop_with_results( array( "approved" ) );
$content = "<p>Draft content.</p>";
$idea = $this->make_idea();
$result = $loop->run( "run-1", "idea:abc", $content, $idea, $this->make_cost_tracker() );
$this->assertSame( $content, $result );
$this->assertFalse( $loop->was_escalated() );
$this->assertCount( 1, $loop->get_rounds() );
$this->assertSame( "approved", $loop->get_rounds()[0]->get_editor_verdict() );
}
// ── Max-rounds bound ────────────────────────────────────────────────
/**
* The loop never exceeds `editorial_max_rounds` regardless of verdict.
* After max rounds without approval, was_escalated() is true and "" returned.
*/
public function test_escalation_after_max_rounds(): void {
// All 3 rounds return "revised" → escalation.
$loop = $this->make_loop_with_results( array( "revised", "revised", "revised" ) );
$idea = $this->make_idea();
$result = $loop->run( "run-2", "idea:abc", "<p>Draft.</p>", $idea, $this->make_cost_tracker() );
$this->assertSame( "", $result );
$this->assertTrue( $loop->was_escalated() );
$this->assertCount( 3, $loop->get_rounds() );
}
/**
* If max_rounds = 1 (floor) and editor rejects, escalation happens after
* exactly 1 round.
*/
public function test_single_round_floor_escalates_after_one_round(): void {
$this->stub_get_option( array(
"prautoblogger_log_level" => "error",
"prautoblogger_editorial_max_rounds" => 1,
"prautoblogger_writing_model" => "openai/gpt-4o-mini",
) );
$loop = $this->make_loop_with_results( array( "rejected" ) );
$result = $loop->run( "run-3", "idea:abc", "<p>Draft.</p>", $this->make_idea(), $this->make_cost_tracker() );
$this->assertSame( "", $result );
$this->assertTrue( $loop->was_escalated() );
$this->assertCount( 1, $loop->get_rounds() );
}
/**
* Configuring max_rounds above MAX_ROUNDS cap (10) is silently clamped to 10.
* The loop still exits once approved within the cap.
*/
public function test_max_rounds_clamped_to_cap(): void {
$this->stub_get_option( array(
"prautoblogger_log_level" => "error",
"prautoblogger_editorial_max_rounds" => 999,
"prautoblogger_writing_model" => "openai/gpt-4o-mini",
) );
// Approve on round 2 — verifies the loop runs at least 2 rounds before approving.
$loop = $this->make_loop_with_results( array( "revised", "approved" ) );
$result = $loop->run( "run-4", "idea:abc", "<p>Draft.</p>", $this->make_idea(), $this->make_cost_tracker() );
$this->assertNotSame( "", $result );
$this->assertFalse( $loop->was_escalated() );
$this->assertCount( 2, $loop->get_rounds() );
}
// ── Round recording ──────────────────────────────────────────────────
/**
* Every non-escalation round is recorded to run_decisions with the
* editor verdict and round number in the rationale.
*/
public function test_each_round_recorded_to_run_decisions(): void {
$loop = $this->make_loop_with_results( array( "revised", "approved" ) );
$loop->run( "run-5", "idea:abc", "<p>Draft.</p>", $this->make_idea(), $this->make_cost_tracker() );
$decision_rows = array_filter(
$this->decision_rows,
static fn( $r ) => isset( $r["stage"] ) && "editorial" === $r["stage"]
);
// Two rounds → two decision rows.
$this->assertCount( 2, array_values( $decision_rows ) );
$rows = array_values( $decision_rows );
$this->assertSame( "revised", $rows[0]["verdict"] );
$this->assertStringStartsWith( "Round 1:", $rows[0]["rationale"] );
$this->assertSame( "approved", $rows[1]["verdict"] );
$this->assertStringStartsWith( "Round 2:", $rows[1]["rationale"] );
}
/**
* Escalation is recorded as a single run_decisions row with verdict="escalated".
*/
public function test_escalation_recorded_as_single_decision_row(): void {
$loop = $this->make_loop_with_results( array( "revised", "revised", "revised" ) );
$loop->run( "run-6", "idea:abc", "<p>Draft.</p>", $this->make_idea(), $this->make_cost_tracker() );
$escalation_rows = array_filter(
$this->decision_rows,
static fn( $r ) => isset( $r["verdict"] ) && "escalated" === $r["verdict"]
);
$this->assertCount( 1, $escalation_rows );
}
// ── Inline revision path ─────────────────────────────────────────────
/**
* When the editor returns verdict="revised" WITH revised_content in the
* review object, that inline content is used without calling the writer LLM.
*/
public function test_inline_revised_content_is_used_without_writer_call(): void {
$inline_revision = "<p>Inline revised.</p>";
$editor = $this->make_editor_mock( array( "revised" ), $inline_revision );
$revision_caller = $this->make_revision_caller_mock();
// Revision caller should NOT be called because editor provided inline content.
$revision_caller->expects( $this->never() )->method( "call" );
$loop = new \PRAutoBlogger_Editorial_Loop( $editor, $revision_caller );
// Run with max_rounds=1 so it escalates after 1 "revised" without approval.
$this->stub_get_option( array(
"prautoblogger_log_level" => "error",
"prautoblogger_editorial_max_rounds" => 1,
"prautoblogger_writing_model" => "openai/gpt-4o-mini",
) );
$loop->run( "run-7", "idea:abc", "<p>Draft.</p>", $this->make_idea(), $this->make_cost_tracker() );
// Escalated (no approval in 1 round), but revision caller was never invoked.
$this->assertTrue( $loop->was_escalated() );
}
// ── Writer revision path ─────────────────────────────────────────────
/**
* When the editor returns "rejected" (no inline revision), the revision
* caller is invoked and its output becomes the next round content.
*/
public function test_writer_revision_caller_invoked_on_rejected_verdict(): void {
$revised_html = "<p>Writer revised.</p>";
$editor = $this->make_editor_mock( array( "rejected", "approved" ) );
$revision_caller = $this->make_revision_caller_mock();
$revision_caller->expects( $this->once() )
->method( "call" )
->willReturn( $revised_html );
$loop = new \PRAutoBlogger_Editorial_Loop( $editor, $revision_caller );
$result = $loop->run( "run-8", "idea:abc", "<p>Draft.</p>", $this->make_idea(), $this->make_cost_tracker() );
// Second round approved → returns revised HTML.
$this->assertSame( $revised_html, $result );
$this->assertFalse( $loop->was_escalated() );
}
// ── Helpers ──────────────────────────────────────────────────────────
/**
* Build an Editorial_Loop whose editor returns verdicts in sequence.
*
* @param string[] $verdicts Sequence of editor verdicts (per round).
*/
private function make_loop_with_results( array $verdicts ): \PRAutoBlogger_Editorial_Loop {
$editor = $this->make_editor_mock( $verdicts );
$revision_caller = $this->make_revision_caller_stub();
return new \PRAutoBlogger_Editorial_Loop( $editor, $revision_caller );
}
/**
* Create a Chief_Editor mock returning the given sequence of verdicts.
*
* @param string[] $verdicts Verdicts per successive review() call.
* @param string $inline_rev Optional inline revised_content for "revised" verdicts.
*/
private function make_editor_mock( array $verdicts, string $inline_rev = "" ): object {
$editor = $this->getMockBuilder( \PRAutoBlogger_Chief_Editor::class )
->disableOriginalConstructor()
->getMock();
$call = 0;
$editor->method( "review" )->willReturnCallback(
function () use ( &$call, $verdicts, $inline_rev ) {
$verdict = $verdicts[ $call ] ?? "revised";
$call++;
return new \PRAutoBlogger_Editorial_Review( array(
"verdict" => $verdict,
"notes" => "Editor notes round {$call}.",
"revised_content" => ( "revised" === $verdict && "" !== $inline_rev ) ? $inline_rev : null,
"quality_score" => 0.7,
"seo_score" => 0.75,
"issues" => array(),
) );
}
);
return $editor;
}
/**
* Create an Editorial_Revision_Caller stub that returns fixed content.
*
* Uses onlyMethods() so PHPUnit knows "call" is a real method to mock.
*/
private function make_revision_caller_stub(): object {
$caller = $this->getMockBuilder( \PRAutoBlogger_Editorial_Revision_Caller::class )
->disableOriginalConstructor()
->onlyMethods( array( "call" ) )
->getMock();
$caller->method( "call" )->willReturn( "<p>Revised.</p>" );
return $caller;
}
/**
* Create an Editorial_Revision_Caller mock with no pre-set behaviour.
*/
private function make_revision_caller_mock(): object {
return $this->getMockBuilder( \PRAutoBlogger_Editorial_Revision_Caller::class )
->disableOriginalConstructor()
->onlyMethods( array( "call" ) )
->getMock();
}
/** Create a minimal article idea stub. */
private function make_idea(): \PRAutoBlogger_Article_Idea {
return new \PRAutoBlogger_Article_Idea( $this->get_article_idea_fixture() );
}
/** Create a minimal cost tracker stub (void methods stubbed). */
private function make_cost_tracker(): object {
$tracker = $this->getMockBuilder( \PRAutoBlogger_Cost_Tracker::class )
->disableOriginalConstructor()
->getMock();
return $tracker;
}
}