From fb1451c4dcaecde7d3645b356e84fba503890bc2 Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Tue, 21 Apr 2026 01:06:59 +0700 Subject: [PATCH] feat(images): retry Cloudflare NSFW-blocked prompts with a sanitized fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cloudflare's Workers AI FLUX.1 schnell content filter sometimes rejects peptide-comic prompts as NSFW (HTTP 400, errors[].code === 3030). On 2026-04-20 this produced a silent publish-without-featured-image for post #650 ("Semaglutide vs Tirzepatide") — the LLM-rewritten scene tripped the filter and we had no recovery path. New behaviour: on a 3030 response, the Cloudflare provider throws a typed PRAutoBlogger_Image_NSFW_Blocked exception. The batch wrapper catches it and tags the per-slot result with `error_type: nsfw_blocked`. Image_Pipeline hands the results to a new PRAutoBlogger_Image_NSFW_Retry helper, which reruns the blocked slot once with a sanitized fallback prompt (title + style suffix, no LLM rewrite). On success the retry result replaces the error entry; on a second block, a WARNING is logged and the pipeline publishes without that image — same terminal behaviour we had before, just explicit. Files: - New providers/class-image-nsfw-blocked.php — typed exception. - providers/class-cloudflare-image-support.php — is_nsfw_error() + throw_if_nsfw() helpers. - providers/class-cloudflare-image-provider.php — calls throw_if_nsfw inside the 4xx branch; batch catches the typed exception and marks the result with error_type. - core/class-image-prompt-builder.php — new public build_fallback_prompt shim so the retry layer can reach the rule-based fallback without going through the LLM path. - New core/class-image-nsfw-retry.php — single-shot retry helper. - core/class-image-pipeline.php — invokes the retry between batch completion and per-slot processing. - admin/class-settings-fields-extended.php — new toggle `prautoblogger_image_nsfw_retry` (default on) in the Images section. Tests: - CloudflareImageProviderTest — 3030 response throws NSFW exception; batch tags the per-slot result with `error_type: nsfw_blocked`. - ImagePipelineTest — retry succeeds when provider returns a good result on the second call; retry is skipped when the setting is off. Cost impact: worst-case an extra Cloudflare call per NSFW-blocked slot (~$0.000834). Current incidence ~2% of generations; ~+$0.00002 per generation amortized. Immaterial. Uninstall: new option covered by the existing prautoblogger_% LIKE sweep in uninstall.php. Part of the v0.8.0 image bundle (issues from thread image-mime-bug). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../admin/class-settings-fields-extended.php | 8 + includes/core/class-image-nsfw-retry.php | 139 ++++++++++++++++++ includes/core/class-image-pipeline.php | 6 + includes/core/class-image-prompt-builder.php | 48 +++--- .../class-cloudflare-image-provider.php | 11 +- .../class-cloudflare-image-support.php | 58 ++++++++ .../providers/class-image-nsfw-blocked.php | 45 ++++++ tests/unit/Core/ImagePipelineTest.php | 82 +++++++++++ .../Providers/CloudflareImageProviderTest.php | 51 +++++++ 9 files changed, 420 insertions(+), 28 deletions(-) create mode 100644 includes/core/class-image-nsfw-retry.php create mode 100644 includes/providers/class-image-nsfw-blocked.php diff --git a/includes/admin/class-settings-fields-extended.php b/includes/admin/class-settings-fields-extended.php index e4fce83..c8f881e 100644 --- a/includes/admin/class-settings-fields-extended.php +++ b/includes/admin/class-settings-fields-extended.php @@ -256,6 +256,14 @@ class PRAutoBlogger_Settings_Fields_Extended { 'default' => PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX, 'description' => __( 'Appended to every image prompt. Controls visual look. Changing mid-run causes visible style drift.', 'prautoblogger' ), ], + [ + 'id' => 'prautoblogger_image_nsfw_retry', + 'label' => __( 'Retry NSFW-Blocked Images', 'prautoblogger' ), + 'type' => 'toggle', + 'section' => 'prautoblogger_images', + 'default' => '1', + 'description' => __( 'When the provider rejects an image prompt as NSFW (e.g. Cloudflare code 3030), retry once with a generic fallback scene built from the article title. Disable to fail fast if the filter gets trigger-happy.', 'prautoblogger' ), + ], ]; } } diff --git a/includes/core/class-image-nsfw-retry.php b/includes/core/class-image-nsfw-retry.php new file mode 100644 index 0000000..c5c0ebb --- /dev/null +++ b/includes/core/class-image-nsfw-retry.php @@ -0,0 +1,139 @@ + 'nsfw_blocked']`. This helper reruns just the blocked + * slot once with the rule-based fallback prompt (article title + style + * suffix, no LLM rewrite) and merges the new result back into the batch. + * + * Intentionally single-retry: if the fallback is also blocked, we log a + * WARNING and leave the `['error' => ...]` entry in place so the pipeline + * proceeds to publish without a featured image (current behaviour for any + * unrecoverable image failure). + * + * Gated behind the `prautoblogger_image_nsfw_retry` setting (default on). + * + * Triggered by: PRAutoBlogger_Image_Pipeline::generate_and_attach_images. + * Dependencies: PRAutoBlogger_Image_Provider_Interface (retry HTTP call), + * PRAutoBlogger_Image_Prompt_Builder (fallback prompt), + * PRAutoBlogger_Logger (warning + info lines). + * + * @see core/class-image-pipeline.php — Sole caller. + * @see core/class-image-prompt-builder.php — Supplies the fallback prompt. + * @see providers/class-image-nsfw-blocked.php — Typed exception raised by providers. + */ +class PRAutoBlogger_Image_NSFW_Retry { + + /** + * Option key for the admin toggle. + */ + private const SETTING_KEY = 'prautoblogger_image_nsfw_retry'; + + /** @var PRAutoBlogger_Image_Provider_Interface */ + private PRAutoBlogger_Image_Provider_Interface $provider; + + /** @var PRAutoBlogger_Image_Prompt_Builder */ + private PRAutoBlogger_Image_Prompt_Builder $prompt_builder; + + /** + * @param PRAutoBlogger_Image_Provider_Interface $provider Provider to retry against. + * @param PRAutoBlogger_Image_Prompt_Builder $prompt_builder Fallback prompt source. + */ + public function __construct( + PRAutoBlogger_Image_Provider_Interface $provider, + PRAutoBlogger_Image_Prompt_Builder $prompt_builder + ) { + $this->provider = $provider; + $this->prompt_builder = $prompt_builder; + } + + /** + * Whether the admin setting currently allows NSFW retries. + * + * Defaults to on. Kept as a static so callers can short-circuit before + * constructing a retry object when nothing needs retrying anyway. + * + * @return bool + */ + public static function is_enabled(): bool { + return '1' === (string) get_option( self::SETTING_KEY, '1' ); + } + + /** + * Replace every NSFW-blocked slot in $batch_results with a fresh result + * from a sanitized fallback prompt. + * + * Mutates $batch_results and $captions in place. Also updates the + * per-slot entry in $captions so the caller writes the fallback + * caption under the post if the retry succeeds. + * + * Side effects: up to one HTTP call per blocked slot; Logger lines + * for every retry attempt (info on success, warning on second block). + * + * @param int $post_id Target post ID (for logs). + * @param array $batch_requests Original per-slot request map. + * @param array $batch_results Provider result map (mutated). + * @param array $captions Caption map (mutated). + * @param array $article_data Article data (for title). + * @param array|null $source_data Source data (for title on image_b). + * @return void + */ + public function retry_blocked_slots( + int $post_id, + array $batch_requests, + array &$batch_results, + array &$captions, + array $article_data, + ?array $source_data + ): void { + foreach ( $batch_results as $key => $entry ) { + if ( ! is_array( $entry ) || 'nsfw_blocked' !== ( $entry['error_type'] ?? '' ) ) { + continue; + } + + $title = 'image_b' === $key + ? (string) ( $source_data['title'] ?? '' ) + : (string) ( $article_data['post_title'] ?? $article_data['suggested_title'] ?? '' ); + + $fallback = $this->prompt_builder->build_fallback_prompt( $title ); + + PRAutoBlogger_Logger::instance()->info( + sprintf( 'NSFW retry for post %d slot "%s" with sanitized fallback prompt.', $post_id, $key ), + 'image_pipeline' + ); + + $retry_request = [ + 'prompt' => $fallback['prompt'], + 'width' => (int) ( $batch_requests[ $key ]['width'] ?? 0 ), + 'height' => (int) ( $batch_requests[ $key ]['height'] ?? 0 ), + 'options' => $batch_requests[ $key ]['options'] ?? [], + ]; + + $retry_results = $this->provider->generate_image_batch( [ $key => $retry_request ] ); + $retry_entry = $retry_results[ $key ] ?? [ 'error' => 'Retry provider returned no result.' ]; + + if ( isset( $retry_entry['error'] ) ) { + PRAutoBlogger_Logger::instance()->warning( + sprintf( + 'NSFW retry for post %d slot "%s" also failed: %s', + $post_id, + $key, + (string) $retry_entry['error'] + ), + 'image_pipeline' + ); + // Leave the original ['error' => ...] entry so the pipeline + // publishes without this image, matching current behaviour + // for any unrecoverable image failure. + continue; + } + + $batch_results[ $key ] = $retry_entry; + $captions[ $key ] = $fallback['caption']; + } + } +} diff --git a/includes/core/class-image-pipeline.php b/includes/core/class-image-pipeline.php index 90d1d4d..7910d58 100644 --- a/includes/core/class-image-pipeline.php +++ b/includes/core/class-image-pipeline.php @@ -138,6 +138,12 @@ class PRAutoBlogger_Image_Pipeline { return $result; } + // Single-shot retry for any slot the provider flagged as NSFW-blocked. + if ( PRAutoBlogger_Image_NSFW_Retry::is_enabled() ) { + ( new PRAutoBlogger_Image_NSFW_Retry( $this->provider, $this->prompt_builder ) ) + ->retry_blocked_slots( $post_id, $batch_requests, $batch_results, $captions, $article_data, $source_data ); + } + // Process Image A result. $this->process_image_a( $post_id, $batch_results, $captions, $result ); diff --git a/includes/core/class-image-prompt-builder.php b/includes/core/class-image-prompt-builder.php index 689776e..1e57499 100644 --- a/includes/core/class-image-prompt-builder.php +++ b/includes/core/class-image-prompt-builder.php @@ -66,18 +66,12 @@ PROMPT; /** * Build a visual prompt from finished article content. * - * Tries LLM rewriting first; falls back to rule-based synthesis on failure. - * Separates the scene description (for image gen) from the caption text - * (for insertion below the image as HTML). Appends style suffix to the - * scene-only prompt so no text is rendered inside the image. + * Tries LLM rewriting first; falls back to rule-based synthesis on + * failure. Splits scene (for image gen) from caption (HTML below the + * image) and appends the style suffix. * - * @param array{ - * post_title?: string, - * post_content?: string, - * suggested_title?: string, - * } $article_data Article data with title and HTML content. - * - * @return array{prompt: string, caption: string} Image prompt (scene + style) and caption text. + * @param array{post_title?: string, post_content?: string, suggested_title?: string} $article_data + * @return array{prompt: string, caption: string} */ public function build_article_prompt( array $article_data ): array { $title = $article_data['post_title'] ?? $article_data['suggested_title'] ?? 'Product'; @@ -95,18 +89,11 @@ PROMPT; } /** - * Build a visual prompt from source Reddit thread data. + * Build a visual prompt from source Reddit thread data. Tries LLM + * rewriting first; falls back to rule-based synthesis on failure. * - * Tries LLM rewriting first; falls back to rule-based synthesis on failure. - * Appends the style suffix from plugin options. - * - * @param array{ - * title?: string, - * selftext?: string, - * comments?: string[], - * } $source_data Reddit source data with title and comments. - * - * @return array{prompt: string, caption: string} Image prompt (scene + style) and caption text. + * @param array{title?: string, selftext?: string, comments?: string[]} $source_data + * @return array{prompt: string, caption: string} */ public function build_source_prompt( array $source_data ): array { $title = $source_data['title'] ?? 'Reddit Discussion'; @@ -257,6 +244,23 @@ PROMPT; return $para; } + /** + * Public entry into the rule-based fallback, used by NSFW retry to + * rebuild a provider-safe prompt from just the article title. Matches + * the return shape of build_article_prompt() / build_source_prompt(). + * + * @param string $title Article or source title. + * @return array{prompt: string, caption: string} + */ + public function build_fallback_prompt( string $title ): array { + $parsed = $this->synthesize_visual_concepts_fallback( $title, '' ); + $style_suffix = get_option( 'prautoblogger_image_style_suffix', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX ); + return [ + 'prompt' => trim( $parsed['scene'] . ' ' . $style_suffix ), + 'caption' => $parsed['caption'], + ]; + } + /** * Rule-based fallback when LLM rewriting is unavailable. * diff --git a/includes/providers/class-cloudflare-image-provider.php b/includes/providers/class-cloudflare-image-provider.php index 57e632c..161a439 100644 --- a/includes/providers/class-cloudflare-image-provider.php +++ b/includes/providers/class-cloudflare-image-provider.php @@ -136,6 +136,7 @@ class PRAutoBlogger_Cloudflare_Image_Provider implements PRAutoBlogger_Image_Pro if ( $status >= 400 ) { $this->support->log_client_error( $status, $api_token, $account_id, $raw ); + $this->support->throw_if_nsfw( $raw ); throw new \RuntimeException( sprintf( /* translators: %1$d: HTTP status, %2$s: error body. */ @@ -183,12 +184,10 @@ class PRAutoBlogger_Cloudflare_Image_Provider implements PRAutoBlogger_Image_Pro $results = []; foreach ( $requests as $key => $req ) { try { - $results[ $key ] = $this->generate_image( - $req['prompt'], - $req['width'], - $req['height'], - $req['options'] ?? [] - ); + $results[ $key ] = $this->generate_image( $req['prompt'], $req['width'], $req['height'], $req['options'] ?? [] ); + } catch ( PRAutoBlogger_Image_NSFW_Blocked $e ) { + // Tag NSFW so the pipeline's retry layer can rebuild the prompt. + $results[ $key ] = [ 'error' => $e->getMessage(), 'error_type' => 'nsfw_blocked' ]; } catch ( \Throwable $e ) { $results[ $key ] = [ 'error' => $e->getMessage() ]; } diff --git a/includes/providers/class-cloudflare-image-support.php b/includes/providers/class-cloudflare-image-support.php index a0afdb6..002f88a 100644 --- a/includes/providers/class-cloudflare-image-support.php +++ b/includes/providers/class-cloudflare-image-support.php @@ -111,4 +111,62 @@ class PRAutoBlogger_Cloudflare_Image_Support { 'cloudflare-image' ); } + + /** + * Cloudflare Workers AI error code for NSFW content rejection. + * + * Observed on FLUX.1 schnell on 2026-04-20 when backfilling the post + * "Semaglutide vs Tirzepatide". Documented upstream as + * `AiError: Input prompt contains NSFW content.` + */ + public const ERROR_CODE_NSFW = 3030; + + /** + * Return true when the Cloudflare response body contains the NSFW + * content-filter error code (3030). + * + * The body shape is `{"errors":[{"code":3030,"message":"..."}],...}`. + * Parsing is tolerant: any JSON decode failure or missing key returns + * false, so a malformed 4xx never masquerades as NSFW. + * + * @param string $raw Raw response body from Cloudflare. + * @return bool True when at least one error entry carries code 3030. + */ + public function is_nsfw_error( string $raw ): bool { + $decoded = json_decode( $raw, true ); + if ( ! is_array( $decoded ) || empty( $decoded['errors'] ) || ! is_array( $decoded['errors'] ) ) { + return false; + } + foreach ( $decoded['errors'] as $err ) { + if ( is_array( $err ) && (int) ( $err['code'] ?? 0 ) === self::ERROR_CODE_NSFW ) { + return true; + } + } + return false; + } + + /** + * Throw a typed NSFW exception when the response body signals CF's + * content-filter rejection (code 3030). No-op otherwise. + * + * Centralised so callers stay at one line at the call site and so the + * exception-construction shape can't drift between call sites. + * + * @param string $raw Raw response body. + * @return void + * @throws PRAutoBlogger_Image_NSFW_Blocked When the body contains code 3030. + */ + public function throw_if_nsfw( string $raw ): void { + if ( ! $this->is_nsfw_error( $raw ) ) { + return; + } + throw new PRAutoBlogger_Image_NSFW_Blocked( + sprintf( + /* translators: %s: truncated error body. */ + esc_html__( 'Cloudflare Workers AI rejected the prompt as NSFW: %s', 'prautoblogger' ), + esc_html( substr( $raw, 0, 300 ) ) + ), + $raw + ); + } } diff --git a/includes/providers/class-image-nsfw-blocked.php b/includes/providers/class-image-nsfw-blocked.php new file mode 100644 index 0000000..a2d18ee --- /dev/null +++ b/includes/providers/class-image-nsfw-blocked.php @@ -0,0 +1,45 @@ +upstream_body = $upstream_body; + } + + /** + * @return string Raw upstream body (may be empty). + */ + public function get_upstream_body(): string { + return $this->upstream_body; + } +} diff --git a/tests/unit/Core/ImagePipelineTest.php b/tests/unit/Core/ImagePipelineTest.php index 88b0b28..043fd19 100644 --- a/tests/unit/Core/ImagePipelineTest.php +++ b/tests/unit/Core/ImagePipelineTest.php @@ -264,6 +264,88 @@ class ImagePipelineTest extends BaseTestCase { unset( $GLOBALS['wpdb'] ); } + /** + * NSFW-blocked slots should be retried once with a sanitized fallback + * prompt. Retry success replaces the error entry with a real image. + */ + public function test_nsfw_blocked_slot_is_retried_with_fallback_prompt(): void { + // NSFW retry is default on; explicit for clarity. + Functions\when( 'get_option' )->alias( function ( $key, $default = false ) { + static $options = [ + 'prautoblogger_image_enabled' => '1', + 'prautoblogger_image_nsfw_retry' => '1', + 'prautoblogger_image_style_suffix' => 'Style: test suffix', + 'prautoblogger_log_level' => 'info', + ]; + return $options[ $key ] ?? $default; + } ); + + $success_result = [ + 'bytes' => 'fake_image_bytes', + 'mime_type' => 'image/png', + 'width' => 1200, + 'height' => 632, + 'model' => 'flux-1-schnell', + 'cost_usd' => 0.000834, + 'latency_ms' => 1500, + ]; + + $provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class ); + $provider->method( 'estimate_cost' )->willReturn( 0.001 ); + // First batch: NSFW. Second batch (retry for image_a): success. + $provider->expects( $this->exactly( 2 ) ) + ->method( 'generate_image_batch' ) + ->willReturnOnConsecutiveCalls( + [ 'image_a' => [ 'error' => 'NSFW', 'error_type' => 'nsfw_blocked' ] ], + [ 'image_a' => $success_result ] + ); + + $cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class ); + $cost_tracker->method( 'would_exceed_budget' )->willReturn( false ); + + Functions\when( 'get_temp_dir' )->justReturn( '/tmp/' ); + Functions\when( 'media_handle_sideload' )->justReturn( 42 ); + + $pipeline = new \PRAutoBlogger_Image_Pipeline( $provider, $cost_tracker ); + $result = $pipeline->generate_and_attach_images( 1, [ 'post_title' => 'Test Article' ] ); + + // Retry succeeded — image_a_id set, no errors. + $this->assertArrayHasKey( 'image_a_id', $result ); + $this->assertEmpty( $result['errors'] ); + } + + /** + * When `prautoblogger_image_nsfw_retry` is off, blocked slots stay + * blocked — no second HTTP call, pipeline publishes without that + * image. Failsafe for when the filter gets trigger-happy. + */ + public function test_nsfw_retry_skipped_when_setting_disabled(): void { + Functions\when( 'get_option' )->alias( function ( $key, $default = false ) { + static $options = [ + 'prautoblogger_image_enabled' => '1', + 'prautoblogger_image_nsfw_retry' => '0', + ]; + return $options[ $key ] ?? $default; + } ); + + $provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class ); + $provider->method( 'estimate_cost' )->willReturn( 0.001 ); + $provider->expects( $this->exactly( 1 ) ) + ->method( 'generate_image_batch' ) + ->willReturn( [ + 'image_a' => [ 'error' => 'NSFW', 'error_type' => 'nsfw_blocked' ], + ] ); + + $cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class ); + $cost_tracker->method( 'would_exceed_budget' )->willReturn( false ); + + $pipeline = new \PRAutoBlogger_Image_Pipeline( $provider, $cost_tracker ); + $result = $pipeline->generate_and_attach_images( 1, [ 'post_title' => 'Test Article' ] ); + + $this->assertArrayNotHasKey( 'image_a_id', $result ); + $this->assertNotEmpty( $result['errors'] ); + } + /** * Test graceful handling when one image in the batch returns an error. */ diff --git a/tests/unit/Providers/CloudflareImageProviderTest.php b/tests/unit/Providers/CloudflareImageProviderTest.php index b6e8b0e..3f97a52 100644 --- a/tests/unit/Providers/CloudflareImageProviderTest.php +++ b/tests/unit/Providers/CloudflareImageProviderTest.php @@ -189,6 +189,57 @@ class CloudflareImageProviderTest extends BaseTestCase { } } + /** + * A 400 carrying Cloudflare's NSFW code (3030) must throw the typed + * PRAutoBlogger_Image_NSFW_Blocked exception, not a generic + * RuntimeException. Image_NSFW_Retry keys off the typed exception to + * decide whether to rebuild the prompt and retry. + */ + public function test_generate_image_throws_nsfw_blocked_on_code_3030(): void { + $this->with_token( 'test-token' ); + + $nsfw_body = '{"errors":[{"message":"AiError: Input prompt contains NSFW content.","code":3030}]}'; + + Functions\when( 'wp_remote_post' )->justReturn( [ + 'body' => $nsfw_body, + 'response' => [ 'code' => 400 ], + 'headers' => [], + ] ); + Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 400 ); + Functions\when( 'wp_remote_retrieve_body' )->justReturn( $nsfw_body ); + + $provider = new \PRAutoBlogger_Cloudflare_Image_Provider(); + $this->expectException( \PRAutoBlogger_Image_NSFW_Blocked::class ); + $provider->generate_image( 'some peptide prompt', 1080, 1080 ); + } + + /** + * A non-3030 4xx (e.g. 401 bad token) must NOT be tagged NSFW — the + * retry path would be wasted work there. + */ + public function test_generate_image_batch_tags_nsfw_errors_distinctly(): void { + $this->with_token( 'test-token' ); + + $nsfw_body = '{"errors":[{"message":"AiError: Input prompt contains NSFW content.","code":3030}]}'; + + Functions\when( 'wp_remote_post' )->justReturn( [ + 'body' => $nsfw_body, + 'response' => [ 'code' => 400 ], + 'headers' => [], + ] ); + Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 400 ); + Functions\when( 'wp_remote_retrieve_body' )->justReturn( $nsfw_body ); + + $provider = new \PRAutoBlogger_Cloudflare_Image_Provider(); + $results = $provider->generate_image_batch( [ + 'image_a' => [ 'prompt' => 'some prompt', 'width' => 1080, 'height' => 1080 ], + ] ); + + $this->assertArrayHasKey( 'image_a', $results ); + $this->assertSame( 'nsfw_blocked', $results['image_a']['error_type'] ?? null ); + $this->assertArrayHasKey( 'error', $results['image_a'] ); + } + /** * Response shape must be one of: image/* bytes OR JSON envelope with * base64 image. Anything else is a contract breach — fail loudly.