210 lines
10 KiB
Python
210 lines
10 KiB
Python
"""Prompt constants and system-prompt assembly helpers.
|
|
|
|
Holds every prompt string the bot sends to the LLM (image layout selection,
|
|
image-prompt engineering, prompt verification) plus the shared response-length
|
|
hint and the ``build_system_prompt`` assembler used by the chat and
|
|
speak-with-bot paths.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from typing import TYPE_CHECKING
|
|
|
|
from vibe_bot.config import (
|
|
IMAGE_GEN_SIZE_LANDSCAPE,
|
|
IMAGE_GEN_SIZE_PORTRAIT,
|
|
IMAGE_GEN_SIZE_SQUARE,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
import discord
|
|
|
|
# Image layout (canvas orientation) selection for doodlebob.
|
|
DEFAULT_IMAGE_LAYOUT = "square"
|
|
VALID_IMAGE_LAYOUTS = ("portrait", "landscape", "square")
|
|
LAYOUT_SIZES: dict[str, str] = {
|
|
"portrait": IMAGE_GEN_SIZE_PORTRAIT,
|
|
"landscape": IMAGE_GEN_SIZE_LANDSCAPE,
|
|
"square": IMAGE_GEN_SIZE_SQUARE,
|
|
}
|
|
|
|
# Shared response-length hint appended to bot system prompts.
|
|
RESPONSE_LENGTH_HINT = "Keep your responses under 2-3 sentences."
|
|
|
|
IMAGE_LAYOUT_SYSTEM_PROMPT = (
|
|
"You decide the aspect ratio (layout) of an image that will be generated "
|
|
"from a user's request. Choose exactly ONE layout from these three options:\n"
|
|
"- portrait: a tall, vertical image (taller than wide). Use for subjects that "
|
|
"are taller than they are wide, such as a single standing person or animal, "
|
|
"a full-body character, a tall building, a skyscraper, a tree, a rocket, or "
|
|
"any vertical composition.\n"
|
|
"- landscape: a wide, horizontal image (wider than tall). Use for scenes that "
|
|
"are wider than they are tall, such as wide landscapes, panoramas, cityscapes, "
|
|
"seas and horizons, battle or group scenes spread out horizontally, or any "
|
|
"horizontal composition.\n"
|
|
"- square: an image that is as wide as it is tall. Use for balanced subjects, "
|
|
"close-ups, faces, single objects, logos, emblems, or whenever no strong tall "
|
|
"or wide orientation is implied.\n"
|
|
"Rules:\n"
|
|
"- Base your choice ONLY on the orientation the content implies.\n"
|
|
"- Respond with ONLY the single word portrait, landscape, or square.\n"
|
|
"- Do NOT include any other text, punctuation, explanation, or reasoning.\n"
|
|
)
|
|
|
|
IMAGE_PROMPT_SYSTEM_PROMPT_TEMPLATE = (
|
|
"You are an expert art director and image-generation prompt engineer. "
|
|
"Convert the user's message into one single, extremely detailed image "
|
|
"generation prompt that will be passed directly to a text-to-image model. "
|
|
"The image model is weak: it guesses at compositions, fumbles rendered "
|
|
"text, and invents details on its own. Your prompt must therefore leave "
|
|
"nothing to interpretation - explicitly describe every visible aspect of "
|
|
"the image so it can be created with extreme precision and detail.\n"
|
|
"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. 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. 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 "
|
|
"the {layout} canvas, and the depth of field.\n"
|
|
"- Text: if the image must contain readable text (titles, signs, "
|
|
"posters, labels, banners, watermarks, logos, captions), quote the EXACT "
|
|
"text verbatim in double quotes with precise capitalization and "
|
|
"punctuation, and specify its font style, color, size, and exact "
|
|
"placement. If the image should contain no text, state that explicitly "
|
|
'("no text anywhere in the image").\n'
|
|
"- Setting and background: the complete environment with concrete "
|
|
"details - location, time of day, weather, and every notable background "
|
|
"and foreground element with its position.\n"
|
|
"- Style and rendering: the art style or medium (for example "
|
|
"photorealistic 35mm photograph, oil painting, watercolor, cel-shaded "
|
|
"anime, pixel art, vector illustration), the color palette with specific "
|
|
"colors, the lighting (source, direction, quality, mood), the overall "
|
|
"atmosphere, and the level of detail.\n"
|
|
'- Finish with concise quality terms such as "highly detailed, sharp '
|
|
'focus".\n'
|
|
"Rules:\n"
|
|
'- Be concrete and specific. Never use vague words like "nice", '
|
|
'"cool", "epic", or "various" - name exact colors, objects, '
|
|
"positions, and quantities.\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"
|
|
"- Respond with ONLY the image generation prompt itself. Do not affirm "
|
|
"the user, do not answer the user's questions, and do not add headings, "
|
|
"labels, numbered lists, or any other text."
|
|
)
|
|
|
|
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 parse_image_layout(response: str) -> str:
|
|
"""Parse an LLM response into a valid image layout.
|
|
|
|
Args:
|
|
response: The raw LLM response text.
|
|
|
|
Returns:
|
|
One of "portrait", "landscape", or "square". Falls back to "square"
|
|
when the response is empty or does not contain a valid layout.
|
|
|
|
"""
|
|
text = response.strip().lower()
|
|
for layout in VALID_IMAGE_LAYOUTS:
|
|
if re.search(rf"\b{layout}\b", text):
|
|
return layout
|
|
return DEFAULT_IMAGE_LAYOUT
|
|
|
|
|
|
def build_system_prompt(personality: str, user_info: str) -> str:
|
|
"""Assemble a custom-bot system prompt with the length hint and user info.
|
|
|
|
Args:
|
|
personality: The base bot personality / system prompt.
|
|
user_info: Preformatted user information to append.
|
|
|
|
Returns:
|
|
The assembled system prompt: personality, a response-length hint, and
|
|
the user information block.
|
|
|
|
"""
|
|
return f"{personality}\n{RESPONSE_LENGTH_HINT}\n\nUser Information:\n{user_info}"
|
|
|
|
|
|
def get_user_info(user: discord.User | discord.Member) -> str:
|
|
"""Format user information for inclusion in bot prompts.
|
|
|
|
Reads only presentation attributes off the (User or Member) object, so it
|
|
has no runtime dependency on the discord package.
|
|
"""
|
|
parts: list[str] = []
|
|
if user.global_name:
|
|
parts.append(f"Global Name: {user.global_name}")
|
|
nick = getattr(user, "nick", None)
|
|
if nick:
|
|
parts.append(f"Nickname: {nick}")
|
|
top_role = getattr(user, "top_role", None)
|
|
if top_role and top_role.name != "@everyone":
|
|
parts.append(f"Top Role: {top_role.name}")
|
|
activities = getattr(user, "activities", None)
|
|
if activities:
|
|
activity_names = [
|
|
getattr(a, "name", str(a))
|
|
for a in activities
|
|
if getattr(a, "name", "") != "custom_status"
|
|
]
|
|
if activity_names:
|
|
parts.append(f"Activities: {', '.join(activity_names)}")
|
|
joined_at = getattr(user, "joined_at", None)
|
|
if joined_at:
|
|
parts.append(f"Joined: {joined_at.strftime('%Y-%m-%d')}")
|
|
parts.append(f"Username: {user.name}")
|
|
parts.append(f"User ID: {user.id}")
|
|
parts.append(
|
|
f"Account Created: {user.created_at.strftime('%Y-%m-%d') if user.created_at else 'Unknown'}"
|
|
)
|
|
return "\n".join(parts)
|