diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1aa1001..1463348 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 02033ab..77193c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: " 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 \ No newline at end of file + WordPress Coding Standards check, and f \ No newline at end of file diff --git a/CONTEXT.md b/CONTEXT.md index f5be2c4..be2f046 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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. | diff --git a/CONVENTIONS.md b/CONVENTIONS.md index 725f103..f29413f 100644 --- a/CONVENTIONS.md +++ b/CONVENTIONS.md @@ -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: '`. 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) | diff --git a/includes/core/class-content-prompts.php b/includes/core/class-content-prompts.php index 1df8c51..bbf67ef 100644 --- a/includes/core/class-content-prompts.php +++ b/includes/core/class-content-prompts.php @@ -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(); diff --git a/includes/core/class-editorial-loop.php b/includes/core/class-editorial-loop.php new file mode 100644 index 0000000..6df2dcc --- /dev/null +++ b/includes/core/class-editorial-loop.php @@ -0,0 +1,298 @@ +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 ) + ); + } +} diff --git a/includes/core/class-editorial-revision-caller.php b/includes/core/class-editorial-revision-caller.php new file mode 100644 index 0000000..e6dbbd2 --- /dev/null +++ b/includes/core/class-editorial-revision-caller.php @@ -0,0 +1,138 @@ +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; + } +} diff --git a/includes/core/class-research-dedup.php b/includes/core/class-research-dedup.php deleted file mode 100644 index 641e113..0000000 --- a/includes/core/class-research-dedup.php +++ /dev/null @@ -1,130 +0,0 @@ - $sources Flat source list. - * @return array 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 ) ) - ) - ); - } -} diff --git a/includes/models/class-editorial-round.php b/includes/models/class-editorial-round.php new file mode 100644 index 0000000..070bebc --- /dev/null +++ b/includes/models/class-editorial-round.php @@ -0,0 +1,106 @@ +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 + */ + 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, + ); + } +} diff --git a/includes/providers/interface-editorial-loop.php b/includes/providers/interface-editorial-loop.php new file mode 100644 index 0000000..f4b81a3 --- /dev/null +++ b/includes/providers/interface-editorial-loop.php @@ -0,0 +1,50 @@ + 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 = "

Draft content.

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

Draft.

", $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", "

Draft.

", $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", "

Draft.

", $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", "

Draft.

", $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", "

Draft.

", $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 = "

Inline revised.

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

Draft.

", $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 = "

Writer revised.

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

Draft.

", $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( "

Revised.

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