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>
This commit is contained in:
@@ -256,6 +256,14 @@ 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' ),
|
||||
|
||||
@@ -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:
|
||||
@@ -141,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,
|
||||
@@ -283,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'] ?? ''
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user