File size: 11,744 Bytes
e29c7ce
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
addec5c
 
e29c7ce
 
1d3bf6d
 
e29c7ce
 
addec5c
 
 
 
e29c7ce
 
 
 
 
 
addec5c
 
e29c7ce
 
 
1d3bf6d
e29c7ce
 
 
 
 
 
 
 
 
 
 
1d3bf6d
e29c7ce
 
 
 
76b9a7f
e29c7ce
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1d3bf6d
e29c7ce
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
773e767
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e29c7ce
 
 
 
 
 
 
 
 
 
773e767
e29c7ce
 
 
 
773e767
 
 
 
 
 
 
 
 
 
e29c7ce
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
773e767
 
 
 
 
e29c7ce
 
 
 
 
 
832430f
 
 
 
 
 
76b9a7f
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
"""
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