From 0505e50e858e7b44f5a609d2ec5d3232e496d6be Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Tue, 21 Apr 2026 01:11:15 +0700 Subject: [PATCH] feat(images): expose rewriter system prompt as admin setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../admin/class-settings-fields-extended.php | 8 ++ includes/core/class-image-prompt-builder.php | 30 ++--- tests/unit/Core/ImagePromptBuilderTest.php | 116 ++++++++++++++++++ 3 files changed, 140 insertions(+), 14 deletions(-) diff --git a/includes/admin/class-settings-fields-extended.php b/includes/admin/class-settings-fields-extended.php index c8f881e..390bfce 100644 --- a/includes/admin/class-settings-fields-extended.php +++ b/includes/admin/class-settings-fields-extended.php @@ -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' ), diff --git a/includes/core/class-image-prompt-builder.php b/includes/core/class-image-prompt-builder.php index 1e57499..cc2d35a 100644 --- a/includes/core/class-image-prompt-builder.php +++ b/includes/core/class-image-prompt-builder.php @@ -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; + } } diff --git a/tests/unit/Core/ImagePromptBuilderTest.php b/tests/unit/Core/ImagePromptBuilderTest.php index 505e704..6f1c1be 100644 --- a/tests/unit/Core/ImagePromptBuilderTest.php +++ b/tests/unit/Core/ImagePromptBuilderTest.php @@ -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' => '

Body.

' ] ); + + 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' => '

Body.

' ] ); + + unset( $GLOBALS['wpdb'] ); + + $this->assertSame( + \PRAutoBlogger_Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT, + $captured_messages[0]['content'] ?? '' + ); + } }