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

@@ -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 )
);
}
}