diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8338a78..f2f8951 100755 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -66,10 +66,16 @@ PRAutoBlogger is a WordPress plugin that monitors social media (starting with Re │ ▼ ┌─────────────────────────┐ -│ 6b. Image Pipeline │ (commit 1b) Generates two A/B images: +│ 6b. Image Pipeline │ Generates two A/B images in parallel: │ (optional, if enabled) │ - Image A: article-driven (featured) │ │ - Image B: source-driven (post meta) -│ │ Sideloads to media library, logs costs +│ │ Provider derived from the saved Image Model +│ │ via Image_Model_Registry::provider_for(). +│ │ NSFW-blocked slots (CF error code 3030) are +│ │ retried once with a rule-based fallback +│ │ prompt via Image_NSFW_Retry; a second block +│ │ logs WARNING and publishes without that image. +│ │ Sideloads to media library, logs costs. └────────────┬────────────┘ │ ▼ @@ -431,7 +437,11 @@ All prefixed with `prautoblogger_`: | `prautoblogger_schedule_time` | Daily generation time (HH:MM, default: '03:00') | | `prautoblogger_cloudflare_ai_token` | Encrypted Cloudflare Workers AI API token | | `prautoblogger_cloudflare_account_id` | Cloudflare account UUID (plaintext — identifier, not secret) | -| `prautoblogger_image_model` | Image model alias: `flux-1-schnell` (default) or `flux-1-dev` | +| `prautoblogger_image_model` | Image model slug from `Image_Model_Registry::get_models()`; provider is derived from this on save | +| `prautoblogger_image_provider` | Derived from the chosen model on save (v0.8.0+); not editable in admin UI | +| `prautoblogger_image_prompt_instructions` | System prompt given to the image rewriter LLM (v0.8.0+); falls back to `Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT` when empty | +| `prautoblogger_image_nsfw_retry` | Toggle: retry NSFW-blocked image slots with a rule-based fallback prompt (default: '1') | +| `prautoblogger_migrated_image_provider_v080` | One-shot migration flag — auto-heals mismatched provider/model pairs on first admin_init after v0.8.0 upgrade | | `prautoblogger_article_font_family` | Font family key: 'default', 'inter', 'georgia', 'merriweather', 'lora', 'open_sans', 'roboto', 'system' | | `prautoblogger_article_font_size` | Body font size in px (0 = theme default). Recommended: 16–18. | | `prautoblogger_table_borders` | Toggle: add borders/padding/striping to tables (default: '1') | diff --git a/CHANGELOG.md b/CHANGELOG.md index 782916b..8ac782e 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,46 @@ All notable changes to PRAutoBlogger will be documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project uses [Semantic Versioning](https://semver.org/). +## [0.8.0] — 2026-04-21 + +### Changed + +- **Single Image Model dropdown in admin → Images.** The separate Image + Provider select has been removed. The provider (OpenRouter or + Cloudflare Workers AI) is now derived from the chosen model's entry + in `PRAutoBlogger_Image_Model_Registry` on save, so mismatched + provider/model pairs — the root cause of posts 650 and 657 silently + missing their featured image on 2026-04-20 — are no longer possible. + A one-shot migration runs on first admin_init after upgrade and + auto-heals any existing site where the saved provider and model + disagreed. The `prautoblogger_image_provider` option key is + preserved; all existing reads in the pipeline continue to work. + +### Added + +- **Image Prompt Instructions setting.** The system prompt used to + rewrite articles into SCENE + CAPTION is now editable from admin → + Images. Default preserves prior behavior; edit to reshape the + creative direction without a code change. +- **Retry NSFW-Blocked Images setting** (default on). When the image + provider rejects a prompt as NSFW — Cloudflare Workers AI returns + HTTP 400 with `errors[].code === 3030` on FLUX.1 schnell — + PRAutoBlogger now retries the slot once with a rule-based fallback + prompt (article title + style suffix, no LLM rewrite). On a second + block it logs a WARNING and publishes without that image, matching + existing behaviour for any unrecoverable image failure. + +### Fixed + +- **Silent per-image failures in the event log.** The OpenRouter batch + provider now emits `Logger::error` with the request key for every + per-slot HTTP/cURL/parse failure (previously logged the error but + not which slot it was), and `Image_Pipeline::process_image_a/_b` + emit `Logger::warning` on `['error' => …]` results whether or not + the outer catch fires. Operators can now see NSFW blocks, 4xx + bodies, and timeouts in the admin Event Log without cross- + referencing PHP error_log timestamps. + ## [0.7.3] — 2026-04-20 ### Fixed diff --git a/includes/admin/class-admin-page.php b/includes/admin/class-admin-page.php index 61881cb..f954999 100755 --- a/includes/admin/class-admin-page.php +++ b/includes/admin/class-admin-page.php @@ -279,7 +279,21 @@ class PRAutoBlogger_Admin_Page { if ( in_array( $option_name, $numeric, true ) ) { return is_numeric( $value ) ? $value : 0; } - + if ( 'prautoblogger_image_model' === $option_name ) { + return $this->sanitize_image_model( (string) $value ); + } return sanitize_text_field( (string) $value ); } + + /** Validate model id against the registry and persist the derived provider. */ + private function sanitize_image_model( string $value ): string { + $candidate = sanitize_text_field( $value ); + $provider = PRAutoBlogger_Image_Model_Registry::provider_for( $candidate ); + if ( '' !== $provider ) { + update_option( 'prautoblogger_image_provider', $provider ); + return $candidate; + } + add_settings_error( 'prautoblogger_image_model', 'prautoblogger_image_model_unknown', sprintf( esc_html__( 'Image model "%s" is not in the registry. Keeping the previous selection.', 'prautoblogger' ), esc_html( $candidate ) ) ); + return (string) get_option( 'prautoblogger_image_model', PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL ); + } } diff --git a/includes/admin/class-image-model-registry.php b/includes/admin/class-image-model-registry.php index 92401e2..cd37bd8 100644 --- a/includes/admin/class-image-model-registry.php +++ b/includes/admin/class-image-model-registry.php @@ -80,4 +80,20 @@ class PRAutoBlogger_Image_Model_Registry { ], ]; } + + /** + * Return the provider id for a known model, or empty string if the + * model id is not in the registry. + * + * @param string $model_id Model slug from the admin UI. + * @return string Provider id ('openrouter' | 'cloudflare' | ''). + */ + public static function provider_for( string $model_id ): string { + foreach ( self::get_models() as $model ) { + if ( ( $model['id'] ?? '' ) === $model_id ) { + return (string) ( $model['provider'] ?? '' ); + } + } + return ''; + } } diff --git a/includes/admin/class-settings-fields-extended.php b/includes/admin/class-settings-fields-extended.php index e4fce83..8431457 100644 --- a/includes/admin/class-settings-fields-extended.php +++ b/includes/admin/class-settings-fields-extended.php @@ -210,18 +210,6 @@ class PRAutoBlogger_Settings_Fields_Extended { 'default' => '1', 'description' => __( 'Generate a second image from source data for A/B testing. Disabling saves one image generation + one LLM prompt rewrite per article.', 'prautoblogger' ), ], - [ - 'id' => 'prautoblogger_image_provider', - 'label' => __( 'Image Provider', 'prautoblogger' ), - 'type' => 'select', - 'section' => 'prautoblogger_images', - 'default' => 'openrouter', - 'options' => [ - 'openrouter' => __( 'OpenRouter (multiple models available)', 'prautoblogger' ), - 'cloudflare' => __( 'Cloudflare Workers AI', 'prautoblogger' ), - ], - 'description' => __( 'OpenRouter reuses your existing API key and offers higher-quality models. Cloudflare is cheaper but lower quality.', 'prautoblogger' ), - ], [ 'id' => 'prautoblogger_image_model', 'label' => __( 'Image Model', 'prautoblogger' ), @@ -229,7 +217,7 @@ class PRAutoBlogger_Settings_Fields_Extended { 'section' => 'prautoblogger_images', 'default' => PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL, 'capability' => 'image_generation', - 'description' => __( 'Pick a model from your selected provider.', 'prautoblogger' ), + 'description' => __( 'Pick an image model. The provider (OpenRouter or Cloudflare Workers AI) is derived from the model registry on save, so mismatched pairs are no longer possible.', 'prautoblogger' ), 'badge' => __( 'Quality', 'prautoblogger' ), ], [ @@ -256,6 +244,22 @@ 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_prompt_instructions', + 'label' => __( 'Image Prompt Instructions', 'prautoblogger' ), + 'type' => 'textarea', + 'section' => 'prautoblogger_images', + 'default' => PRAutoBlogger_Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT, + 'description' => __( 'System prompt given to the rewriter LLM that turns each article into a SCENE + CAPTION for the image generator. Changing this reshapes the look of all future images. Leave blank to use the default.', '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/class-prautoblogger.php b/includes/class-prautoblogger.php index 8a9a98a..2a26c16 100644 --- a/includes/class-prautoblogger.php +++ b/includes/class-prautoblogger.php @@ -160,6 +160,20 @@ class PRAutoBlogger { update_option( 'prautoblogger_migrated_gemini_flash_lite', '1' ); } + // One-time migration (v0.8.0): the admin no longer has an independent + // Image Provider dropdown; provider is derived from the image model on + // save. Auto-heal any existing site where the saved provider doesn't + // match the saved model's provider (the root cause of posts 650/657 + // silently missing their featured image on 2026-04-20). Runs once. + if ( ! get_option( 'prautoblogger_migrated_image_provider_v080' ) ) { + $saved_model = (string) get_option( 'prautoblogger_image_model', '' ); + $provider = PRAutoBlogger_Image_Model_Registry::provider_for( $saved_model ); + if ( '' !== $provider ) { + update_option( 'prautoblogger_image_provider', $provider ); + } + update_option( 'prautoblogger_migrated_image_provider_v080', '1' ); + } + // One-time migration (v3): switch to single-panel newspaper comic style. // Replaces both the old infomercial pastiche and the short-lived premium // photography style. Force-update unless the user has a truly custom value. 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 c805621..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 ); @@ -165,7 +171,14 @@ class PRAutoBlogger_Image_Pipeline { private function process_image_a( int $post_id, array $batch_results, array $captions, array &$result ): void { $image_data = $batch_results['image_a'] ?? null; if ( ! $image_data || isset( $image_data['error'] ) ) { - $result['errors'][] = $image_data['error'] ?? 'Image A missing from batch results.'; + $err_msg = $image_data['error'] ?? 'Image A missing from batch results.'; + $result['errors'][] = $err_msg; + // Log at WARNING so the path is visible even if the outer catch in the + // caller doesn't fire. See thread image-mime-bug Issue 2. + PRAutoBlogger_Logger::instance()->warning( + sprintf( 'Image A generation failed for post %d: %s', $post_id, $err_msg ), + 'image_pipeline' + ); return; } @@ -196,7 +209,14 @@ class PRAutoBlogger_Image_Pipeline { private function process_image_b( int $post_id, array $batch_results, array $captions, array &$result ): void { $image_data = $batch_results['image_b'] ?? null; if ( ! $image_data || isset( $image_data['error'] ) ) { - $result['errors'][] = $image_data['error'] ?? 'Image B missing from batch results.'; + $err_msg = $image_data['error'] ?? 'Image B missing from batch results.'; + $result['errors'][] = $err_msg; + // Log at WARNING so the path is visible even if the outer catch in the + // caller doesn't fire. See thread image-mime-bug Issue 2. + PRAutoBlogger_Logger::instance()->warning( + sprintf( 'Image B generation failed for post %d: %s', $post_id, $err_msg ), + 'image_pipeline' + ); return; } diff --git a/includes/core/class-image-prompt-builder.php b/includes/core/class-image-prompt-builder.php index 689776e..cc2d35a 100644 --- a/includes/core/class-image-prompt-builder.php +++ b/includes/core/class-image-prompt-builder.php @@ -21,13 +21,12 @@ declare(strict_types=1); class PRAutoBlogger_Image_Prompt_Builder { /** - * System prompt that teaches the LLM how to write image-gen prompts. - * - * Why this lives here instead of in wp_options: it's engineering-level - * instruction, not a user-facing setting. Changing it should require a - * code review, not an admin-panel click. + * Default system prompt that teaches the LLM how to write image-gen + * prompts. Exposed as a `public const` so the admin-settings layer can + * use it as the default value for `prautoblogger_image_prompt_instructions`. + * The option, when non-empty, wins at call time (see rewrite_via_llm). */ - private const REWRITER_SYSTEM_PROMPT = <<<'PROMPT' + public const REWRITER_SYSTEM_PROMPT = <<<'PROMPT' You are a comedy writer and single-panel cartoon creator, like Gary Larson (The Far Side) meets science humor. Given an article title and summary about peptides, supplements, or biohacking, create a SINGLE-PANEL COMIC concept. Output TWO parts separated by a blank line: @@ -66,18 +65,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 +88,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'; @@ -154,12 +140,13 @@ PROMPT; } try { - $llm = $this->get_llm_provider(); - $model = get_option( 'prautoblogger_analysis_model', PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL ); + $llm = $this->get_llm_provider(); + $model = get_option( 'prautoblogger_analysis_model', PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL ); + $system = $this->resolve_system_prompt(); $result = $llm->send_chat_completion( [ - [ 'role' => 'system', 'content' => self::REWRITER_SYSTEM_PROMPT ], + [ 'role' => 'system', 'content' => $system ], [ 'role' => 'user', 'content' => $user_message ], ], $model, @@ -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. * @@ -279,15 +283,17 @@ PROMPT; ]; } - /** - * Lazy-load the OpenRouter provider. - * - * @return PRAutoBlogger_OpenRouter_Provider - */ + /** Lazy-load the OpenRouter provider. */ private function get_llm_provider(): PRAutoBlogger_OpenRouter_Provider { if ( null === $this->llm ) { $this->llm = new PRAutoBlogger_OpenRouter_Provider(); } return $this->llm; } + + /** Admin option wins; blank falls back to REWRITER_SYSTEM_PROMPT. */ + private function resolve_system_prompt(): string { + $override = (string) get_option( 'prautoblogger_image_prompt_instructions', '' ); + return '' !== trim( $override ) ? $override : self::REWRITER_SYSTEM_PROMPT; + } } 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/includes/providers/class-open-router-image-batch.php b/includes/providers/class-open-router-image-batch.php index 59b40e2..5703f5b 100644 --- a/includes/providers/class-open-router-image-batch.php +++ b/includes/providers/class-open-router-image-batch.php @@ -130,6 +130,7 @@ class PRAutoBlogger_OpenRouter_Image_Batch { $results = []; foreach ( $handles as $key => $entry ) { $results[ $key ] = $this->collect_result( + (string) $key, $entry['handle'], $entry['model'], $entry['req'], @@ -169,21 +170,35 @@ class PRAutoBlogger_OpenRouter_Image_Batch { /** * Parse the result of one cURL handle into image data or error. * + * All failure paths emit `Logger::error` at the point they fold the + * request into an `['error' => ...]` array — tagged with the request + * `$key` so the pipeline can attribute which of the parallel images + * failed (image_a vs image_b) without cross-referencing timestamps. + * + * @param string $key Request key from the batch map (e.g. "image_a"). * @param \CurlHandle $ch Individual cURL handle (completed). * @param string $model Resolved model id. * @param array $req Original request array. * @param float $start Microtime when this handle was created. * * @return array Image data array or `{error: string}`. + * + * Side effects: writes `Logger::error` on any failure path. */ - private function collect_result( $ch, string $model, array $req, float $start ): array { + private function collect_result( string $key, $ch, string $model, array $req, float $start ): array { $latency_ms = (int) ( ( microtime( true ) - $start ) * 1000 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_errno $curl_errno = curl_errno( $ch ); if ( 0 !== $curl_errno ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_error - $msg = sprintf( 'cURL error %d: %s', $curl_errno, curl_error( $ch ) ); + $detail = curl_error( $ch ); + $msg = sprintf( + 'OpenRouter batch image gen cURL error %d for key "%s": %s', + $curl_errno, + $key, + $detail + ); PRAutoBlogger_Logger::instance()->error( $msg, 'openrouter-image-batch' ); return [ 'error' => $msg ]; } @@ -194,7 +209,12 @@ class PRAutoBlogger_OpenRouter_Image_Batch { $raw = (string) curl_multi_getcontent( $ch ); if ( $http_code >= 400 ) { - $msg = sprintf( 'HTTP %d: %s', $http_code, substr( $raw, 0, 300 ) ); + $msg = sprintf( + 'OpenRouter batch image gen HTTP %d for key "%s": %s', + $http_code, + $key, + substr( $raw, 0, 300 ) + ); PRAutoBlogger_Logger::instance()->error( $msg, 'openrouter-image-batch' ); return [ 'error' => $msg ]; } @@ -202,11 +222,13 @@ class PRAutoBlogger_OpenRouter_Image_Batch { try { $image_bytes = $this->support->extract_image_bytes( $raw ); } catch ( \Throwable $e ) { - PRAutoBlogger_Logger::instance()->error( - 'Batch parse error: ' . $e->getMessage(), - 'openrouter-image-batch' + $msg = sprintf( + 'OpenRouter batch image gen parse error for key "%s": %s', + $key, + $e->getMessage() ); - return [ 'error' => $e->getMessage() ]; + PRAutoBlogger_Logger::instance()->error( $msg, 'openrouter-image-batch' ); + return [ 'error' => $msg ]; } return [ diff --git a/prautoblogger.php b/prautoblogger.php index bfb0c11..6a5222a 100644 --- a/prautoblogger.php +++ b/prautoblogger.php @@ -8,7 +8,7 @@ declare(strict_types=1); * Plugin Name: PRAutoBlogger * Plugin URI: https://peptiderepo.com/prautoblogger * Description: Monitors social media for trending topics, generates SEO-friendly blog posts using AI, and publishes them on a daily schedule with full cost tracking and self-improvement metrics. - * Version: 0.7.3 + * Version: 0.8.0 * Requires at least: 6.0 * Requires PHP: 7.4 * Author: PeptideRepo @@ -35,7 +35,7 @@ if ( ! defined( 'ABSPATH' ) ) { | and limits without magic strings. */ -define( 'PRAUTOBLOGGER_VERSION', '0.7.3' ); +define( 'PRAUTOBLOGGER_VERSION', '0.8.0' ); define( 'PRAUTOBLOGGER_DB_VERSION', '1.1.0' ); define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ ); define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); diff --git a/tests/unit/Core/ImagePipelineTest.php b/tests/unit/Core/ImagePipelineTest.php index 7409f7b..043fd19 100644 --- a/tests/unit/Core/ImagePipelineTest.php +++ b/tests/unit/Core/ImagePipelineTest.php @@ -211,6 +211,141 @@ class ImagePipelineTest extends BaseTestCase { $this->assertStringContainsString( 'API timeout', implode( ' ', $result['errors'] ) ); } + /** + * Test that a Logger::warning row is persisted when image_data carries an error. + * + * Covers Issue 2 from the image-mime-bug thread: previously the pipeline + * pushed the error into $result['errors'] silently, with no WARNING-level + * event_log row visible to ops. This regression-guards that a WARNING row + * now fires whether or not the outer catch in the caller triggers. + */ + public function test_partial_batch_failure_emits_warning_log_row(): void { + // Seed Logger's log_level so warnings actually get written. + Functions\when( 'get_option' )->alias( function ( $key, $default = false ) { + static $options = [ + 'prautoblogger_image_enabled' => '1', + 'prautoblogger_log_level' => 'info', + ]; + return $options[ $key ] ?? $default; + } ); + + $mock_wpdb = $this->create_mock_wpdb(); + $captured_rows = []; + $mock_wpdb->method( 'insert' )->willReturnCallback( + function ( $table, $row ) use ( &$captured_rows ) { + $captured_rows[] = $row; + return 1; + } + ); + $GLOBALS['wpdb'] = $mock_wpdb; + + $provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class ); + $provider->method( 'estimate_cost' )->willReturn( 0.05 ); + $provider->method( 'generate_image_batch' )->willReturn( [ + 'image_a' => [ 'error' => 'HTTP 400: flux-1-schnell is not a valid model ID' ], + ] ); + + $cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class ); + $cost_tracker->method( 'would_exceed_budget' )->willReturn( false ); + + $pipeline = new \PRAutoBlogger_Image_Pipeline( $provider, $cost_tracker ); + $pipeline->generate_and_attach_images( 1, [ 'post_title' => 'Test' ], null ); + + $warning_rows = array_filter( + $captured_rows, + static fn( $row ) => ( $row['level'] ?? '' ) === 'warning' + && ( $row['context'] ?? '' ) === 'image_pipeline' + ); + $this->assertNotEmpty( + $warning_rows, + 'Expected at least one image_pipeline WARNING row when image_data has an error key.' + ); + + 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/Core/ImagePromptBuilderTest.php b/tests/unit/Core/ImagePromptBuilderTest.php index 505e704..6f1c1be 100644 --- a/tests/unit/Core/ImagePromptBuilderTest.php +++ b/tests/unit/Core/ImagePromptBuilderTest.php @@ -29,6 +29,21 @@ class ImagePromptBuilderTest extends BaseTestCase { } ); } + /** + * Inject a mock OpenRouter provider into the Prompt_Builder via + * reflection so the rewrite path can be exercised without a real HTTP + * call. Used by the system-prompt override tests below. + */ + private function inject_llm( + \PRAutoBlogger_Image_Prompt_Builder $builder, + $mock_llm + ): void { + $ref = new \ReflectionClass( \PRAutoBlogger_Image_Prompt_Builder::class ); + $prop = $ref->getProperty( 'llm' ); + $prop->setAccessible( true ); + $prop->setValue( $builder, $mock_llm ); + } + /** * Test build_article_prompt() generates a prompt from article title and content. */ @@ -119,4 +134,105 @@ class ImagePromptBuilderTest extends BaseTestCase { // Prompt should be under 500 chars (concept + style suffix). $this->assertLessThan( 500, strlen( $prompt ) ); } + + /** + * When `prautoblogger_image_prompt_instructions` is non-empty, the + * rewriter LLM must receive that content as its system message — + * NOT the hardcoded REWRITER_SYSTEM_PROMPT constant. + */ + public function test_rewrite_uses_setting_when_present(): void { + $custom_system = 'CUSTOM-OVERRIDE: tell the LLM a very different thing.'; + + Functions\when( 'get_option' )->alias( function ( $key, $default = false ) use ( $custom_system ) { + if ( 'prautoblogger_image_prompt_instructions' === $key ) { + return $custom_system; + } + if ( 'prautoblogger_image_style_suffix' === $key ) { + return 'Style: x'; + } + return $default; + } ); + + $captured_messages = []; + $mock_llm = $this->createMock( \PRAutoBlogger_OpenRouter_Provider::class ); + $mock_llm->method( 'send_chat_completion' )->willReturnCallback( + function ( $messages ) use ( &$captured_messages ) { + $captured_messages = $messages; + return [ + 'content' => "A scene.\n\n\"A caption.\"", + 'prompt_tokens' => 10, + 'completion_tokens' => 10, + ]; + } + ); + $mock_llm->method( 'estimate_cost' )->willReturn( 0.0 ); + + // Stub $wpdb so Cost_Tracker::log_api_call inside rewrite_via_llm + // can insert without blowing up. + $mock_wpdb = $this->create_mock_wpdb(); + $mock_wpdb->insert_id = 1; + $mock_wpdb->method( 'insert' )->willReturn( 1 ); + $GLOBALS['wpdb'] = $mock_wpdb; + + $builder = new \PRAutoBlogger_Image_Prompt_Builder(); + $this->inject_llm( $builder, $mock_llm ); + + $builder->build_article_prompt( [ 'post_title' => 'Test', 'post_content' => '

Body.

' ] ); + + unset( $GLOBALS['wpdb'] ); + + $this->assertNotEmpty( $captured_messages, 'Rewriter LLM was not called.' ); + $this->assertSame( 'system', $captured_messages[0]['role'] ?? '' ); + $this->assertSame( $custom_system, $captured_messages[0]['content'] ?? '' ); + } + + /** + * When the option is empty (or whitespace-only), the rewriter must + * fall back to REWRITER_SYSTEM_PROMPT — belt-and-braces so a blank + * save from the admin UI cannot brick image generation. + */ + public function test_rewrite_falls_back_to_default_when_setting_empty(): void { + Functions\when( 'get_option' )->alias( function ( $key, $default = false ) { + if ( 'prautoblogger_image_prompt_instructions' === $key ) { + return ' '; // whitespace — should trigger fallback. + } + if ( 'prautoblogger_image_style_suffix' === $key ) { + return 'Style: x'; + } + return $default; + } ); + + $captured_messages = []; + $mock_llm = $this->createMock( \PRAutoBlogger_OpenRouter_Provider::class ); + $mock_llm->method( 'send_chat_completion' )->willReturnCallback( + function ( $messages ) use ( &$captured_messages ) { + $captured_messages = $messages; + return [ + 'content' => "A scene.\n\n\"A caption.\"", + 'prompt_tokens' => 10, + 'completion_tokens' => 10, + ]; + } + ); + $mock_llm->method( 'estimate_cost' )->willReturn( 0.0 ); + + // Stub $wpdb so Cost_Tracker::log_api_call inside rewrite_via_llm + // can insert without blowing up. + $mock_wpdb = $this->create_mock_wpdb(); + $mock_wpdb->insert_id = 1; + $mock_wpdb->method( 'insert' )->willReturn( 1 ); + $GLOBALS['wpdb'] = $mock_wpdb; + + $builder = new \PRAutoBlogger_Image_Prompt_Builder(); + $this->inject_llm( $builder, $mock_llm ); + + $builder->build_article_prompt( [ 'post_title' => 'Test', 'post_content' => '

Body.

' ] ); + + unset( $GLOBALS['wpdb'] ); + + $this->assertSame( + \PRAutoBlogger_Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT, + $captured_messages[0]['content'] ?? '' + ); + } } 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. diff --git a/tests/unit/Services/ImageModelRegistryTest.php b/tests/unit/Services/ImageModelRegistryTest.php new file mode 100644 index 0000000..f6235c1 --- /dev/null +++ b/tests/unit/Services/ImageModelRegistryTest.php @@ -0,0 +1,69 @@ +assertNotEmpty( $models ); + + foreach ( $models as $model ) { + $this->assertArrayHasKey( 'id', $model ); + $this->assertNotEmpty( $model['id'] ); + $this->assertArrayHasKey( 'provider', $model ); + $this->assertContains( + $model['provider'], + [ 'openrouter', 'cloudflare' ], + sprintf( 'Unexpected provider %s for model %s', (string) $model['provider'], (string) $model['id'] ) + ); + } + } + + /** + * provider_for() returns the paired provider for a known model id. + * This is the core of the v0.8.0 collapsed-dropdown contract. + */ + public function test_provider_for_returns_paired_provider_for_known_model(): void { + $this->assertSame( + 'cloudflare', + \PRAutoBlogger_Image_Model_Registry::provider_for( 'flux-1-schnell' ) + ); + $this->assertSame( + 'openrouter', + \PRAutoBlogger_Image_Model_Registry::provider_for( 'google/gemini-2.5-flash-image' ) + ); + } + + /** + * Unknown model ids return an empty string so callers can distinguish + * "not in registry" from a valid provider and show a settings error. + */ + public function test_provider_for_returns_empty_for_unknown_model(): void { + $this->assertSame( + '', + \PRAutoBlogger_Image_Model_Registry::provider_for( 'nonexistent/model' ) + ); + $this->assertSame( + '', + \PRAutoBlogger_Image_Model_Registry::provider_for( '' ) + ); + } +}