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

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