* 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>
300 lines
11 KiB
PHP
300 lines
11 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* Builds image prompts from article content or source data.
|
|
*
|
|
* Uses a cheap LLM (via OpenRouter) to distill article content into concise
|
|
* visual scene descriptions optimised for image generation. Falls back to rule-based
|
|
* synthesis if the LLM call fails, so image generation never blocks on a
|
|
* prompt-rewriting outage.
|
|
*
|
|
* Triggered by: PRAutoBlogger_Image_Pipeline during content generation run.
|
|
* Dependencies: PRAutoBlogger_OpenRouter_Provider (LLM call),
|
|
* PRAutoBlogger_Cost_Tracker (logs prompt-rewrite cost),
|
|
* PRAutoBlogger_Logger (diagnostics).
|
|
*
|
|
* @see core/class-image-pipeline.php — Consumes build_article_prompt() and build_source_prompt().
|
|
* @see providers/class-open-router-provider.php — LLM provider used for rewriting.
|
|
* @see ARCHITECTURE.md — Image generation data flow.
|
|
*/
|
|
class PRAutoBlogger_Image_Prompt_Builder {
|
|
|
|
/**
|
|
* 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).
|
|
*/
|
|
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:
|
|
|
|
SCENE: One sentence describing the visual gag — a funny, absurd, or ironic situation related to the article's topic. Use anthropomorphized molecules, lab-coat-wearing animals, befuddled scientists, gym bros encountering peptide science, supplement bottles with personality, or everyday situations with a peptide twist. Keep it simple — one clear visual joke, 1-3 characters max.
|
|
|
|
CAPTION: A short, punchy caption or speech bubble line (under 15 words) that delivers the punchline. The humor should be dry, nerdy, and accessible — the reader should chuckle even if they only half-understand the science.
|
|
|
|
Rules:
|
|
- The joke MUST relate to the article's actual subject matter, not generic science humor
|
|
- Characters can have simple cartoon faces (round heads, dot eyes, expressive eyebrows)
|
|
- Keep the scene physically simple — few objects, clear staging, easy to read at thumbnail size
|
|
- The caption should work as a standalone joke even without the image
|
|
- No logos, watermarks, or branding
|
|
- Output ONLY the scene and caption. No preamble, no explanation.
|
|
|
|
Example output format:
|
|
A muscular gym bro stares in confusion at a tiny vial while a peptide molecule with arms and legs flexes next to him, clearly outperforming him.
|
|
|
|
"I've been doing this wrong for three years."
|
|
PROMPT;
|
|
|
|
/**
|
|
* Max tokens for the rewriter response. Comic concepts need more room
|
|
* for the scene description plus the caption punchline.
|
|
*/
|
|
private const REWRITER_MAX_TOKENS = 180;
|
|
|
|
/**
|
|
* OpenRouter provider for LLM calls. Null until first use.
|
|
*
|
|
* @var PRAutoBlogger_OpenRouter_Provider|null
|
|
*/
|
|
private ?PRAutoBlogger_OpenRouter_Provider $llm = null;
|
|
|
|
/**
|
|
* Build a visual prompt from finished article content.
|
|
*
|
|
* 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
|
|
* @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';
|
|
$content = $article_data['post_content'] ?? '';
|
|
$first_para = $this->extract_first_paragraph( $content );
|
|
|
|
$parsed = $this->rewrite_via_llm( $title, $first_para );
|
|
|
|
$style_suffix = get_option( 'prautoblogger_image_style_suffix', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX );
|
|
|
|
return [
|
|
'prompt' => trim( $parsed['scene'] . ' ' . $style_suffix ),
|
|
'caption' => $parsed['caption'],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Build a visual prompt from source Reddit thread data. Tries LLM
|
|
* rewriting first; falls back to rule-based synthesis on failure.
|
|
*
|
|
* @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';
|
|
$comments = $source_data['comments'] ?? [];
|
|
$context = is_array( $comments ) && ! empty( $comments ) ? $comments[0] : '';
|
|
|
|
$parsed = $this->rewrite_via_llm( $title, $context );
|
|
|
|
$style_suffix = get_option( 'prautoblogger_image_style_suffix', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX );
|
|
|
|
return [
|
|
'prompt' => trim( $parsed['scene'] . ' ' . $style_suffix ),
|
|
'caption' => $parsed['caption'],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Use a cheap LLM to distill title + context into a visual scene + caption.
|
|
*
|
|
* The LLM response contains a SCENE line and a CAPTION line separated by a
|
|
* blank line. This method parses them apart so the scene drives image gen
|
|
* (no text baked in) and the caption is inserted as HTML below the image.
|
|
*
|
|
* Falls back to rule-based synthesis if the LLM call fails for any
|
|
* reason (network, auth, timeout, unexpected response shape).
|
|
*
|
|
* Side effects: one OpenRouter API call; one cost-tracker log entry.
|
|
*
|
|
* @param string $title Article or thread title.
|
|
* @param string $context First paragraph or top comment.
|
|
* @return array{scene: string, caption: string} Scene for image gen, caption for HTML.
|
|
*/
|
|
private function rewrite_via_llm( string $title, string $context ): array {
|
|
$title = trim( sanitize_text_field( $title ) );
|
|
$context = trim( sanitize_text_field( $context ) );
|
|
|
|
// Truncate context to keep prompt tokens low.
|
|
if ( strlen( $context ) > 300 ) {
|
|
$context = substr( $context, 0, 300 ) . '...';
|
|
}
|
|
|
|
$user_message = "Article title: {$title}";
|
|
if ( '' !== $context ) {
|
|
$user_message .= "\n\nSummary: {$context}";
|
|
}
|
|
|
|
try {
|
|
$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' => $system ],
|
|
[ 'role' => 'user', 'content' => $user_message ],
|
|
],
|
|
$model,
|
|
[
|
|
'temperature' => 0.7,
|
|
'max_tokens' => self::REWRITER_MAX_TOKENS,
|
|
]
|
|
);
|
|
|
|
$raw = trim( $result['content'] ?? '' );
|
|
|
|
// Log the rewrite cost so it shows in the analytics dashboard.
|
|
( new PRAutoBlogger_Cost_Tracker() )->log_api_call(
|
|
null,
|
|
'image_prompt_rewrite',
|
|
'openrouter',
|
|
$model,
|
|
$result['prompt_tokens'] ?? 0,
|
|
$result['completion_tokens'] ?? 0
|
|
);
|
|
|
|
$cost = $llm->estimate_cost(
|
|
$model,
|
|
$result['prompt_tokens'] ?? 0,
|
|
$result['completion_tokens'] ?? 0
|
|
);
|
|
|
|
PRAutoBlogger_Logger::instance()->debug(
|
|
sprintf( 'Image prompt rewritten (%d→%d chars, $%.6f): %s', strlen( $user_message ), strlen( $raw ), $cost, substr( $raw, 0, 120 ) ),
|
|
'image_prompt_builder'
|
|
);
|
|
|
|
if ( '' !== $raw ) {
|
|
return $this->parse_scene_and_caption( $raw );
|
|
}
|
|
} catch ( \Throwable $e ) {
|
|
// LLM failure is not fatal — fall back to rule-based synthesis.
|
|
PRAutoBlogger_Logger::instance()->warning(
|
|
sprintf( 'Image prompt LLM rewrite %s, using fallback: %s', get_class( $e ), $e->getMessage() ),
|
|
'image_prompt_builder'
|
|
);
|
|
}
|
|
|
|
return $this->synthesize_visual_concepts_fallback( $title, $context );
|
|
}
|
|
|
|
/**
|
|
* Parse the LLM response into separate scene and caption parts.
|
|
*
|
|
* Expected format from the LLM:
|
|
* Scene description text here.
|
|
*
|
|
* "Caption punchline here."
|
|
*
|
|
* Falls back gracefully: if no blank-line separator is found, the entire
|
|
* response becomes the scene with an empty caption.
|
|
*
|
|
* @param string $raw Raw LLM response text.
|
|
* @return array{scene: string, caption: string}
|
|
*/
|
|
private function parse_scene_and_caption( string $raw ): array {
|
|
// Split on blank line (the format the system prompt requests).
|
|
$parts = preg_split( '/\n\s*\n/', $raw, 2 );
|
|
|
|
if ( count( $parts ) >= 2 ) {
|
|
$scene = trim( $parts[0] );
|
|
$caption = trim( $parts[1] );
|
|
// Strip surrounding quotes from caption — the LLM wraps it in quotes.
|
|
$caption = trim( $caption, '"\'' );
|
|
return [ 'scene' => $scene, 'caption' => $caption ];
|
|
}
|
|
|
|
// No blank-line separator found — treat entire response as scene.
|
|
return [ 'scene' => trim( $raw ), 'caption' => '' ];
|
|
}
|
|
|
|
/**
|
|
* Extract the first paragraph from HTML content.
|
|
*
|
|
* Removes all tags and captures the text up to the first double-newline
|
|
* or 200 characters, whichever comes first.
|
|
*
|
|
* @param string $html HTML content.
|
|
* @return string Plain text of the first paragraph.
|
|
*/
|
|
private function extract_first_paragraph( string $html ): string {
|
|
$text = wp_strip_all_tags( $html );
|
|
$paras = preg_split( '/\n\n+/', $text );
|
|
$para = isset( $paras[0] ) ? trim( $paras[0] ) : '';
|
|
|
|
if ( strlen( $para ) > 200 ) {
|
|
$para = substr( $para, 0, 200 ) . '...';
|
|
}
|
|
|
|
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.
|
|
*
|
|
* Kept deliberately simple — this only runs if OpenRouter is down.
|
|
*
|
|
* @param string $title Main heading / topic.
|
|
* @param string $supporting_text Additional context.
|
|
* @return array{scene: string, caption: string} Scene for image gen, caption for HTML.
|
|
*/
|
|
private function synthesize_visual_concepts_fallback( string $title, string $supporting_text ): array {
|
|
// Fallback produces a simple comic concept when the LLM is unavailable.
|
|
$scene = sprintf(
|
|
'A cartoon scientist in a lab coat looking bewildered while examining something related to: %s.',
|
|
$title
|
|
);
|
|
|
|
return [
|
|
'scene' => trim( $scene ),
|
|
'caption' => 'Science is full of surprises.',
|
|
];
|
|
}
|
|
|
|
/** 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;
|
|
}
|
|
}
|