diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 787973e..7cf2cd6 100755 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -138,7 +138,8 @@ prautoblogger/ │ │ ├── class-image-media-sideloader.php # Imports images into WordPress media library │ │ ├── class-cost-tracker.php # Logs all API costs, enforces budget limits │ │ ├── class-logger.php # Structured logging singleton (error/warning/info/debug) -│ │ ├── class-pipeline-runner.php # Orchestrates the full generation pipeline +│ │ ├── class-article-worker.php # Single-article generation (content + edit + publish) +│ │ ├── class-pipeline-runner.php # Orchestrates pipeline; chains per-article cron jobs │ │ ├── class-ga4-client.php # Google Analytics 4 API client (OAuth + Data API) │ │ └── class-metrics-collector.php# Collects post performance data (WP + GA4) │ │ diff --git a/includes/class-executor.php b/includes/class-executor.php index 58c4113..ff18a06 100644 --- a/includes/class-executor.php +++ b/includes/class-executor.php @@ -24,12 +24,14 @@ class PRAutoBlogger_Executor { /** * Handle the daily generation cron event. - * Uses an atomic DB mutex to prevent overlapping runs. + * + * Uses an atomic DB mutex. If the pipeline queues additional articles, + * they fire as chained cron events and the lock is released by the + * last article job — not here. * * Side effects: API calls, database writes, WordPress post creation. */ public function on_daily_generation(): void { - // Refresh model registry before lock — stuck lock shouldn't block refresh. do_action( 'prautoblogger_refresh_model_registry', false ); if ( ! PRAutoBlogger_Generation_Lock::acquire() ) { @@ -39,12 +41,16 @@ class PRAutoBlogger_Executor { try { ( new PRAutoBlogger_Pipeline_Runner() )->run(); + + // Release lock only if no articles were queued for chained processing. + if ( ! get_option( 'prautoblogger_article_queue' ) ) { + PRAutoBlogger_Generation_Lock::release(); + } } catch ( \Throwable $e ) { PRAutoBlogger_Logger::instance()->error( sprintf( 'Daily generation %s: %s', get_class( $e ), $e->getMessage() ), 'scheduler' ); - } finally { PRAutoBlogger_Generation_Lock::release(); } } @@ -132,11 +138,14 @@ class PRAutoBlogger_Executor { ] ); } - /** Cron handler: runs pipeline in background, writes results to transient. */ + /** + * Cron handler: runs pipeline orchestration + first article. + * + * If the pipeline queues additional articles (2..N), they will fire as + * chained cron events — each in its own PHP process. The lock is released + * by the pipeline when the last article completes, not here. + */ public function on_manual_generation(): void { - // Keep PHP running even after LiteSpeed closes the HTTP connection. - // On Hostinger shared hosting, the web server kills HTTP connections - // at 120 seconds, but ignore_user_abort lets the PHP process continue. // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged @ignore_user_abort( true ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged @@ -153,17 +162,21 @@ class PRAutoBlogger_Executor { try { $this->update_generation_stage( __( 'Collecting sources from Reddit...', 'prautoblogger' ) ); - $results = ( new PRAutoBlogger_Pipeline_Runner() ) - ->set_skip_dedup( true ) - ->run(); + $runner = ( new PRAutoBlogger_Pipeline_Runner() )->set_skip_dedup( true ); + $results = $runner->run(); - set_transient( self::STATUS_TRANSIENT, [ - 'status' => 'complete', - 'generated' => $results['generated'], - 'published' => $results['published'], - 'rejected' => $results['rejected'], - 'cost' => $results['cost'], - ], self::STATUS_TTL ); + // If no articles were queued (target=1 or 0 ideas), finalize here. + // When articles ARE queued, the pipeline handles status + lock release. + if ( ! get_option( 'prautoblogger_article_queue' ) ) { + set_transient( self::STATUS_TRANSIENT, [ + 'status' => 'complete', + 'generated' => $results['generated'], + 'published' => $results['published'], + 'rejected' => $results['rejected'], + 'cost' => $results['cost'], + ], self::STATUS_TTL ); + PRAutoBlogger_Generation_Lock::release(); + } } catch ( \Throwable $e ) { PRAutoBlogger_Logger::instance()->error( sprintf( 'Manual generation %s: %s', get_class( $e ), $e->getMessage() ), @@ -173,93 +186,87 @@ class PRAutoBlogger_Executor { 'status' => 'error', 'message' => $e->getMessage(), ], self::STATUS_TTL ); - } finally { PRAutoBlogger_Generation_Lock::release(); } } + /** + * Cron handler: process the next queued article in its own PHP process. + * + * Chained by Pipeline_Runner — each article gets its own execution time. + */ + public function on_process_article_queue(): void { + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + @ignore_user_abort( true ); + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + @set_time_limit( 300 ); + + try { + ( new PRAutoBlogger_Pipeline_Runner() )->process_next_queued_article(); + } catch ( \Throwable $e ) { + PRAutoBlogger_Logger::instance()->error( + sprintf( 'Queued article generation %s: %s', get_class( $e ), $e->getMessage() ), + 'pipeline' + ); + // Clean up on catastrophic failure. + delete_option( 'prautoblogger_article_queue' ); + PRAutoBlogger_Generation_Lock::release(); + set_transient( self::STATUS_TRANSIENT, [ + 'status' => 'error', + 'message' => $e->getMessage(), + ], self::STATUS_TTL ); + } + } + /** AJAX: return generation status for frontend polling. Recovers orphaned runs. */ public function on_ajax_generation_status(): void { check_ajax_referer( 'prautoblogger_generate_now', 'nonce' ); - if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ], 403 ); return; } $status = get_transient( self::STATUS_TRANSIENT ); - if ( ! is_array( $status ) ) { wp_send_json_success( [ 'status' => 'idle' ] ); return; } - // Fallback: detect orphaned "running" state when PHP process was killed. - // On Hostinger shared hosting, set_time_limit() is often ignored and - // PHP kills the cron process at max_execution_time (120–180s). The - // pipeline creates the post first and images last, so the post exists - // even when the process died during image generation. if ( 'running' === $status['status'] ) { - $started = $status['started'] ?? 0; - $elapsed = time() - $started; + $elapsed = time() - ( $status['started'] ?? 0 ); - // After 10 minutes, give up unconditionally. + // Absolute timeout — give up after 10 minutes. if ( $elapsed > self::STATUS_TTL ) { - delete_transient( self::STATUS_TRANSIENT ); - wp_send_json_success( [ - 'status' => 'error', - 'message' => __( 'Generation timed out. Check the Activity Log for details.', 'prautoblogger' ), - ] ); + $this->abort_orphaned_run( __( 'Generation timed out. Check the Activity Log.', 'prautoblogger' ) ); return; } - // After 90 seconds, proactively check if the pipeline already - // produced a post. This catches the common case where the cron - // process was killed by max_execution_time after publishing but - // before updating the transient. Without this, the user stares - // at "Generating…" for 5+ minutes despite the work being done. - if ( $elapsed > 90 ) { - $recent = get_posts( [ - 'post_type' => 'post', - 'post_status' => [ 'publish', 'draft' ], - 'posts_per_page' => 1, - 'date_query' => [ [ 'after' => gmdate( 'Y-m-d H:i:s', $started ) ] ], - 'meta_key' => '_prautoblogger_run_id', - 'orderby' => 'date', - 'order' => 'DESC', - ] ); - - if ( ! empty( $recent ) ) { - delete_transient( self::STATUS_TRANSIENT ); - PRAutoBlogger_Generation_Lock::release(); - wp_send_json_success( [ - 'status' => 'complete', - 'generated' => 1, - 'published' => 1, - 'rejected' => 0, - 'cost' => 0.0, - 'note' => __( 'Generation completed (status recovered).', 'prautoblogger' ), - ] ); - return; + // After 90s, check if a queued job died and needs re-scheduling. + if ( $elapsed > 90 && get_option( 'prautoblogger_article_queue' ) ) { + if ( ! wp_next_scheduled( PRAutoBlogger_Pipeline_Runner::CRON_ACTION ) ) { + wp_schedule_single_event( time(), PRAutoBlogger_Pipeline_Runner::CRON_ACTION ); + spawn_cron(); } + } - // No post found yet — only declare failure after 5 minutes. - // The pipeline genuinely needs 2–4 min for article + images. - if ( $elapsed > 300 ) { - delete_transient( self::STATUS_TRANSIENT ); - PRAutoBlogger_Generation_Lock::release(); - wp_send_json_success( [ - 'status' => 'error', - 'message' => __( 'Generation process ended without producing content. Check Activity Log.', 'prautoblogger' ), - ] ); - return; - } + // After 5 min with no progress, declare failure. + if ( $elapsed > 300 ) { + $this->abort_orphaned_run( __( 'Generation stalled. Check Activity Log.', 'prautoblogger' ) ); + return; } } wp_send_json_success( $status ); } + /** Clean up an orphaned generation run and report error. */ + private function abort_orphaned_run( string $message ): void { + delete_transient( self::STATUS_TRANSIENT ); + delete_option( 'prautoblogger_article_queue' ); + PRAutoBlogger_Generation_Lock::release(); + wp_send_json_success( [ 'status' => 'error', 'message' => $message ] ); + } + /** * Helper: update the generation stage for frontend polling. * diff --git a/includes/class-prautoblogger.php b/includes/class-prautoblogger.php index 454aac0..d15a12f 100644 --- a/includes/class-prautoblogger.php +++ b/includes/class-prautoblogger.php @@ -95,6 +95,13 @@ class PRAutoBlogger { // Hostinger's 120-second web-server connection timeout. add_action( 'prautoblogger_manual_generation', [ $this->executor, 'on_manual_generation' ] ); + // Chained article generation — each queued article fires as its own + // cron event so it gets a fresh PHP process and execution time budget. + add_action( + PRAutoBlogger_Pipeline_Runner::CRON_ACTION, + [ $this->executor, 'on_process_article_queue' ] + ); + $registry = $this->executor->get_model_registry(); add_action( 'prautoblogger_refresh_model_registry', [ $registry, 'refresh' ] ); } diff --git a/includes/core/class-article-worker.php b/includes/core/class-article-worker.php new file mode 100644 index 0000000..a2bd6b1 --- /dev/null +++ b/includes/core/class-article-worker.php @@ -0,0 +1,144 @@ +cost_tracker = $cost_tracker; + } + + /** + * Generate one article from an idea. + * + * Runs the full single-article pipeline: LLM content generation → + * editorial review → publish or save as draft. + * + * Side effects: LLM API calls, database writes, WordPress post creation, + * image generation, cost logging. + * + * @param PRAutoBlogger_Article_Idea $idea The scored idea to generate from. + * + * @return array{generated: int, published: int, rejected: int, cost: float} + */ + public function generate( PRAutoBlogger_Article_Idea $idea ): array { + $llm = new PRAutoBlogger_OpenRouter_Provider(); + $generator = new PRAutoBlogger_Content_Generator( $llm, $this->cost_tracker ); + $editor = new PRAutoBlogger_Chief_Editor( $llm, $this->cost_tracker ); + $publisher = new PRAutoBlogger_Publisher(); + + $auto_publish = in_array( + get_option( 'prautoblogger_auto_publish', '0' ), + [ '1', 'yes' ], + true + ); + + $result = [ + 'generated' => 0, + 'published' => 0, + 'rejected' => 0, + 'cost' => 0.0, + ]; + + try { + $this->broadcast_stage( __( 'Generating article draft via AI…', 'prautoblogger' ) ); + $content = $generator->generate( $idea ); + $result['generated'] = 1; + + $this->broadcast_stage( __( 'Running editorial pass…', 'prautoblogger' ) ); + $review = $editor->review( $content, $idea ); + + $this->broadcast_stage( __( 'Saving and publishing…', 'prautoblogger' ) ); + $this->publish_or_draft( + $content, $idea, $review, $publisher, $auto_publish, $result + ); + } catch ( \Throwable $e ) { + PRAutoBlogger_Logger::instance()->error( + sprintf( + 'Article generation %s for "%s": %s', + get_class( $e ), + $idea->get_topic(), + $e->getMessage() + ), + 'pipeline' + ); + } + + $result['cost'] = $this->cost_tracker->get_current_run_cost(); + return $result; + } + + /** + * Publish approved content or save as draft. + * + * @param string $content Generated HTML content. + * @param PRAutoBlogger_Article_Idea $idea Source idea. + * @param PRAutoBlogger_Editorial_Review $review Editor verdict. + * @param PRAutoBlogger_Publisher $publisher Publisher instance. + * @param bool $auto_publish Whether to auto-publish. + * @param array &$result Result counters (by ref). + */ + private function publish_or_draft( + string $content, + PRAutoBlogger_Article_Idea $idea, + PRAutoBlogger_Editorial_Review $review, + PRAutoBlogger_Publisher $publisher, + bool $auto_publish, + array &$result + ): void { + $verdict = $review->get_verdict(); + $run_id = $this->cost_tracker->get_run_id(); + + if ( 'approved' === $verdict || 'revised' === $verdict ) { + $final = 'revised' === $verdict + ? ( $review->get_revised_content() ?? $content ) + : $content; + + if ( $auto_publish ) { + $publisher->publish( $final, $idea, $review, $run_id ); + $result['published']++; + } else { + $publisher->save_as_draft( $final, $idea, $review, $run_id ); + } + } else { + $publisher->save_as_draft( $content, $idea, $review, $run_id ); + $result['rejected']++; + PRAutoBlogger_Logger::instance()->info( + 'Article rejected by editor: ' . $idea->get_topic(), + 'pipeline' + ); + } + } + + /** + * Update the generation status transient with the current stage. + * + * @param string $stage Human-readable stage description. + */ + private function broadcast_stage( string $stage ): void { + $transient_key = 'prautoblogger_generation_status'; + $current = get_transient( $transient_key ); + if ( is_array( $current ) && 'running' === ( $current['status'] ?? '' ) ) { + $current['stage'] = $stage; + set_transient( $transient_key, $current, 600 ); + } + } +} diff --git a/includes/core/class-pipeline-runner.php b/includes/core/class-pipeline-runner.php index 34447f6..c7ba904 100755 --- a/includes/core/class-pipeline-runner.php +++ b/includes/core/class-pipeline-runner.php @@ -2,41 +2,37 @@ declare(strict_types=1); /** - * Executes the full content generation pipeline. + * Executes the content generation pipeline using a chained-job architecture. * * Orchestrates: Budget check → Collect source data → Analyze patterns → - * Score ideas → Generate content → Editorial review → Publish. + * Score ideas → Generate article 1 in-process → Queue articles 2..N as + * individual WP-Cron events so each gets its own PHP execution time budget. * - * Triggered by: PRAutoBlogger::on_daily_generation() and PRAutoBlogger::on_ajax_generate_now(). - * Dependencies: All core content classes (Collector, Analyzer, Scorer, Generator, Editor, Publisher). + * Why chained jobs: Hostinger kills PHP processes at ~120s. A single article + * (LLM generation + editorial + 2 images) takes ~60-90s, so one-per-process + * is safe. Running N articles sequentially in one process times out at N≥2. * - * @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. + * Triggered by: PRAutoBlogger_Executor (daily cron + manual "Generate Now"). + * Dependencies: Core content classes, Article_Worker, WP-Cron for chaining. + * + * @see class-executor.php — Cron/AJAX handlers that call us. + * @see core/class-article-worker.php — Single-article generation worker. + * @see core/class-cost-tracker.php — Tracks costs throughout the run. + * @see ARCHITECTURE.md — Data flow diagram. */ 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). */ + /** Option key for the article generation queue. */ + private const QUEUE_KEY = 'prautoblogger_article_queue'; + + /** WP-Cron action for chained article generation. */ + public const CRON_ACTION = 'prautoblogger_generate_queued_article'; + + /** @var bool Whether to skip duplicate-topic detection. */ 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 { @@ -44,128 +40,224 @@ class PRAutoBlogger_Pipeline_Runner { return $this; } + /** + * Run orchestration and generate the first article in-process. + * + * Steps: Budget check → Collect → Analyze → Score → Generate article 1. + * Remaining articles are queued as chained WP-Cron events. + * + * Side effects: API calls, DB writes, post creation, cron scheduling. + * + * @return array{generated: int, published: int, rejected: int, cost: float} + * @throws \RuntimeException If budget exceeded. + */ 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. - $this->broadcast_stage( __( 'Collecting sources from Reddit…', 'prautoblogger' ) ); - $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. - $this->broadcast_stage( __( 'Analyzing topics and scoring…', 'prautoblogger' ) ); - $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. - $this->broadcast_stage( __( 'Selecting best topic…', 'prautoblogger' ) ); - $scorer = new PRAutoBlogger_Idea_Scorer(); - $scorer->set_skip_dedup( $this->skip_dedup ); - $ideas = $scorer->score_and_rank( $analysis, $target_count ); - + $ideas = $this->orchestrate( $cost_tracker ); 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 ); + // Generate article 1 in this process — always fits within time limit. + $first_idea = array_shift( $ideas ); + $worker = new PRAutoBlogger_Article_Worker( $cost_tracker ); + $result = $worker->generate( $first_idea ); - $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 { - $this->broadcast_stage( __( 'Generating article draft via AI…', 'prautoblogger' ) ); - $content = $generator->generate( $idea ); - $generated++; - - $this->broadcast_stage( __( 'Running editorial pass…', 'prautoblogger' ) ); - $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; - - $this->broadcast_stage( __( 'Saving and publishing…', 'prautoblogger' ) ); - 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 ( \Throwable $e ) { - PRAutoBlogger_Logger::instance()->error( sprintf( 'Article generation %s for "%s": %s', get_class( $e ), $idea->get_topic(), $e->getMessage() ), 'pipeline' ); - // Continue with next idea — don't let one failure stop the batch. - } + // Queue remaining articles as chained cron events. + if ( ! empty( $ideas ) ) { + $this->queue_remaining( $ideas, $cost_tracker->get_run_id(), $result ); + } else { + $this->log_summary( $result ); } - $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, - ]; + return $result; } /** - * Update the generation status transient with the current pipeline stage. + * Process the next article from the persistent queue. * - * Called at key checkpoints during the pipeline so the frontend status - * poller can show real progress instead of fake timers. No-ops if the - * transient doesn't exist (e.g., during daily cron runs). + * Called by WP-Cron for articles 2, 3, …N. Pops one idea, generates + * it, updates running totals, and chains the next job or finalizes. * - * @param string $stage Human-readable stage description. + * Side effects: API calls, DB writes, post creation, cron scheduling. */ + public function process_next_queued_article(): void { + $queue = get_option( self::QUEUE_KEY ); + if ( ! is_array( $queue ) || empty( $queue['ideas'] ) ) { + $this->finish_queue(); + return; + } + + $idea_data = array_shift( $queue['ideas'] ); + $idea = new PRAutoBlogger_Article_Idea( $idea_data ); + $cost_tracker = new PRAutoBlogger_Cost_Tracker(); + $cost_tracker->set_run_id( $queue['run_id'] ); + + if ( $cost_tracker->is_budget_exceeded() ) { + PRAutoBlogger_Logger::instance()->warning( + 'Budget limit reached. Aborting remaining queued articles.', + 'pipeline' + ); + $this->finish_queue(); + return; + } + + $worker = new PRAutoBlogger_Article_Worker( $cost_tracker ); + $article_result = $worker->generate( $idea ); + + // Merge into running totals. + $queue['results']['generated'] += $article_result['generated']; + $queue['results']['published'] += $article_result['published']; + $queue['results']['rejected'] += $article_result['rejected']; + $queue['results']['cost'] += $article_result['cost']; + + if ( ! empty( $queue['ideas'] ) ) { + update_option( self::QUEUE_KEY, $queue, false ); + $this->update_queue_status( $queue ); + $this->schedule_next(); + } else { + $this->write_final_status( $queue['results'] ); + delete_option( self::QUEUE_KEY ); + PRAutoBlogger_Generation_Lock::release(); + $this->log_summary( $queue['results'] ); + } + } + + // ── Private helpers ───────────────────────────────────────────────── + + /** + * Collect → Analyze → Score. Returns ranked ideas. + * + * @param PRAutoBlogger_Cost_Tracker $cost_tracker + * @return PRAutoBlogger_Article_Idea[] + */ + private function orchestrate( PRAutoBlogger_Cost_Tracker $cost_tracker ): array { + $target = absint( get_option( 'prautoblogger_daily_article_target', 1 ) ); + + $this->broadcast_stage( __( 'Collecting sources from Reddit…', 'prautoblogger' ) ); + ( new PRAutoBlogger_Source_Collector() )->collect_from_all_sources(); + + $this->broadcast_stage( __( 'Analyzing topics and scoring…', 'prautoblogger' ) ); + $llm = new PRAutoBlogger_OpenRouter_Provider(); + $analyzer = new PRAutoBlogger_Content_Analyzer( $llm, $cost_tracker ); + $analysis = $analyzer->analyze_recent_data( max( $target * 2, 6 ) ); + + $this->broadcast_stage( __( 'Selecting best topic…', 'prautoblogger' ) ); + $scorer = new PRAutoBlogger_Idea_Scorer(); + $scorer->set_skip_dedup( $this->skip_dedup ); + $ideas = $scorer->score_and_rank( $analysis, $target ); + + if ( empty( $ideas ) ) { + PRAutoBlogger_Logger::instance()->info( + 'No viable article ideas found. Skipping generation.', + 'pipeline' + ); + } + + return $ideas; + } + + /** + * Store remaining ideas and schedule the next chained job. + * + * @param PRAutoBlogger_Article_Idea[] $ideas Remaining ideas (2..N). + * @param string $run_id Pipeline run UUID. + * @param array $first Results from article 1. + */ + private function queue_remaining( array $ideas, string $run_id, array $first ): void { + $serialized = array_map( + static fn( PRAutoBlogger_Article_Idea $i ) => $i->to_array(), + $ideas + ); + + $queue = [ + 'run_id' => $run_id, + 'ideas' => $serialized, + 'results' => $first, + ]; + + update_option( self::QUEUE_KEY, $queue, false ); + $this->update_queue_status( $queue ); + $this->schedule_next(); + + PRAutoBlogger_Logger::instance()->info( + sprintf( 'Article 1 done. Queued %d more as chained jobs.', count( $serialized ) ), + 'pipeline' + ); + } + + /** Schedule the next chained cron event and fire it immediately. */ + private function schedule_next(): void { + if ( ! wp_next_scheduled( self::CRON_ACTION ) ) { + wp_schedule_single_event( time(), self::CRON_ACTION ); + } + spawn_cron(); + wp_remote_post( + site_url( 'wp-cron.php?doing_wp_cron=' . sprintf( '%.22F', microtime( true ) ) ), + [ 'timeout' => 0.01, 'blocking' => false, 'sslverify' => false ] + ); + } + + /** Update the frontend status transient with queue progress. */ + private function update_queue_status( array $queue ): void { + $done = $queue['results']['generated'] + $queue['results']['rejected']; + $total = $done + count( $queue['ideas'] ); + + $current = get_transient( 'prautoblogger_generation_status' ); + set_transient( 'prautoblogger_generation_status', [ + 'status' => 'running', + 'stage' => sprintf( __( 'Generating article %1$d of %2$d…', 'prautoblogger' ), $done + 1, $total ), + 'started' => is_array( $current ) ? ( $current['started'] ?? time() ) : time(), + ], 600 ); + } + + /** Write the final "complete" status transient. */ + private function write_final_status( array $r ): void { + set_transient( 'prautoblogger_generation_status', [ + 'status' => 'complete', + 'generated' => $r['generated'], + 'published' => $r['published'], + 'rejected' => $r['rejected'], + 'cost' => $r['cost'], + ], 600 ); + } + + /** Clean up a stale or empty queue. */ + private function finish_queue(): void { + $queue = get_option( self::QUEUE_KEY ); + if ( is_array( $queue ) && isset( $queue['results'] ) ) { + $this->write_final_status( $queue['results'] ); + } + delete_option( self::QUEUE_KEY ); + PRAutoBlogger_Generation_Lock::release(); + } + + /** Log the pipeline summary. */ + private function log_summary( array $r ): void { + PRAutoBlogger_Logger::instance()->info( + sprintf( + 'Pipeline complete: %d generated, %d published, %d rejected. Cost: $%.4f', + $r['generated'], $r['published'], $r['rejected'], $r['cost'] + ), + 'pipeline' + ); + } + + /** Update the generation status transient with the current stage. */ private function broadcast_stage( string $stage ): void { - $transient_key = 'prautoblogger_generation_status'; - $current = get_transient( $transient_key ); + $key = 'prautoblogger_generation_status'; + $current = get_transient( $key ); if ( is_array( $current ) && 'running' === ( $current['status'] ?? '' ) ) { $current['stage'] = $stage; - set_transient( $transient_key, $current, 600 ); + set_transient( $key, $current, 600 ); } } } diff --git a/includes/models/class-article-idea.php b/includes/models/class-article-idea.php index d6f38cb..af95b98 100755 --- a/includes/models/class-article-idea.php +++ b/includes/models/class-article-idea.php @@ -84,4 +84,25 @@ class PRAutoBlogger_Article_Idea { public function get_target_keywords(): array { return $this->target_keywords; } + + /** + * Serialize to array for storage in options/transients. + * + * Used by the pipeline queue to persist ideas across chained cron jobs. + * + * @return array Constructor-compatible array. + */ + public function to_array(): array { + return [ + 'topic' => $this->topic, + 'article_type' => $this->article_type, + 'suggested_title' => $this->suggested_title, + 'summary' => $this->summary, + 'score' => $this->score, + 'analysis_id' => $this->analysis_id, + 'source_ids' => $this->source_ids, + 'key_points' => $this->key_points, + 'target_keywords' => $this->target_keywords, + ]; + } }