""" 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