Compare commits
5 Commits
claude/ima
...
claude/v0.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7026e2f0a | ||
|
|
52a8ffbd9e | ||
|
|
0505e50e85 | ||
|
|
fb1451c4dc | ||
|
|
a3ed663bae |
@@ -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') |
|
||||
|
||||
40
CHANGELOG.md
40
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
|
||||
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' ),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
139
includes/core/class-image-nsfw-retry.php
Normal file
139
includes/core/class-image-nsfw-retry.php
Normal file
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Single-shot retry of NSFW-blocked image slots with a sanitized prompt.
|
||||
*
|
||||
* When an image provider (currently only Cloudflare Workers AI) rejects a
|
||||
* prompt via its content filter, its batch result carries
|
||||
* `['error_type' => '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<string, array|null> $batch_results Provider result map (mutated).
|
||||
* @param array<string, string> $captions Caption map (mutated).
|
||||
* @param array<string, mixed> $article_data Article data (for title).
|
||||
* @param array<string, mixed>|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'];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() ];
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
45
includes/providers/class-image-nsfw-blocked.php
Normal file
45
includes/providers/class-image-nsfw-blocked.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Exception raised when an image provider rejects a prompt as NSFW.
|
||||
*
|
||||
* Cloudflare Workers AI returns HTTP 400 with an `errors[].code === 3030`
|
||||
* body when its content filter blocks a prompt. We catch that shape in
|
||||
* the provider and raise this typed exception so the image pipeline can
|
||||
* distinguish a content-filter rejection from a generic 4xx and retry
|
||||
* once with a sanitized fallback prompt.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_Cloudflare_Image_Provider on NSFW 4xx.
|
||||
* Dependencies: None.
|
||||
*
|
||||
* @see providers/class-cloudflare-image-provider.php — Raises this.
|
||||
* @see core/class-image-nsfw-retry.php — Catches + rebuilds prompt.
|
||||
* @see https://developers.cloudflare.com/workers-ai/models/flux-1-schnell/ — Upstream error codes.
|
||||
*/
|
||||
class PRAutoBlogger_Image_NSFW_Blocked extends \RuntimeException {
|
||||
|
||||
/**
|
||||
* The raw upstream response body, kept verbatim for log forensics.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private string $upstream_body;
|
||||
|
||||
/**
|
||||
* @param string $message Human-readable summary.
|
||||
* @param string $upstream_body Raw response body from the provider.
|
||||
* @param \Throwable|null $previous Optional previous exception.
|
||||
*/
|
||||
public function __construct( string $message, string $upstream_body = '', ?\Throwable $previous = null ) {
|
||||
parent::__construct( $message, 0, $previous );
|
||||
$this->upstream_body = $upstream_body;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string Raw upstream body (may be empty).
|
||||
*/
|
||||
public function get_upstream_body(): string {
|
||||
return $this->upstream_body;
|
||||
}
|
||||
}
|
||||
@@ -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 [
|
||||
|
||||
@@ -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__ ) );
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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' => '<p>Body.</p>' ] );
|
||||
|
||||
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' => '<p>Body.</p>' ] );
|
||||
|
||||
unset( $GLOBALS['wpdb'] );
|
||||
|
||||
$this->assertSame(
|
||||
\PRAutoBlogger_Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT,
|
||||
$captured_messages[0]['content'] ?? ''
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
69
tests/unit/Services/ImageModelRegistryTest.php
Normal file
69
tests/unit/Services/ImageModelRegistryTest.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Image_Model_Registry.
|
||||
*
|
||||
* The admin save flow (v0.8.0) collapses Provider and Image Model into a
|
||||
* single dropdown and derives the provider from the model via
|
||||
* Image_Model_Registry::provider_for(). This test pins that mapping down
|
||||
* so a registry edit that breaks the contract fails CI.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Services
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Services;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
|
||||
class ImageModelRegistryTest extends BaseTestCase {
|
||||
|
||||
/**
|
||||
* Every entry returned by get_models() must carry a non-empty id and
|
||||
* a provider of 'openrouter' or 'cloudflare'. If this breaks, the
|
||||
* admin save flow will silently drop the provider derivation.
|
||||
*/
|
||||
public function test_every_registry_entry_has_id_and_known_provider(): void {
|
||||
$models = \PRAutoBlogger_Image_Model_Registry::get_models();
|
||||
$this->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( '' )
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user