Compare commits

..

2 Commits

Author SHA1 Message Date
peptiderepo
b071c27780 merge: resolve CHANGELOG conflict after model-picker merge
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 16:51:10 +07:00
peptiderepo
84feb5e98b feat(images): wire image pipeline into article flow (commit 1b)
Completes the image + A/B experiment workstream by integrating
image generation into the post publishing pipeline.

New classes:
- class-image-pipeline.php: Orchestrates A/B generation (article-driven
  Image A → featured; source-driven Image B → post meta). Each image
  independently fallible; article publishes even if both fail.
- class-image-prompt-builder.php: Synthesizes visual prompts from article
  title+content and Reddit source data. Appends CEO-locked 90s infomercial
  style suffix. Keeps prompts concise for FLUX.1 quality.
- class-image-media-sideloader.php: Downloads image bytes, creates temp
  file, sideloads via media_handle_sideload(), sets alt text and metadata,
  cleans up temp files.

Changes to existing files:
- Publisher.attach_generated_images(): Runs image pipeline post-creation,
  sets _thumbnail_id (Image A) and _prautoblogger_image_b_id (Image B).
  Errors logged but don't block post publication.
- Settings: prautoblogger_image_enabled (toggle, default off).

Tests:
- ImagePipelineTest.php: Orchestration, cost tracking, graceful failures.
- ImagePromptBuilderTest.php: Prompt generation, length limits.

Docs:
- ARCHITECTURE.md: New step 6b in data flow, new files in file tree.
- CHANGELOG.md: Commit 1a/1b breakdown.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 16:43:58 +07:00
8 changed files with 28 additions and 130 deletions

View File

