diff --git a/includes/class-executor.php b/includes/class-executor.php index deb7b02..d2944b3 100644 --- a/includes/class-executor.php +++ b/includes/class-executor.php @@ -135,13 +135,26 @@ class PRAutoBlogger_Executor { 'post_content' => $post->post_content, ]; - $result = $pipeline->generate_and_attach_images( $post_id, $article_data, null ); + // Retrieve original source IDs from post meta so Image B can generate + // a source-driven prompt even on retroactive regeneration. + $source_ids_json = get_post_meta( $post_id, '_prautoblogger_source_ids', true ); + $source_ids = is_string( $source_ids_json ) ? json_decode( $source_ids_json, true ) : []; + $source_data = ( new PRAutoBlogger_Source_Collector() )->get_source_data_for_image( + is_array( $source_ids ) ? array_map( 'absint', $source_ids ) : [] + ); + + $result = $pipeline->generate_and_attach_images( $post_id, $article_data, $source_data ); // Set featured image if Image A was generated. if ( ! empty( $result['image_a_id'] ) ) { set_post_thumbnail( $post_id, $result['image_a_id'] ); } + // Store Image B in post meta if generated. + if ( ! empty( $result['image_b_id'] ) ) { + update_post_meta( $post_id, '_prautoblogger_image_b_id', $result['image_b_id'] ); + } + wp_send_json_success( $result ); } catch ( \Exception $e ) { PRAutoBlogger_Logger::instance()->error( diff --git a/includes/core/class-post-assembler.php b/includes/core/class-post-assembler.php index 8d1c705..4f02041 100644 --- a/includes/core/class-post-assembler.php +++ b/includes/core/class-post-assembler.php @@ -132,7 +132,10 @@ class PRAutoBlogger_Post_Assembler { * @param array $post_data Post data array (for article content). */ public static function attach_generated_images( int $post_id, PRAutoBlogger_Article_Idea $idea, array $post_data ): void { - $source_data = null; + // Fetch the original Reddit source data for Image B's source-driven prompt. + // The idea's source_ids point to rows in the source_data table collected + // during the pipeline's collection step. + $source_data = ( new PRAutoBlogger_Source_Collector() )->get_source_data_for_image( $idea->get_source_ids() ); try { $result = ( new PRAutoBlogger_Image_Pipeline() )->generate_and_attach_images( $post_id, $post_data, $source_data ); diff --git a/includes/core/class-source-collector.php b/includes/core/class-source-collector.php index 410cc4a..7cc4f6f 100755 --- a/includes/core/class-source-collector.php +++ b/includes/core/class-source-collector.php @@ -57,6 +57,47 @@ class PRAutoBlogger_Source_Collector { return $total_inserted; } + /** + * Retrieve source data rows by their database IDs. + * + * Used by the image pipeline to build Image B's source-driven prompt from + * the source_ids stored on the Article_Idea. Returns the first matching + * row's title and content; these map directly to what + * Image_Prompt_Builder::build_source_prompt() expects. + * + * Side effects: one SELECT query. + * + * @param int[] $ids Database row IDs from the source_data table. + * @return array{title: string, selftext: string}|null First source row, or null if none found. + */ + public function get_source_data_for_image( array $ids ): ?array { + if ( empty( $ids ) ) { + return null; + } + + global $wpdb; + $table = $wpdb->prefix . 'prautoblogger_source_data'; + $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) ); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare + $row = $wpdb->get_row( + $wpdb->prepare( + "SELECT title, content, subreddit FROM {$table} WHERE id IN ({$placeholders}) ORDER BY score DESC LIMIT 1", + ...$ids + ), + ARRAY_A + ); + + if ( null === $row ) { + return null; + } + + return [ + 'title' => $row['title'] ?? 'Reddit Discussion', + 'selftext' => $row['content'] ?? '', + ]; + } + /** * Store collected source data in the database. * diff --git a/tests/unit/Core/ImagePipelineTest.php b/tests/unit/Core/ImagePipelineTest.php index d04b8a8..5d340f6 100644 --- a/tests/unit/Core/ImagePipelineTest.php +++ b/tests/unit/Core/ImagePipelineTest.php @@ -130,6 +130,45 @@ class ImagePipelineTest extends BaseTestCase { $this->assertGreaterThan( 0.05, $result['cost_usd'] ); } + /** + * Test that Image B is NOT generated when source_data is null. + * + * This is the regression test for the Image B data-handoff gap: + * post_assembler.php and executor.php both passed null as source_data, + * so the pipeline silently skipped Image B. If this test ever fails + * with `$this->exactly(2)`, it means someone re-broke the handoff. + */ + public function test_image_b_skipped_when_source_data_is_null(): void { + $provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class ); + $provider->method( 'estimate_cost' )->willReturn( 0.05 ); + $provider->method( 'generate_image' )->willReturn( [ + 'bytes' => 'fake_image_data', + 'mime_type' => 'image/png', + 'width' => 1200, + 'height' => 630, + 'model' => 'flux-1-schnell', + 'cost_usd' => 0.05, + 'latency_ms' => 2000, + ] ); + + $cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class ); + $cost_tracker->method( 'would_exceed_budget' )->willReturn( false ); + + // Only ONE call to log_image_generation — Image A only. + $cost_tracker->expects( $this->exactly( 1 ) )->method( 'log_image_generation' ); + + $pipeline = new \PRAutoBlogger_Image_Pipeline( $provider, $cost_tracker ); + + Functions\when( 'get_temp_dir' )->justReturn( '/tmp/' ); + Functions\when( 'media_handle_sideload' )->justReturn( 42 ); + + // Pass null source_data — Image B must NOT fire. + $result = $pipeline->generate_and_attach_images( 1, [ 'post_title' => 'Test' ], null ); + + $this->assertArrayHasKey( 'image_a_id', $result ); + $this->assertArrayNotHasKey( 'image_b_id', $result ); + } + /** * Test graceful handling when image generation fails. */