From 16407dd7c89c01cf3c7d26ab7571c2c755308e54 Mon Sep 17 00:00:00 2001 From: ducoterra Date: Mon, 17 Aug 2026 13:42:05 -0400 Subject: [PATCH] Try to improve prompt adherence for low variety image models --- vibe_bot/llama_wrapper.py | 2 +- vibe_bot/main.py | 80 +++++++++++++++++++++++++++++++++++-- vibe_bot/tests/test_main.py | 61 ++++++++++++++++++++++++++++ 3 files changed, 138 insertions(+), 5 deletions(-) diff --git a/vibe_bot/llama_wrapper.py b/vibe_bot/llama_wrapper.py index 6c789f2..5c3decb 100644 --- a/vibe_bot/llama_wrapper.py +++ b/vibe_bot/llama_wrapper.py @@ -323,7 +323,7 @@ def image_generation( n=n, size=size, model=model, - timeout=120.0, + timeout=300.0, ) except openai.APIConnectionError: return "" diff --git a/vibe_bot/main.py b/vibe_bot/main.py index 5851d45..b3c8475 100644 --- a/vibe_bot/main.py +++ b/vibe_bot/main.py @@ -146,13 +146,17 @@ IMAGE_PROMPT_SYSTEM_PROMPT_TEMPLATE = ( "The final image will use a {layout} canvas, so compose the scene to fit " "that orientation.\n" "Your prompt must cover all of the following as one flowing, descriptive " - "passage:\n" + "passage. Begin with the main subject, described completely in the very " + "first sentence:\n" "- Subject(s): every subject with concrete specifics (species or " "character, age, build, clothing, colors, materials, accessories), its " "exact pose, expression, gaze direction, and its precise position in the " 'frame (for example "centered in the foreground" or "small in the ' 'upper-left background"). State the relative scale of subjects to each ' - "other and to the frame.\n" + "other and to the frame. If the subject is a fusion, hybrid, or anything " + "unusual, the first sentence must state in full what is joined to what " + "and exactly how it looks, and that description must be repeated near " + "the end of the passage.\n" "- Composition and framing: the camera angle (eye-level, low, high, " "bird's-eye), the shot type (extreme close-up, portrait, full body, wide " "establishing shot), the focal point, the arrangement of elements across " @@ -177,8 +181,23 @@ IMAGE_PROMPT_SYSTEM_PROMPT_TEMPLATE = ( '- Be concrete and specific. Never use vague words like "nice", ' '"cool", "epic", or "various" - name exact colors, objects, ' "positions, and quantities.\n" - "- If the request is vague or incomplete, fill in the missing details " - "with coherent choices that fit the request.\n" + "- Be literal. Interpret the user's request exactly as written. Never " + 'rationalize, normalize, or "improve" it: surreal, absurd, or ' + 'anthropomorphic requests are intentional, not mistakes. A "fountain ' + 'pen wearing pants" is an anthropomorphized fountain pen character ' + "wearing pants, not a pen lying next to a pair of pants.\n" + "- Preserve everything the user specified. Fill in details the user did " + "not specify with coherent choices that fit the request, but never " + "alter, drop, or reinterpret what the user did specify.\n" + "- Decompose concepts. The image model lacks world knowledge, so never " + "rely on a name alone for anything it might misrender (mythical " + "creatures, fictional characters, cultural items, animal breeds, " + "instruments, vehicles). Spell out the visual anatomy: silhouette, body " + "parts, materials, and distinguishing features, with explicit " + "disambiguation. A centaur is a single creature with a human torso, " + "arms, and head seamlessly fused to a horse's front half, the horse's " + "four legs extending from the human's waist - one fused body, not a " + "person riding a horse.\n" "- If told to generate an image of yourself, generate a picture of a " "canada goose. If told to generate a picture of 'me', 'myself', or some " "other self reference, generate a picture of a canada goose.\n" @@ -228,6 +247,54 @@ def select_image_layout(user_message: str) -> str: return parse_image_layout(response) +IMAGE_PROMPT_VERIFY_SYSTEM_PROMPT = ( + "You are the final quality check for an image generation prompt. The " + "image model that will use it has no world knowledge: it renders only " + "what is described literally and silently drops anything it does not " + "understand - a prompt that merely names a centaur without describing " + "the fused human-animal body will produce a plain horse. " + "Given the user's original request and the drafted prompt, judge " + "strictly: would the drafted prompt, taken completely literally, " + "produce exactly what the user asked for, including every unusual, " + "mythical, surreal, or anthropomorphic element? " + "Respond with ONLY the single word PASS if it would. Otherwise respond " + "with ONLY a corrected version of the prompt that would produce exactly " + "what the user asked for: one flowing descriptive passage, the " + "subject's full anatomy and every unusual element described explicitly " + "in the first sentence and repeated near the end, no other text." +) + + +def verify_image_prompt(user_message: str, image_prompt: str) -> str: + """Check the drafted prompt literally produces the user's request. + + Args: + user_message: The user's original image request. + image_prompt: The drafted image generation prompt. + + Returns: + The original prompt when the check passes or the LLM returns an + empty response, otherwise the LLM's corrected prompt. + + """ + check_prompt = f"User request: {user_message}\n\nDrafted prompt: {image_prompt}" + response = llama_wrapper.chat_completion_instruct( + system_prompt=IMAGE_PROMPT_VERIFY_SYSTEM_PROMPT, + user_prompt=check_prompt, + openai_url=CHAT_ENDPOINT, + openai_api_key=CHAT_ENDPOINT_KEY, + model=CHAT_MODEL, + max_tokens=MAX_COMPLETION_TOKENS, + ) + if not response: + return image_prompt + # A passing check is the single word PASS; a correction is a full + # rewritten passage, which is always much longer. + if len(response) <= 50 and re.search(r"\bpass\b", response, re.IGNORECASE): + return image_prompt + return response + + @bot.event async def on_ready() -> None: """Log when the bot is ready and logged in.""" @@ -862,6 +929,11 @@ async def doodlebob(ctx: CommandsContext[Bot], *, message: str) -> None: logger.warning("No image prompt supplied. Check for errors.") return + # Verify the prompt literally produces the user's request; the check + # may return a corrected prompt. + image_prompt = verify_image_prompt(message, image_prompt) + logger.info("Doodlebob final image prompt: %s", image_prompt) + # Alert the user we're generating the image db = get_database() estimated_seconds = db.get_image_generation_time_estimate() diff --git a/vibe_bot/tests/test_main.py b/vibe_bot/tests/test_main.py index f54e1df..7cad75f 100644 --- a/vibe_bot/tests/test_main.py +++ b/vibe_bot/tests/test_main.py @@ -1030,6 +1030,10 @@ def test_image_prompt_system_prompt_covers_key_details() -> None: assert "style" in lowered assert "canada goose" in lowered assert "only the image generation prompt" in lowered + assert "exactly as written" in lowered + assert "fountain pen wearing pants" in lowered + assert "centaur" in lowered + assert "not a person riding a horse" in lowered def test_doodlebob_prompt_rewrite_uses_detailed_system_prompt( @@ -1046,6 +1050,7 @@ def test_doodlebob_prompt_rewrite_uses_detailed_system_prompt( mock_llama_wrapper.chat_completion_instruct.side_effect = [ "landscape", # layout selection "a very detailed prompt", # prompt rewrite + "PASS", # prompt verification ] mock_llama_wrapper.image_generation.return_value = "aW1hZ2U=" mock_database.get_image_generation_time_estimate.return_value = None @@ -1064,6 +1069,55 @@ def test_doodlebob_prompt_rewrite_uses_detailed_system_prompt( ) +def test_verify_image_prompt_passes_unchanged( + mock_llama_wrapper: MagicMock, +) -> None: + """A PASS verdict keeps the original prompt and sends both inputs.""" + import vibe_bot.main as main_module + + mock_llama_wrapper.chat_completion_instruct.return_value = "PASS" + result = main_module.verify_image_prompt("a centaur", "a detailed prompt") + assert result == "a detailed prompt" + call = mock_llama_wrapper.chat_completion_instruct.call_args + assert call.kwargs["system_prompt"] == main_module.IMAGE_PROMPT_VERIFY_SYSTEM_PROMPT + assert "a centaur" in call.kwargs["user_prompt"] + assert "a detailed prompt" in call.kwargs["user_prompt"] + + +def test_verify_image_prompt_case_insensitive_pass( + mock_llama_wrapper: MagicMock, +) -> None: + """A lower-case, punctuated 'pass' verdict also keeps the original.""" + import vibe_bot.main as main_module + + mock_llama_wrapper.chat_completion_instruct.return_value = "pass." + assert main_module.verify_image_prompt("a centaur", "p") == "p" + + +def test_verify_image_prompt_correction_replaces_prompt( + mock_llama_wrapper: MagicMock, +) -> None: + """A non-PASS response is used as the corrected prompt.""" + import vibe_bot.main as main_module + + corrected = ( + "one fused creature, a human torso joined to a horse's front half, " + "human arms raised, standing in a rocky field" + ) + mock_llama_wrapper.chat_completion_instruct.return_value = corrected + assert main_module.verify_image_prompt("a centaur", "a horse") == corrected + + +def test_verify_image_prompt_empty_falls_back( + mock_llama_wrapper: MagicMock, +) -> None: + """An empty verification response falls back to the drafted prompt.""" + import vibe_bot.main as main_module + + mock_llama_wrapper.chat_completion_instruct.return_value = "" + assert main_module.verify_image_prompt("a centaur", "drafted") == "drafted" + + def test_doodlebob_selects_portrait( mock_ctx: MagicMock, mock_llama_wrapper: MagicMock, @@ -1078,6 +1132,7 @@ def test_doodlebob_selects_portrait( mock_llama_wrapper.chat_completion_instruct.side_effect = [ "portrait", # layout selection "a tall portrait of a lighthouse", # prompt rewrite + "PASS", # prompt verification ] mock_llama_wrapper.image_generation.return_value = "aW1hZ2U=" mock_database.get_image_generation_time_estimate.return_value = None @@ -1113,6 +1168,7 @@ def test_doodlebob_selects_landscape( mock_llama_wrapper.chat_completion_instruct.side_effect = [ "landscape", "a wide panoramic coastline", + "PASS", # prompt verification ] mock_llama_wrapper.image_generation.return_value = "aW1hZ2U=" mock_database.get_image_generation_time_estimate.return_value = None @@ -1137,6 +1193,7 @@ def test_doodlebob_malformed_layout_defaults_square( mock_llama_wrapper.chat_completion_instruct.side_effect = [ "banana", # malformed layout "a balanced composition", + "PASS", # prompt verification ] mock_llama_wrapper.image_generation.return_value = "aW1hZ2U=" mock_database.get_image_generation_time_estimate.return_value = None @@ -1163,6 +1220,7 @@ def test_doodlebob_empty_layout_defaults_square( mock_llama_wrapper.chat_completion_instruct.side_effect = [ "", # empty layout response "a balanced composition", + "PASS", # prompt verification ] mock_llama_wrapper.image_generation.return_value = "aW1hZ2U=" mock_database.get_image_generation_time_estimate.return_value = None @@ -1188,6 +1246,7 @@ def test_doodlebob_reports_estimate_and_elapsed( mock_llama_wrapper.chat_completion_instruct.side_effect = [ "square", "a test scene", + "PASS", # prompt verification ] mock_llama_wrapper.image_generation.return_value = "aW1hZ2U=" mock_database.get_image_generation_time_estimate.return_value = 12.34 @@ -1220,6 +1279,7 @@ def test_doodlebob_no_estimate_without_history( mock_llama_wrapper.chat_completion_instruct.side_effect = [ "square", "a test scene", + "PASS", # prompt verification ] mock_llama_wrapper.image_generation.return_value = "aW1hZ2U=" mock_database.get_image_generation_time_estimate.return_value = None @@ -1247,6 +1307,7 @@ def test_doodlebob_failed_generation_not_recorded( mock_llama_wrapper.chat_completion_instruct.side_effect = [ "square", "a test scene", + "PASS", # prompt verification ] mock_llama_wrapper.image_generation.return_value = "" mock_database.get_image_generation_time_estimate.return_value = 8.0