v0.8.0: fix silent image failures + NSFW retry + prompt instructions (#97)
* fix(image-pipeline): surface per-image failures in the event log
Two gaps made posts 650/657 appear to silently "lose" their featured
images on 2026-04-20:
1. The batch provider tagged each failed key with ['error' => ...] but
the ERROR log line it emitted didn't identify which key failed, so
operators couldn't tell image_a from image_b in the event log when
one of two parallel requests died.
2. Image_Pipeline::process_image_a/_b folded the per-key error into
$result['errors'] and returned silently, with no WARNING row. Ops
only learned about the miss when the outer catch in the caller fired
— and on the 2026-04-20 incident that outer catch didn't fire, so
nothing was logged at all except an unrelated pricing warning.
Fix:
- class-open-router-image-batch.php: collect_result() takes the request
key as its first argument and every Logger::error message reads
'OpenRouter batch image gen <class> for key "<key>": <detail>'.
Matches the shape of OpenRouter_Image_Support::log_retry.
- class-image-pipeline.php: process_image_a and process_image_b now
emit a Logger::warning('image_pipeline') row whenever $image_data is
missing or carries an error key, in addition to pushing to
$result['errors']. The warning fires whether or not the outer catch
runs.
Test: ImagePipelineTest::test_partial_batch_failure_emits_warning_log_row
mocks $wpdb, runs the pipeline with image_a erroring, and asserts at
least one warning/image_pipeline row was inserted.
No behavior change beyond logging; cost impact zero.
Part of the v0.8.0 image bundle (issues from thread image-mime-bug).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(images): retry Cloudflare NSFW-blocked prompts with a sanitized fallback
Cloudflare's Workers AI FLUX.1 schnell content filter sometimes rejects
peptide-comic prompts as NSFW (HTTP 400, errors[].code === 3030). On
2026-04-20 this produced a silent publish-without-featured-image for
post #650 ("Semaglutide vs Tirzepatide") — the LLM-rewritten scene
tripped the filter and we had no recovery path.
New behaviour: on a 3030 response, the Cloudflare provider throws a
typed PRAutoBlogger_Image_NSFW_Blocked exception. The batch wrapper
catches it and tags the per-slot result with
`error_type: nsfw_blocked`. Image_Pipeline hands the results to a new
PRAutoBlogger_Image_NSFW_Retry helper, which reruns the blocked slot
once with a sanitized fallback prompt (title + style suffix, no LLM
rewrite). On success the retry result replaces the error entry; on a
second block, a WARNING is logged and the pipeline publishes without
that image — same terminal behaviour we had before, just explicit.
Files:
- New providers/class-image-nsfw-blocked.php — typed exception.
- providers/class-cloudflare-image-support.php — is_nsfw_error() +
throw_if_nsfw() helpers.
- providers/class-cloudflare-image-provider.php — calls throw_if_nsfw
inside the 4xx branch; batch catches the typed exception and marks
the result with error_type.
- core/class-image-prompt-builder.php — new public build_fallback_prompt
shim so the retry layer can reach the rule-based fallback without
going through the LLM path.
- New core/class-image-nsfw-retry.php — single-shot retry helper.
- core/class-image-pipeline.php — invokes the retry between batch
completion and per-slot processing.
- admin/class-settings-fields-extended.php — new toggle
`prautoblogger_image_nsfw_retry` (default on) in the Images section.
Tests:
- CloudflareImageProviderTest — 3030 response throws NSFW exception;
batch tags the per-slot result with `error_type: nsfw_blocked`.
- ImagePipelineTest — retry succeeds when provider returns a good
result on the second call; retry is skipped when the setting is off.
Cost impact: worst-case an extra Cloudflare call per NSFW-blocked slot
(~$0.000834). Current incidence ~2% of generations; ~+$0.00002 per
generation amortized. Immaterial.
Uninstall: new option covered by the existing prautoblogger_% LIKE
sweep in uninstall.php.
Part of the v0.8.0 image bundle (issues from thread image-mime-bug).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(images): expose rewriter system prompt as admin setting
CEO feedback: every generated image is anchoring on the cartoon
peptide-with-arms-and-legs character because the hardcoded rewriter
system prompt both lists "anthropomorphized molecules" first in its
primer and ships a few-shot example that literally shows one. The ask
is to give CEO the lever without a code change.
Adds `prautoblogger_image_prompt_instructions` (textarea) in the
Images section. Default value is the existing REWRITER_SYSTEM_PROMPT
constant verbatim, so behaviour is unchanged on upgrade until someone
edits it. `Image_Prompt_Builder::rewrite_via_llm()` reads the option
at call time via a new `resolve_system_prompt()` helper and falls back
to the constant if the option is blank or whitespace-only, so a saved
empty value cannot brick image generation.
Const visibility flipped from `private` to `public` so the settings
field can reference it directly as the field default.
Tests:
- `test_rewrite_uses_setting_when_present` — option set, assert the
LLM's system message equals the option content.
- `test_rewrite_falls_back_to_default_when_setting_empty` — option
whitespace, assert the LLM's system message equals the constant.
Uninstall: new option covered by the existing prautoblogger_% LIKE
sweep in uninstall.php.
Cost impact: none by default. Future CEO edits will change prompt
length; current default is ~180 tokens, budget impact linear from
there.
Part of the v0.8.0 image bundle (thread image-mime-bug, message 03).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(images): collapse Image Provider + Image Model into a single dropdown
Until now, Image Provider and Image Model were two independent selects
in the admin UI. That let the CEO set Model=flux-1-schnell while
Provider was still openrouter — OpenRouter has no such model so every
image gen on posts 650/657 on 2026-04-20 returned HTTP 400, and the
article published without a featured image.
v0.8.0 removes the Image Provider field from the admin UI entirely.
The provider is now derived from the model's entry in the registry
every time the model is saved. A new
PRAutoBlogger_Image_Model_Registry::provider_for() helper centralises
the mapping, so admin save, the on-upgrade migration, and future
callers all read from one place.
On save, PRAutoBlogger_Admin_Page::sanitize_image_model() validates
the submitted slug against the registry:
- Known slug: persist it, and update `prautoblogger_image_provider` to
the paired provider as a side-effect.
- Unknown slug: add_settings_error with a clear message, and return
the previously-saved value so the site stays on a working model.
A one-shot v0.8.0 migration in
PRAutoBlogger::on_check_db_version() derives the provider from the
currently-saved model on first admin_init after the plugin is updated.
This auto-heals sites that were running mismatched pairs before the
upgrade, without requiring the admin to re-open settings.
`prautoblogger_image_provider` option key is preserved — all existing
reads in the pipeline, cost tracker, and validators continue to work
unchanged.
Tests (Services/ImageModelRegistryTest):
- Every registry entry has an id and a provider in {openrouter, cloudflare}.
- provider_for() returns the paired provider for a known slug.
- provider_for() returns '' for unknown/empty slugs.
Cost impact: none.
Migration idempotent via the `prautoblogger_migrated_image_provider_v080`
flag. Uninstall sweeps it along with every other plugin option.
Part of the v0.8.0 image bundle (thread image-mime-bug, Issue 1).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(v0.8.0): version bump + CHANGELOG + ARCHITECTURE.md for image bundle
Bump plugin header and PRAUTOBLOGGER_VERSION 0.7.3 → 0.8.0. Minor bump
because Commit D removes the Image Provider dropdown from admin (a
UX-visible change), not a patch.
CHANGELOG:
- Changed: collapsed Image Provider + Model dropdowns; provider derived.
- Added: Image Prompt Instructions setting; Retry NSFW-Blocked Images
setting.
- Fixed: silent per-image failures in the event log.
ARCHITECTURE.md:
- Stage 6b (Image Pipeline) callout now describes provider derivation
and the NSFW-retry fallback path.
- wp_options table lists the four new/changed keys
(`prautoblogger_image_provider` as derived, new
`prautoblogger_image_prompt_instructions`,
`prautoblogger_image_nsfw_retry`, and the one-shot migration flag
`prautoblogger_migrated_image_provider_v080`).
DB schema unchanged — no PRAUTOBLOGGER_DB_VERSION bump needed.
Final commit of the v0.8.0 image bundle (thread image-mime-bug).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: peptiderepo <peptiderepo@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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