- Rewrite encryption with "enc:" prefix to permanently eliminate the double-encryption bug on settings save - Add one-time migration to clear legacy non-prefixed encrypted values - Rewrite content analyzer prompts to request N distinct diverse topics across 5 categories (question, complaint, comparison, news, guide) - Pass target_count*2 from pipeline to analyzer for headroom - Add skip_dedup flag so manual Generate Now always produces content - Generate Now button now sends force=1 to clear stale locks - Rename OpenRouter provider files to PSR-4 convention (hyphenated) - Switch default models to google/gemini-2.5-flash-lite - Clean up reddit_debug log channel to standard 'reddit' - Bump version to 0.2.1 Cost: ~$0.003/article with Gemini 2.0 Flash, ~34s pipeline. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
148 lines
5.6 KiB
PHP
Executable File
148 lines
5.6 KiB
PHP
Executable File
<?php
|
||
declare(strict_types=1);
|
||
|
||
/**
|
||
* Executes the full content generation pipeline.
|
||
*
|
||
* Orchestrates: Budget check → Collect source data → Analyze patterns →
|
||
* Score ideas → Generate content → Editorial review → Publish.
|
||
*
|
||
* Triggered by: PRAutoBlogger::on_daily_generation() and PRAutoBlogger::on_ajax_generate_now().
|
||
* Dependencies: All core content classes (Collector, Analyzer, Scorer, Generator, Editor, Publisher).
|
||
*
|
||
* @see class-prautoblogger.php — Instantiates this class and calls run().
|
||
* @see core/class-cost-tracker.php — Tracks cumulative API costs throughout the run.
|
||
* @see ARCHITECTURE.md — Data flow diagram showing pipeline steps.
|
||
*/
|
||
class PRAutoBlogger_Pipeline_Runner {
|
||
|
||
/**
|
||
* Execute the full content generation pipeline.
|
||
*
|
||
* Steps: Budget check → Collect source data → Analyze patterns →
|
||
* Score ideas → Generate content → Editorial review → Publish.
|
||
*
|
||
* Side effects: API calls (OpenRouter, Reddit), database writes, post creation.
|
||
*
|
||
* @return array{generated: int, published: int, rejected: int, cost: float} Pipeline results.
|
||
*
|
||
* @throws \RuntimeException If budget is exceeded or critical step fails.
|
||
*/
|
||
/** @var bool Whether to skip duplicate-topic detection (used for manual runs). */
|
||
private bool $skip_dedup = false;
|
||
|
||
/**
|
||
* Allow manual runs to skip the has_similar_post() check so "Generate Now"
|
||
* always produces content even when a related article was recently published.
|
||
*
|
||
* @param bool $skip True to skip deduplication.
|
||
*
|
||
* @return $this
|
||
*/
|
||
public function set_skip_dedup( bool $skip ): self {
|
||
$this->skip_dedup = $skip;
|
||
return $this;
|
||
}
|
||
|
||
public function run(): array {
|
||
$cost_tracker = new PRAutoBlogger_Cost_Tracker();
|
||
|
||
// Tag all log entries in this run with a unique ID so link_generation_logs()
|
||
// can accurately attribute costs to the correct post in batch runs.
|
||
$cost_tracker->set_run_id( wp_generate_uuid4() );
|
||
|
||
// Hard stop if monthly budget is exceeded.
|
||
if ( $cost_tracker->is_budget_exceeded() ) {
|
||
throw new \RuntimeException(
|
||
__( 'Monthly API budget exceeded. Generation halted.', 'prautoblogger' )
|
||
);
|
||
}
|
||
|
||
$target_count = absint( get_option( 'prautoblogger_daily_article_target', 1 ) );
|
||
|
||
// Step 1: Collect source data.
|
||
$collector = new PRAutoBlogger_Source_Collector();
|
||
$collector->collect_from_all_sources();
|
||
|
||
// Step 2: Analyze collected data for patterns.
|
||
// Pass a generous target so the LLM returns enough diverse ideas for
|
||
// scoring to pick from — ask for 2× the daily target so dedup has room.
|
||
$llm = new PRAutoBlogger_OpenRouter_Provider();
|
||
$analyzer = new PRAutoBlogger_Content_Analyzer( $llm, $cost_tracker );
|
||
$analysis = $analyzer->analyze_recent_data( max( $target_count * 2, 6 ) );
|
||
|
||
// Step 3: Score and deduplicate ideas.
|
||
$scorer = new PRAutoBlogger_Idea_Scorer();
|
||
$scorer->set_skip_dedup( $this->skip_dedup );
|
||
$ideas = $scorer->score_and_rank( $analysis, $target_count );
|
||
|
||
if ( empty( $ideas ) ) {
|
||
PRAutoBlogger_Logger::instance()->info( 'No viable article ideas found. Skipping generation.', 'pipeline' );
|
||
return [ 'generated' => 0, 'published' => 0, 'rejected' => 0, 'cost' => 0.0 ];
|
||
}
|
||
|
||
// Step 4 & 5: Generate content and run through editor for each idea.
|
||
$generator = new PRAutoBlogger_Content_Generator( $llm, $cost_tracker );
|
||
$editor = new PRAutoBlogger_Chief_Editor( $llm, $cost_tracker );
|
||
$publisher = new PRAutoBlogger_Publisher();
|
||
$auto_publish = in_array( get_option( 'prautoblogger_auto_publish', '0' ), [ '1', 'yes' ], true );
|
||
|
||
$generated = 0;
|
||
$published = 0;
|
||
$rejected = 0;
|
||
|
||
foreach ( $ideas as $idea ) {
|
||
// Re-check budget before each article — stop early if we hit the limit.
|
||
if ( $cost_tracker->is_budget_exceeded() ) {
|
||
PRAutoBlogger_Logger::instance()->warning( 'Budget limit reached during generation. Stopping early.', 'pipeline' );
|
||
break;
|
||
}
|
||
|
||
try {
|
||
$content = $generator->generate( $idea );
|
||
$generated++;
|
||
|
||
$review = $editor->review( $content, $idea );
|
||
|
||
if ( 'approved' === $review->get_verdict() || 'revised' === $review->get_verdict() ) {
|
||
// Null-coalesce: if editor returns 'revised' verdict but
|
||
// get_revised_content() is null (e.g. malformed LLM response),
|
||
// fall back to the original generated content.
|
||
$final_content = 'revised' === $review->get_verdict()
|
||
? ( $review->get_revised_content() ?? $content )
|
||
: $content;
|
||
|
||
if ( $auto_publish ) {
|
||
$publisher->publish( $final_content, $idea, $review, $cost_tracker->get_run_id() );
|
||
$published++;
|
||
} else {
|
||
// Auto-publish disabled: send to review queue as draft.
|
||
$publisher->save_as_draft( $final_content, $idea, $review, $cost_tracker->get_run_id() );
|
||
}
|
||
} else {
|
||
$publisher->save_as_draft( $content, $idea, $review, $cost_tracker->get_run_id() );
|
||
$rejected++;
|
||
PRAutoBlogger_Logger::instance()->info( 'Article rejected by editor: ' . $idea->get_topic(), 'pipeline' );
|
||
}
|
||
} catch ( \Exception $e ) {
|
||
PRAutoBlogger_Logger::instance()->error( 'Failed to generate article for topic "' . $idea->get_topic() . '": ' . $e->getMessage(), 'pipeline' );
|
||
// Continue with next idea — don't let one failure stop the batch.
|
||
}
|
||
}
|
||
|
||
$total_cost = $cost_tracker->get_current_run_cost();
|
||
|
||
PRAutoBlogger_Logger::instance()->info(
|
||
sprintf( 'Pipeline complete: %d generated, %d published, %d rejected. Cost: $%.4f', $generated, $published, $rejected, $total_cost ),
|
||
'pipeline'
|
||
);
|
||
|
||
return [
|
||
'generated' => $generated,
|
||
'published' => $published,
|
||
'rejected' => $rejected,
|
||
'cost' => $total_cost,
|
||
];
|
||
}
|
||
}
|