CharacterChatBot / engine /prompt_builder.py
cereeenn120's picture
Seperate categories for each ai characters
76b9a7f
Raw History Blame Contribute Delete
11.7 kB
"""
Prompt builder β€” assembles the full system prompt from all parts.
Build order (each section separated by a blank line):
1. Global rules (format rules, roleplay rules)
2. Character definition (identity, personality, speech, world, examples)
3. Character-specific instructions (system_prompt field in JSON)
4. User persona (optional)
5. Memory (conversation summary + long-term memories)
Then passed as the system message, followed by:
6. Recent conversation history
7. Current user message
"""
import glob
import logging
import os
import re
from pathlib import Path
from typing import Any
from engine.static_files import load_static_json, load_static_text
logger = logging.getLogger(__name__)
ROOT = Path(__file__).resolve().parent.parent
CHARACTERS_DIR = ROOT / "characters"
PROMPTS_DIR = ROOT / "prompts"
ASSETS_DIR = ROOT / "assets"
# ── Character loading ──────────────────────────────────────────────────────────
def load_character(character_id: str) -> dict[str, Any]:
"""Load and return a character dict from characters/{character_id}.json."""
if not isinstance(character_id, str) or not re.fullmatch(r"[a-zA-Z0-9_-]{1,100}", character_id):
raise ValueError("Invalid character ID.")
path = os.path.join(CHARACTERS_DIR, f"{character_id}.json")
if not os.path.exists(path):
raise FileNotFoundError(f"Character file not found: {path}")
return load_static_json(path)
def list_characters() -> list[dict[str, str]]:
"""
Discover all characters from the characters/ directory.
Returns a list of dicts with id, name, and description.
Skips any files that fail to parse (logs a warning instead of raising).
"""
characters = []
for path in sorted(glob.glob(os.path.join(CHARACTERS_DIR, "*.json"))):
try:
data = load_static_json(path)
characters.append({
"id": data.get("id", os.path.splitext(os.path.basename(path))[0]),
"name": data.get("name", "Unknown"),
"description": data.get("description", ""),
"categories": data.get("categories", []),
})
except Exception as e:
logger.warning(f"[Characters] Skipping {path}: {e}")
return characters
def get_avatar_path(character_id: str) -> str | None:
"""
Return the first avatar image path found for a character.
Logs a warning and returns None if no image exists β€” does not raise.
"""
avatar_dir = os.path.join(ASSETS_DIR, "characters", character_id)
for pattern in ("*.png", "*.jpg", "*.jpeg", "*.webp"):
matches = glob.glob(os.path.join(avatar_dir, pattern))
if matches:
return sorted(matches)[0]
logger.warning(
f"[Avatar] No image found for '{character_id}' in '{avatar_dir}'. "
"A placeholder will be shown in the UI."
)
return None
# ── Prompt sections ────────────────────────────────────────────────────────────
def build_global_prompt() -> str:
"""Load the global system prompt from prompts/system_prompt.txt."""
path = os.path.join(PROMPTS_DIR, "system_prompt.txt")
return load_static_text(path).strip()
def build_character_prompt(character: dict[str, Any]) -> str:
"""
Build the character definition block from a character dict.
Covers: identity, personality, speech style, behavior, world, roleplay rules,
and a capped set of example dialogues.
"""
name = character.get("name", "Unknown")
sections = [f"You are {name}."]
# Identity
identity = character.get("identity", {})
if identity:
parts = []
if identity.get("role"):
parts.append(f"Role: {identity['role']}")
if identity.get("background"):
parts.append(f"Background: {identity['background']}")
if parts:
sections.append("IDENTITY\n" + "\n".join(parts))
# Personality
personality = character.get("personality", [])
if personality:
sections.append("PERSONALITY\n" + ", ".join(personality))
# Speech style
speech = character.get("speech_style", {})
if speech:
parts = []
if speech.get("tone"):
parts.append(f"Tone: {', '.join(speech['tone'])}")
if speech.get("formality"):
parts.append(f"Formality: {speech['formality']}")
if speech.get("sentence_style"):
parts.append(f"Sentence style: {speech['sentence_style']}")
flags = []
if speech.get("uses_humor"):
flags.append("humor")
if speech.get("uses_rhetorical_questions"):
flags.append("rhetorical questions")
if speech.get("avoids_excessive_enthusiasm"):
flags.append("avoids excessive enthusiasm")
if flags:
parts.append("Uses: " + ", ".join(flags))
if parts:
sections.append("SPEECH STYLE\n" + "\n".join(parts))
# Behavior
behavior = character.get("behavior", {})
if behavior:
lines = [
f"- {k.replace('_', ' ')}: {v}"
for k, v in behavior.items()
]
sections.append("BEHAVIOR\n" + "\n".join(lines))
# World
world = character.get("world", {})
if world:
parts = []
if world.get("universe"):
parts.append(f"Universe: {world['universe']}")
if world.get("setting"):
parts.append(f"Setting: {world['setting']}")
if world.get("description"):
parts.append(world["description"])
sections.append("WORLD\n" + "\n".join(parts))
# Roleplay rules
roleplay = character.get("roleplay", {})
if roleplay:
parts = []
if roleplay.get("action_format"):
parts.append(f"Action format: {roleplay['action_format']}")
if roleplay.get("thought_format"):
parts.append(f"Thought format: {roleplay['thought_format']}")
if roleplay.get("break_character") is False:
parts.append("Never break character.")
if parts:
sections.append("ROLEPLAY RULES\n" + "\n".join(parts))
# Example dialogues (capped at 4 to manage token budget)
examples = character.get("example_dialogues", [])
if examples:
lines = ["EXAMPLE DIALOGUES"]
for ex in examples[:4]:
lines.append(f'User: {ex["user"]}')
lines.append(f'{name}: {ex["character"]}')
lines.append("")
sections.append("\n".join(lines).strip())
return "\n\n".join(sections)
def build_character_specific_prompt(character: dict[str, Any]) -> str:
"""
Return the character's system_prompt field.
This is character-specific fine-tuning on top of the global rules β€”
it does not replace them.
"""
sp = character.get("system_prompt", "").strip()
if sp:
return f"ADDITIONAL CHARACTER NOTES\n{sp}"
return ""
def build_persona_prompt(persona: dict[str, str] | None) -> str:
"""
Build the user persona section.
Returns an empty string if persona is absent or all fields are blank.
"""
if not persona or not any(persona.values()):
return ""
lines = ["THE USER IS PLAYING THE ROLE OF:"]
for key in ("name", "age", "gender", "personality", "background"):
val = persona.get(key, "").strip()
if val:
lines.append(f"{key.capitalize()}: {val}")
return "\n".join(lines)
def build_memory_prompt(summary: str, long_term_memory: list[dict]) -> str:
"""Build the memory section from conversation summary and long-term memories."""
parts = []
if summary:
parts.append(f"CONVERSATION SUMMARY\n{summary}")
if long_term_memory:
lines = ["LONG-TERM MEMORY"]
for mem in long_term_memory:
importance = mem.get("importance", "medium").upper()
content = mem.get("content", "")
lines.append(f"[{importance}] {content}")
parts.append("\n".join(lines))
return "\n\n".join(parts)
# ── Language instruction ───────────────────────────────────────────────────────
# Maps lowercase language name β†’ display label (native name in parentheses)
_LANG_LABELS: dict[str, str] = {
"turkish": "Turkish (TΓΌrkΓ§e)",
"french": "French (FranΓ§ais)",
"german": "German (Deutsch)",
"italian": "Italian (Italiano)",
"russian": "Russian (Русский)",
"chinese": "Chinese (δΈ­ζ–‡)",
}
def build_language_prompt(language: str) -> str:
"""
Return a language-enforcement instruction for the system prompt.
Returns an empty string for English (the default behaviour of most models).
"""
key = language.strip().lower()
if not key or key == "english":
return ""
label = _LANG_LABELS.get(key, language)
return (
f"LANGUAGE\n"
f"You must always respond in {label}. "
f"This applies to all dialogue, actions, and internal thoughts β€” "
f"regardless of what language the user writes in."
)
# ── Final assembly ─────────────────────────────────────────────────────────────
def build_messages(
character: dict[str, Any],
persona: dict[str, str] | None,
summary: str,
long_term_memory: list[dict],
recent_messages: list[dict],
current_message: str,
language: str = "English",
) -> list[dict[str, str]]:
"""
Assemble the full messages array to send to the LLM.
System prompt build order:
1. Global rules (format rules, roleplay rules)
2. Character definition (identity, personality, speech, world, examples)
3. Character-specific instructions (system_prompt field in JSON)
4. User persona (optional)
5. Memory (summary + long-term)
6. Language instruction (omitted for English)
Then:
7. Recent conversation history
8. Current user message
"""
system_sections: list[str] = []
global_prompt = build_global_prompt()
if global_prompt:
system_sections.append(global_prompt)
char_prompt = build_character_prompt(character)
if char_prompt:
system_sections.append(char_prompt)
char_specific = build_character_specific_prompt(character)
if char_specific:
system_sections.append(char_specific)
persona_prompt = build_persona_prompt(persona)
if persona_prompt:
system_sections.append(persona_prompt)
memory_prompt = build_memory_prompt(summary, long_term_memory)
if memory_prompt:
system_sections.append(memory_prompt)
# Language instruction goes last β€” recency helps models follow it reliably
lang_prompt = build_language_prompt(language)
if lang_prompt:
system_sections.append(lang_prompt)
system_content = "\n\n".join(system_sections)
messages: list[dict[str, str]] = [{"role": "system", "content": system_content}]
messages.extend(recent_messages)
messages.append({"role": "user", "content": current_message})
logger.debug(
f"[PromptBuilder] Built: {len(system_content)} chars system | "
f"{len(system_sections)} sections | "
f"{len(recent_messages)} history turns | "
f"language='{language}'"
)
return messages