@@ -134,65 +134,6 @@ class PRAutoBlogger_Cost_Tracker {
return round( $monthly_spend, 4 ) >= round( $budget, 4 );
}
/**
* Log an image generation API call with a known cost.
*
* Unlike log_api_call() which calculates cost from token counts, image
* generation uses per-megapixel pricing that the image provider already
* computed. This method accepts the pre-calculated cost directly.
*
* @param float $cost_usd Estimated cost in USD (from image provider pricing).
* @param string $model Image model alias (e.g. 'flux-1-schnell').
* @param int $post_id WordPress post ID the image is attached to.
* @param string $stage Pipeline stage ('image_a' or 'image_b').
* @return void
*/
public function log_image_generation( float $cost_usd, string $model, int $post_id, string $stage ): void {
$this->current_run_cost += $cost_usd;
global $wpdb;
if ( null === $wpdb ) {
return;
}
$table = $wpdb->prefix . 'prautoblogger_generation_log';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->insert( $table, [
'post_id' => $post_id,
'run_id' => $this->run_id,
'stage' => $stage,
'provider' => 'cloudflare-workers-ai',
'model' => $model,
'prompt_tokens' => 0,
'completion_tokens' => 0,
'estimated_cost' => $cost_usd,
'response_status' => 'success',
'error_message' => '',
'created_at' => current_time( 'mysql' ),
] );
}
/**
* Check if adding an estimated cost would exceed the monthly budget.
*
* Unlike is_budget_exceeded() which checks current state, this method
* proactively checks whether a planned expenditure would push spend
* over the budget. Used by the image pipeline to pre-check before
* making an API call.
*
* @param float $estimated_cost_usd Estimated cost in USD for the planned operation.
* @return bool True if (current spend + estimated cost) >= configured budget.
*/
public function would_exceed_budget( float $estimated_cost_usd ): bool {
$budget = (float) get_option( 'prautoblogger_monthly_budget_usd', 50.00 );
if ( $budget <= 0 ) {
return false;
}
$projected = $this->get_monthly_spend() + $estimated_cost_usd;
return round( $projected, 4 ) >= round( $budget, 4 );
}
/**
* Get total estimated spend for the current calendar month.
*

View File

@@ -38,7 +38,7 @@ class PRAutoBlogger_Image_Media_Sideloader {
*
* @return int|\WP_Error Attachment ID on success, WP_Error on failure.
*/
public function sideload_image( array $image_data, int $post_id, string $alt_text = '' ) {
public function sideload_image( array $image_data, int $post_id, string $alt_text = '' ): int | \WP_Error {
// Validate input.
if ( empty( $image_data['bytes'] ) ) {
return new \WP_Error( 'invalid_image_data', 'Image data (bytes) is empty.' );
@@ -97,7 +97,7 @@ class PRAutoBlogger_Image_Media_Sideloader {
*
* @return string|\WP_Error Path to temporary file on success, WP_Error on failure.
*/
private function create_temp_file( string $bytes, string $mime_type ) {
private function create_temp_file( string $bytes, string $mime_type ): string | \WP_Error {
$temp_dir = get_temp_dir();
$filename = 'prautoblogger_img_' . wp_generate_uuid4() . $this->get_extension_for_mime( $mime_type );
$temp_path = $temp_dir . $filename;

View File

@@ -124,9 +124,7 @@ class PRAutoBlogger_Image_Pipeline {
? $image_a_result->get_error_message()
: 'Image A generation produced no attachment ID.';
}
if ( ! is_wp_error( $image_a_result ) ) {
$result['cost_usd'] += $image_a_result['cost_usd'] ?? 0.0;
}
$result['cost_usd'] += $image_a_result['cost_usd'] ?? 0.0;
// Generate Image B (source-driven prompt) if source data is available.
if ( null !== $source_data && ! empty( $source_data ) ) {
@@ -138,9 +136,7 @@ class PRAutoBlogger_Image_Pipeline {
? $image_b_result->get_error_message()
: 'Image B generation produced no attachment ID.';
}
if ( ! is_wp_error( $image_b_result ) ) {
$result['cost_usd'] += $image_b_result['cost_usd'] ?? 0.0;
}
$result['cost_usd'] += $image_b_result['cost_usd'] ?? 0.0;
}
return $result;
@@ -157,7 +153,7 @@ class PRAutoBlogger_Image_Pipeline {
* cost_usd: float,
* }|\WP_Error
*/
private function generate_image_a( int $post_id, array $article_data ) {
private function generate_image_a( int $post_id, array $article_data ): array | \WP_Error {
try {
// Build the article-driven prompt.
$prompt = $this->prompt_builder->build_article_prompt( $article_data );
@@ -223,7 +219,7 @@ class PRAutoBlogger_Image_Pipeline {
* cost_usd: float,
* }|\WP_Error
*/
private function generate_image_b( int $post_id, array $source_data ) {
private function generate_image_b( int $post_id, array $source_data ): array | \WP_Error {
try {
// Build the source-driven prompt.
$prompt = $this->prompt_builder->build_source_prompt( $source_data );

View File

@@ -126,11 +126,6 @@ class PRAutoBlogger_Logger {
}
global $wpdb;
if ( null === $wpdb ) {
// No database available (unit test environment). PHP error_log
// was already called above for warnings/errors; silently skip DB write.
return;
}
$table = $wpdb->prefix . 'prautoblogger_event_log';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery

View File

@@ -55,11 +55,6 @@ if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL' ) ) {
define( 'PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL', 'google/gemini-2.5-flash-lite' );
}
// Default image style suffix used by ImagePromptBuilder and settings UI.
if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX' ) ) {
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX', 'Style: test infomercial style.' );
}
// WordPress database constants used in $wpdb queries.
if ( ! defined( 'ARRAY_A' ) ) {
define( 'ARRAY_A', 'ARRAY_A' );
@@ -97,39 +92,3 @@ if ( ! class_exists( 'WP_Query' ) ) {
}
}
}
// NOTE: Do NOT stub wp_strip_all_tags() or sanitize_text_field() here.
// Brain Monkey / Patchwork manages these stubs in individual tests via
// Functions\when(). Defining them in bootstrap causes Patchwork's
// "DefinedTooEarly" error.
// WP_Error stub — used by model registry and image pipeline tests.
// This is safe to define here because it's a class (not a function),
// so Patchwork doesn't need to intercept its definition.
if ( ! class_exists( 'WP_Error' ) ) {
class WP_Error {
public $errors = [];
public $error_data = [];
public function __construct( $code = '', $message = '', $data = '' ) {
if ( $code ) {
$this->errors[ $code ][] = $message;
if ( $data ) {
$this->error_data[ $code ] = $data;
}
}
}
public function get_error_code() {
$codes = array_keys( $this->errors );
return $codes ? $codes[0] : '';
}
public function get_error_message( $code = '' ) {
if ( ! $code ) {
$code = $this->get_error_code();
}
return isset( $this->errors[ $code ] ) ? $this->errors[ $code ][0] : '';
}
}
}

View File

@@ -18,11 +18,8 @@ class ImagePipelineTest extends BaseTestCase {
protected function setUp(): void {
parent::setUp();
// Mock WordPress functions used by the pipeline and its dependencies.
Functions\when( 'is_wp_error' )->alias( function ( $thing ) {
return $thing instanceof \WP_Error;
} );
Functions\when( 'wp_generate_uuid4' )->justReturn( 'test-uuid' );
// Mock WordPress functions.
Functions\when( 'wp_generate_uuid4' )->returnArg();
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
static $options = [
'prautoblogger_image_enabled' => '1',
@@ -31,12 +28,7 @@ class ImagePipelineTest extends BaseTestCase {
return $options[ $key ] ?? $default;
} );
Functions\when( 'sanitize_text_field' )->returnArg();
Functions\when( 'wp_strip_all_tags' )->alias( function ( $str ) {
return trim( strip_tags( $str ) );
} );
Functions\when( 'update_post_meta' )->justReturn( true );
Functions\when( 'sanitize_title' )->returnArg();
Functions\when( 'esc_html__' )->returnArg();
}
/**
@@ -48,10 +40,13 @@ class ImagePipelineTest extends BaseTestCase {
return $options[ $key ] ?? $default;
} );
$provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class );
$provider->expects( $this->never() )->method( 'generate_image' );
// Mock the logger.
$logger = $this->createMock( \PRAutoBlogger_Logger::class );
$logger->expects( $this->once() )->method( 'info' );
$cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class );
$provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class );
$provider->expects( $this->never() )->method( 'generate_image' );
$cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class );
$pipeline = new \PRAutoBlogger_Image_Pipeline( $provider, $cost_tracker );
$result = $pipeline->generate_and_attach_images( 1, [ 'post_title' => 'Test' ] );
@@ -80,6 +75,7 @@ class ImagePipelineTest extends BaseTestCase {
$cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class );
$cost_tracker->method( 'would_exceed_budget' )->willReturn( false );
$cost_tracker->method( 'log_image_generation' )->willReturn( true );
$pipeline = new \PRAutoBlogger_Image_Pipeline( $provider, $cost_tracker );

View File

@@ -20,7 +20,7 @@ class ImagePromptBuilderTest extends BaseTestCase {
// Mock WordPress functions.
Functions\when( 'wp_strip_all_tags' )->alias( function ( $str ) {
return trim( strip_tags( $str ) );
return wp_strip_all_tags( $str );
} );
Functions\when( 'sanitize_text_field' )->returnArg();
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {

View File

@@ -224,7 +224,8 @@ class OpenRouterModelRegistryTest extends BaseTestCase {
return 'Connection timed out';
};
Functions\when( 'wp_remote_get' )->alias( function () {
return new \WP_Error( 'http_request_failed', 'Connection timed out' );
$e = new \WP_Error_Stub();
return $e;
} );
// Accept that update_option will be called for the stale flag.
@@ -239,3 +240,13 @@ class OpenRouterModelRegistryTest extends BaseTestCase {
}
}
/**
* Minimal WP_Error stub for testing is_wp_error() branches.
*/
if ( ! class_exists( 'WP_Error_Stub' ) ) {
class WP_Error_Stub {
public function get_error_message(): string {
return 'Connection timed out';
}
}
}