Commit 1: editorial image prompt + style-template setting
Pivot the image pipeline from the single-panel comic style to a text-free
editorial scientific illustration, and replace the Style Suffix setting with
an editable Style Template setting. Commit 1 of the in-plugin editorial image
pipeline (no composer yet; pipeline otherwise unchanged).
- New constant PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE (brief §7 editorial
template with the single {{ topic_summary }} token).
- New setting prautoblogger_image_style_template (textarea) replaces the
Style Suffix field; single-token validation on save (brief A5); old
prautoblogger_image_style_suffix mirrored to _deprecated for one cycle and
not deleted (one-time migration prautoblogger_migrated_style_template_v0160).
- REWRITER_SYSTEM_PROMPT now emits a 1-2 sentence editorial topic/mechanism
summary as the SCENE (no comic gag), keeping the CAPTION line for the
unchanged HTML-caption-below-image path; scene/caption parsing contract
preserved.
- New PRAutoBlogger_Image_Template_Filler substitutes the scene into the
template's {{ topic_summary }} slot across all three entry points; strips
control chars + clamps length (A5); degrades safely on missing token or
empty summary so a blank/token-only prompt is never sent to the provider
(A5/A6).
- Version bump 0.15.1 -> 0.16.0 + CHANGELOG + ARCHITECTURE.md updates.
Agent-Session: image-editorial-commit1
This commit is contained in:
@@ -6,13 +6,16 @@ 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
|
||||
* Uses a cheap LLM (via OpenRouter) to distill article content into a concise
|
||||
* 1-2 sentence editorial topic/mechanism summary, then substitutes that summary
|
||||
* into the admin-editable editorial style template (the {{ topic_summary }}
|
||||
* slot) to produce a text-free image-gen prompt. 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_Image_Template_Filler (template substitution),
|
||||
* PRAutoBlogger_Cost_Tracker (logs prompt-rewrite cost),
|
||||
* PRAutoBlogger_Logger (diagnostics).
|
||||
*
|
||||
@@ -27,34 +30,39 @@ class PRAutoBlogger_Image_Prompt_Builder {
|
||||
* 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).
|
||||
*
|
||||
* Editorial pivot (v0.16.0): the model emits a concise topic/mechanism
|
||||
* summary as the SCENE — concrete, single centered focal subject, no text,
|
||||
* no people-as-gag, no logos — which the prompt builder substitutes into
|
||||
* the editorial style template's {{ topic_summary }} slot. The CAPTION line
|
||||
* is preserved unchanged for the existing HTML-caption-below-image path.
|
||||
*/
|
||||
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.
|
||||
You write subject descriptions for a text-free editorial science illustration about peptides, supplements, or biohacking.
|
||||
|
||||
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:
|
||||
Given an article title and summary, 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. Favor CONCRETE, HUMAN-SCALE scenes: people reacting to things, doctors and patients, scientists in a lab, gym bros with peptide vials, partners at a kitchen table, ordinary objects in ordinary rooms. Keep it simple — one clear visual joke, 1-3 characters max.
|
||||
SCENE: A concise 1-2 sentence description of the article's core topic or mechanism, written as the SUBJECT of a single editorial illustration. Name ONE concrete, centered focal subject and the few supporting visual elements around it (e.g. a labelled-free vial and dropper, a stylised receptor on a cell membrane, a molecular chain, a dosing syringe, an organ cross-section). Favor a clear central focal point with margin around it so the image crops well. This is image-gen direction, not prose.
|
||||
|
||||
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.
|
||||
CAPTION: A short, informative caption line (under 15 words) for display as HTML text below the image. Plain and editorial, not a joke.
|
||||
|
||||
Rules:
|
||||
- The joke MUST relate to the article's actual subject matter, not generic science humor
|
||||
- DO NOT personify molecules, peptides, hormones, proteins, or other chemical structures (no "anthropomorphic molecule", no "cartoon hormone"). Image models render anthropomorphized chemistry as incoherent blobs. If the article is about a substance, show a PERSON reacting to or using the substance; if the article is abstract (a mechanism, a process, a concept), fall back on a human observer reacting to its effect.
|
||||
- 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
|
||||
- The SCENE must depict the article's actual subject/mechanism, not a generic science scene.
|
||||
- NO text, words, captions, speech bubbles, labels, or logos described in the SCENE — the illustration is text-free; text is rendered separately.
|
||||
- NO people used as a visual gag and NO cartoon/comic framing. A human anatomical element (a cell, an organ, a hand holding a vial) is fine if it is the legitimate subject.
|
||||
- Do NOT personify molecules, peptides, hormones, or proteins (no "smiling molecule"); depict them as clean stylised diagrams or structures.
|
||||
- Keep the staging simple with one clear focal subject, readable at small sizes.
|
||||
- Output ONLY the scene and caption. No preamble, no explanation.
|
||||
|
||||
Example output format:
|
||||
A muscular gym bro stares, deflated, at a tiny glass vial in his hand while his friend squats a barbell effortlessly in the background.
|
||||
A single glass peptide vial on a clean surface with a fine dropper above it, a faint stylised molecular chain arcing behind as a backdrop, centered with generous negative space.
|
||||
|
||||
"They told me the peptide did the work, not the squats."
|
||||
A concentrated look at how the compound is prepared before use.
|
||||
PROMPT;
|
||||
|
||||
/**
|
||||
* Max tokens for the rewriter response. Comic concepts need more room
|
||||
* for the scene description plus the caption punchline.
|
||||
* Max tokens for the rewriter response. Enough room for the 1-2 sentence
|
||||
* topic/mechanism summary plus the short editorial caption line.
|
||||
*/
|
||||
private const REWRITER_MAX_TOKENS = 180;
|
||||
|
||||
@@ -85,8 +93,9 @@ PROMPT;
|
||||
* 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.
|
||||
* failure. Splits scene (the topic summary, for image gen) from caption
|
||||
* (HTML below the image) and substitutes the scene into the editorial
|
||||
* style template's {{ topic_summary }} slot.
|
||||
*
|
||||
* @param array{post_title?: string, post_content?: string, suggested_title?: string} $article_data
|
||||
* @return array{prompt: string, caption: string}
|
||||
@@ -98,10 +107,8 @@ PROMPT;
|
||||
|
||||
$parsed = $this->rewrite_via_llm( $title, $first_para );
|
||||
|
||||
$style_suffix = $this->get_style_suffix();
|
||||
|
||||
return array(
|
||||
'prompt' => trim( $parsed['scene'] . ' ' . $style_suffix ),
|
||||
'prompt' => PRAutoBlogger_Image_Template_Filler::fill( $parsed['scene'] ),
|
||||
'caption' => $parsed['caption'],
|
||||
);
|
||||
}
|
||||
@@ -120,10 +127,8 @@ PROMPT;
|
||||
|
||||
$parsed = $this->rewrite_via_llm( $title, $context );
|
||||
|
||||
$style_suffix = $this->get_style_suffix();
|
||||
|
||||
return array(
|
||||
'prompt' => trim( $parsed['scene'] . ' ' . $style_suffix ),
|
||||
'prompt' => PRAutoBlogger_Image_Template_Filler::fill( $parsed['scene'] ),
|
||||
'caption' => $parsed['caption'],
|
||||
);
|
||||
}
|
||||
@@ -260,10 +265,9 @@ PROMPT;
|
||||
* @return array{prompt: string, caption: string}
|
||||
*/
|
||||
public function build_fallback_prompt( string $title ): array {
|
||||
$parsed = PRAutoBlogger_Image_Scene_Parser::synthesize_visual_concepts_fallback( $title, '' );
|
||||
$style_suffix = $this->get_style_suffix();
|
||||
$parsed = PRAutoBlogger_Image_Scene_Parser::synthesize_visual_concepts_fallback( $title, '' );
|
||||
return array(
|
||||
'prompt' => trim( $parsed['scene'] . ' ' . $style_suffix ),
|
||||
'prompt' => PRAutoBlogger_Image_Template_Filler::fill( $parsed['scene'] ),
|
||||
'caption' => $parsed['caption'],
|
||||
);
|
||||
}
|
||||
@@ -281,20 +285,4 @@ PROMPT;
|
||||
$override = (string) get_option( 'prautoblogger_image_prompt_instructions', '' );
|
||||
return '' !== trim( $override ) ? $override : self::REWRITER_SYSTEM_PROMPT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image style suffix with empty-string safeguard.
|
||||
*
|
||||
* Treats both absent and empty string as "use default" to prevent
|
||||
* silent style loss if the admin settings field is saved empty.
|
||||
*
|
||||
* @return string The style suffix with safe fallback.
|
||||
*/
|
||||
private function get_style_suffix(): string {
|
||||
$style_suffix = (string) get_option( 'prautoblogger_image_style_suffix', '' );
|
||||
if ( '' === trim( $style_suffix ) ) {
|
||||
$style_suffix = PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX;
|
||||
}
|
||||
return $style_suffix;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,22 +70,25 @@ class PRAutoBlogger_Image_Scene_Parser {
|
||||
/**
|
||||
* Rule-based fallback when LLM rewriting is unavailable.
|
||||
*
|
||||
* Kept deliberately simple — this only runs if OpenRouter is down.
|
||||
* Kept deliberately simple — this only runs if OpenRouter is down. Produces
|
||||
* a text-free editorial-illustration subject (not a comic) so the prompt
|
||||
* builder always has a non-empty topic summary to substitute into the
|
||||
* style template, never shipping a blank prompt to the provider (brief A6).
|
||||
*
|
||||
* @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.
|
||||
*/
|
||||
public static function synthesize_visual_concepts_fallback( string $title, string $supporting_text ): array {
|
||||
// Fallback produces a simple comic concept when the LLM is unavailable.
|
||||
// Fallback produces a simple editorial-illustration subject when the LLM is unavailable.
|
||||
$scene = sprintf(
|
||||
'A cartoon scientist in a lab coat looking bewildered while examining something related to: %s.',
|
||||
'A clean, centered editorial science illustration representing the topic of %s, with a single clear focal subject and generous negative space.',
|
||||
$title
|
||||
);
|
||||
|
||||
return array(
|
||||
'scene' => trim( $scene ),
|
||||
'caption' => 'Science is full of surprises.',
|
||||
'caption' => '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
185
includes/core/class-image-template-filler.php
Normal file
185
includes/core/class-image-template-filler.php
Normal file
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Fills the admin-editable editorial image-prompt template with the
|
||||
* rewriter-produced topic summary, replacing the old "scene + style suffix"
|
||||
* concatenation.
|
||||
*
|
||||
* The template (option `prautoblogger_image_style_template`, default
|
||||
* PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE) contains exactly one
|
||||
* `{{ topic_summary }}` token. This class:
|
||||
* - resolves the active template (admin override wins; blank → default),
|
||||
* - sanitises and length-clamps the topic summary (brief A5),
|
||||
* - substitutes the token, and
|
||||
* - degrades safely (brief A5/A6) so a blank or token-only prompt is never
|
||||
* emitted to the image provider.
|
||||
*
|
||||
* Who calls it: PRAutoBlogger_Image_Prompt_Builder (static call from each of
|
||||
* build_article_prompt / build_source_prompt / build_fallback_prompt).
|
||||
* Dependencies: PRAutoBlogger_Logger (diagnostics only).
|
||||
*
|
||||
* @see core/class-image-prompt-builder.php — Sole caller.
|
||||
* @see ARCHITECTURE.md — Image generation data flow.
|
||||
*/
|
||||
class PRAutoBlogger_Image_Template_Filler {
|
||||
|
||||
/** The single substitution token the template must contain. */
|
||||
public const TOKEN = '{{ topic_summary }}';
|
||||
|
||||
/**
|
||||
* Max characters retained from the rewriter topic summary before it is
|
||||
* substituted into the template. Keeps the final provider prompt bounded
|
||||
* even if the rewriter ignores its length guidance.
|
||||
*/
|
||||
public const MAX_SUMMARY_CHARS = 320;
|
||||
|
||||
/**
|
||||
* Build the final image-generation prompt from a rewriter scene.
|
||||
*
|
||||
* @param string $scene Raw topic/mechanism summary from the rewriter (the
|
||||
* "scene" half of the scene/caption contract).
|
||||
* @return string Provider-ready prompt; never empty.
|
||||
*/
|
||||
public static function fill( string $scene ): string {
|
||||
$template = self::resolve_template();
|
||||
$summary = self::sanitize_summary( $scene );
|
||||
|
||||
// Brief A6: never ship a blank or token-only prompt. If the rewriter
|
||||
// produced nothing usable, return the template with the token removed
|
||||
// only as a last resort — but the caller guards this by passing a
|
||||
// rule-based fallback scene, so an empty summary here is unexpected.
|
||||
if ( '' === $summary ) {
|
||||
PRAutoBlogger_Logger::instance()->warning(
|
||||
'Editorial prompt fill received an empty topic summary; emitting style-only prompt.',
|
||||
'image_template_filler'
|
||||
);
|
||||
return self::strip_token( $template );
|
||||
}
|
||||
|
||||
$token_count = substr_count( $template, self::TOKEN );
|
||||
|
||||
if ( 1 === $token_count ) {
|
||||
return trim( str_replace( self::TOKEN, $summary, $template ) );
|
||||
}
|
||||
|
||||
// Brief A5: admin removed or duplicated the token. Fall back to
|
||||
// appending the summary to the style text and log a warning rather
|
||||
// than shipping a malformed prompt.
|
||||
PRAutoBlogger_Logger::instance()->warning(
|
||||
sprintf(
|
||||
'Image style template has %d "%s" tokens (expected 1); appending summary instead of substituting.',
|
||||
$token_count,
|
||||
self::TOKEN
|
||||
),
|
||||
'image_template_filler'
|
||||
);
|
||||
|
||||
$base = self::strip_token( $template );
|
||||
return trim( $base . ' ' . $summary );
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitise and validate the admin-submitted style template on save.
|
||||
*
|
||||
* Multi-line, so sanitised with sanitize_textarea_field() (not
|
||||
* sanitize_text_field(), which would strip newlines). Per brief A5 the
|
||||
* template must contain exactly one TOKEN; otherwise the previous value is
|
||||
* kept and a settings error is surfaced. A blank submission falls back to
|
||||
* the editorial default so image generation can never be bricked.
|
||||
*
|
||||
* @param string $value Submitted template.
|
||||
* @return string Sanitised, validated template.
|
||||
*/
|
||||
public static function sanitize_for_save( string $value ): string {
|
||||
$clean = sanitize_textarea_field( $value );
|
||||
|
||||
if ( '' === trim( $clean ) ) {
|
||||
return PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE;
|
||||
}
|
||||
|
||||
$token_count = substr_count( $clean, self::TOKEN );
|
||||
if ( 1 !== $token_count ) {
|
||||
add_settings_error(
|
||||
'prautoblogger_image_style_template',
|
||||
'prautoblogger_image_style_template_token',
|
||||
sprintf(
|
||||
/* translators: 1: required token, 2: number of tokens found. */
|
||||
esc_html__( 'The Style Template must contain exactly one %1$s token (found %2$d). Keeping the previous value.', 'prautoblogger' ),
|
||||
esc_html( self::TOKEN ),
|
||||
(int) $token_count
|
||||
)
|
||||
);
|
||||
return (string) get_option( 'prautoblogger_image_style_template', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE );
|
||||
}
|
||||
|
||||
return $clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the active template: admin override wins; blank → default.
|
||||
*
|
||||
* @return string Non-empty template string.
|
||||
*/
|
||||
private static function resolve_template(): string {
|
||||
$override = (string) get_option( 'prautoblogger_image_style_template', '' );
|
||||
if ( '' !== trim( $override ) ) {
|
||||
return $override;
|
||||
}
|
||||
return PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip control characters and clamp length of the rewriter summary
|
||||
* (brief A5). Whitespace runs (including newlines) are collapsed to single
|
||||
* spaces so the summary sits cleanly inside the template.
|
||||
*
|
||||
* @param string $scene Raw rewriter scene.
|
||||
* @return string Sanitised, length-clamped summary (possibly empty).
|
||||
*/
|
||||
private static function sanitize_summary( string $scene ): string {
|
||||
// Remove control characters (C0/C1) except via whitespace collapse below.
|
||||
$clean = preg_replace( '/[\x00-\x1F\x7F]+/u', ' ', $scene );
|
||||
if ( null === $clean ) {
|
||||
// preg_replace can return null on bad UTF-8; degrade conservatively.
|
||||
$clean = preg_replace( '/[\x00-\x1F\x7F]+/', ' ', $scene );
|
||||
$clean = is_string( $clean ) ? $clean : '';
|
||||
}
|
||||
|
||||
// Collapse whitespace runs to single spaces.
|
||||
$collapsed = preg_replace( '/\s+/u', ' ', $clean );
|
||||
$clean = is_string( $collapsed ) ? $collapsed : $clean;
|
||||
$clean = trim( $clean );
|
||||
|
||||
if ( '' === $clean ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Clamp length (multibyte-aware where available).
|
||||
if ( function_exists( 'mb_strlen' ) ) {
|
||||
if ( mb_strlen( $clean ) > self::MAX_SUMMARY_CHARS ) {
|
||||
$clean = rtrim( mb_substr( $clean, 0, self::MAX_SUMMARY_CHARS ) ) . '...';
|
||||
}
|
||||
} elseif ( strlen( $clean ) > self::MAX_SUMMARY_CHARS ) {
|
||||
$clean = rtrim( substr( $clean, 0, self::MAX_SUMMARY_CHARS ) ) . '...';
|
||||
}
|
||||
|
||||
return $clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove every occurrence of the token from a template, tidying the
|
||||
* leftover whitespace so the residual style text is still well-formed.
|
||||
*
|
||||
* @param string $template Template possibly containing the token.
|
||||
* @return string Token-free, trimmed template.
|
||||
*/
|
||||
private static function strip_token( string $template ): string {
|
||||
$stripped = str_replace( self::TOKEN, '', $template );
|
||||
$stripped = preg_replace( '/[ \t]+/', ' ', $stripped );
|
||||
$stripped = is_string( $stripped ) ? $stripped : $template;
|
||||
return trim( $stripped );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user