cereeenn120 commited on
Commit
e29c7ce
·
1 Parent(s): 8088f0e

Add chatbot engine and memory system phase-1

Browse files
app.py CHANGED
@@ -1,69 +1,85 @@
 
1
  import gradio as gr
2
  from huggingface_hub import InferenceClient
 
 
3
 
 
 
4
 
5
- def respond(
6
- message,
7
- history: list[dict[str, str]],
8
- system_message,
9
- max_tokens,
10
- temperature,
11
- top_p,
12
- hf_token: gr.OAuthToken,
13
- ):
14
- """
15
- For more information on `huggingface_hub` Inference API support, please check the docs: https://huggingface.co/docs/huggingface_hub/v0.22.2/en/guides/inference
16
- """
17
- client = InferenceClient(token=hf_token.token, model="openai/gpt-oss-20b")
18
 
19
- messages = [{"role": "system", "content": system_message}]
 
 
20
 
21
- messages.extend(history)
22
 
23
- messages.append({"role": "user", "content": message})
 
 
 
 
 
 
 
 
 
 
 
24
 
25
- response = ""
26
 
27
- for message in client.chat_completion(
28
- messages,
 
 
 
 
29
  max_tokens=max_tokens,
30
- stream=True,
31
  temperature=temperature,
32
  top_p=top_p,
33
- ):
34
- choices = message.choices
35
- token = ""
36
- if len(choices) and choices[0].delta.content:
37
- token = choices[0].delta.content
38
-
39
- response += token
40
- yield response
41
 
42
 
43
- """
44
- For information on how to customize the ChatInterface, peruse the gradio docs: https://www.gradio.app/docs/chatinterface
45
- """
46
  chatbot = gr.ChatInterface(
47
- respond,
 
 
 
 
 
 
48
  additional_inputs=[
49
- gr.Textbox(value="You are a friendly Chatbot.", label="System message"),
50
- gr.Slider(minimum=1, maximum=2048, value=512, step=1, label="Max new tokens"),
51
- gr.Slider(minimum=0.1, maximum=4.0, value=0.7, step=0.1, label="Temperature"),
52
- gr.Slider(
53
- minimum=0.1,
54
- maximum=1.0,
55
- value=0.95,
56
- step=0.05,
57
- label="Top-p (nucleus sampling)",
58
- ),
59
  ],
 
60
  )
61
 
62
- with gr.Blocks() as demo:
63
  with gr.Sidebar():
64
  gr.LoginButton()
 
 
 
 
 
 
 
65
  chatbot.render()
66
 
67
 
68
  if __name__ == "__main__":
69
- demo.launch()
 
1
+ import logging
2
  import gradio as gr
3
  from huggingface_hub import InferenceClient
4
+ from engine.chat import generate_response
5
+ from engine.prompt_builder import get_avatar_path, load_character
6
 
7
+ logging.basicConfig(level=logging.INFO)
8
+ logger = logging.getLogger(__name__)
9
 
10
+ # ── Configuration ──────────────────────────────────────────────────────────────
11
+ # Switch CHARACTER_ID to load a different character from characters/*.json
12
+ # Switch MODEL to change the LLM (any model available on HuggingFace Inference API)
13
+ CHARACTER_ID = "jessica_jones"
14
+ MODEL = "openai/gpt-4o-mini" # or e.g. "deepseek-ai/DeepSeek-V3"
15
+
16
+ # ── Load character on startup ──────────────────────────────────────────────────
17
+ character = load_character(CHARACTER_ID)
18
+ avatar_path = get_avatar_path(CHARACTER_ID) # None if missing (warns, doesn't crash)
 
 
 
 
19
 
20
+ char_name = character.get("name", CHARACTER_ID)
21
+ char_role = character.get("identity", {}).get("role", "")
22
+ greeting = character.get("greeting", "Hello. What do you want?")
23
 
 
24
 
25
+ # ── Gradio respond function ────────────────────────────────────────────────────
26
+ def respond(
27
+ message: str,
28
+ history: list[dict], # Gradio 5.x messages format: [{"role": ..., "content": ...}]
29
+ max_tokens: int,
30
+ temperature: float,
31
+ top_p: float,
32
+ hf_token: gr.OAuthToken,
33
+ ):
34
+ if hf_token is None:
35
+ yield "⚠️ Please log in with your HuggingFace account to chat."
36
+ return
37
 
38
+ client = InferenceClient(token=hf_token.token)
39
 
40
+ yield from generate_response(
41
+ message=message,
42
+ character_id=CHARACTER_ID,
43
+ client=client,
44
+ persona=None, # Phase 8: user persona form
45
+ model=MODEL,
46
  max_tokens=max_tokens,
 
47
  temperature=temperature,
48
  top_p=top_p,
49
+ )
 
 
 
 
 
 
 
50
 
51
 
52
+ # ── UI ─────────────────────────────────────────────────────────────────────────
53
+ # Gradio 5.x: ChatInterface defaults to messages format, no 'type' param needed.
54
+ # gr.Chatbot value uses dict format: {"role": "user"|"assistant", "content": "..."}
55
  chatbot = gr.ChatInterface(
56
+ fn=respond,
57
+ chatbot=gr.Chatbot(
58
+ value=[{"role": "assistant", "content": greeting}],
59
+ label=char_name,
60
+ avatar_images=(None, avatar_path),
61
+ height=500,
62
+ ),
63
  additional_inputs=[
64
+ gr.Slider(minimum=1, maximum=2048, value=512, step=1, label="Max new tokens"),
65
+ gr.Slider(minimum=0.1, maximum=4.0, value=0.7, step=0.1, label="Temperature"),
66
+ gr.Slider(minimum=0.1, maximum=1.0, value=0.95, step=0.05, label="Top-p"),
 
 
 
 
 
 
 
67
  ],
68
+ additional_inputs_accordion=gr.Accordion("⚙️ Settings", open=False),
69
  )
70
 
71
+ with gr.Blocks(title=f"Chat with {char_name}") as demo:
72
  with gr.Sidebar():
73
  gr.LoginButton()
74
+ gr.Markdown(f"## {char_name}")
75
+ if char_role:
76
+ gr.Markdown(f"*{char_role}*")
77
+ if avatar_path:
78
+ gr.Image(value=avatar_path, show_label=False, height=280, container=False)
79
+ else:
80
+ gr.Markdown("*No avatar image found.*")
81
  chatbot.render()
82
 
83
 
84
  if __name__ == "__main__":
85
+ demo.launch()
engine/chat.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Chat engine — core conversation loop.
3
+
4
+ Connects prompt_builder + memory + parser into a single streaming generator
5
+ that app.py (or any UI layer) can consume with `yield from`.
6
+
7
+ Separation of concerns:
8
+ - app.py : Gradio UI, OAuth token, slider values
9
+ - chat.py : LLM call, streaming, post-stream memory save
10
+ - prompt_builder.py : system prompt assembly
11
+ - memory.py : file I/O for all three memory layers
12
+ - parser.py : raw response → typed segments → markdown
13
+ """
14
+
15
+ from huggingface_hub import InferenceClient
16
+
17
+ from engine.memory import (
18
+ append_message,
19
+ load_long_term_memory,
20
+ load_recent_messages,
21
+ load_summary,
22
+ )
23
+ from engine.parser import parse_response, segments_to_markdown
24
+ from engine.prompt_builder import build_messages, load_character
25
+
26
+
27
+ def generate_response(
28
+ message: str,
29
+ character_id: str,
30
+ client: InferenceClient,
31
+ persona: dict | None = None,
32
+ model: str = "openai/gpt-4o-mini",
33
+ max_tokens: int = 512,
34
+ temperature: float = 0.7,
35
+ top_p: float = 0.95,
36
+ ):
37
+ """
38
+ Core streaming generator.
39
+
40
+ Yields the accumulated raw response after each token arrives so Gradio
41
+ can display it in real time. After the stream ends, yields one final
42
+ formatted version (actions italic, thoughts em-dashed) and saves
43
+ the exchange to disk memory.
44
+
45
+ Args:
46
+ message: Current user input.
47
+ character_id: Which character to respond as.
48
+ client: Initialised HuggingFace InferenceClient.
49
+ persona: Optional user persona dict (wired up in Phase 8).
50
+ model: HuggingFace model string.
51
+ max_tokens: Maximum tokens to generate.
52
+ temperature: Sampling temperature.
53
+ top_p: Nucleus sampling probability.
54
+ """
55
+ character = load_character(character_id)
56
+ recent_messages = load_recent_messages(character_id)
57
+ summary = load_summary(character_id)
58
+ long_term_memory = load_long_term_memory(character_id)
59
+
60
+ messages = build_messages(
61
+ character=character,
62
+ persona=persona,
63
+ summary=summary,
64
+ long_term_memory=long_term_memory,
65
+ recent_messages=recent_messages,
66
+ current_message=message,
67
+ )
68
+
69
+ raw_response = ""
70
+
71
+ for chunk in client.chat_completion(
72
+ messages,
73
+ model=model,
74
+ max_tokens=max_tokens,
75
+ stream=True,
76
+ temperature=temperature,
77
+ top_p=top_p,
78
+ ):
79
+ choices = chunk.choices
80
+ if choices and choices[0].delta.content:
81
+ raw_response += choices[0].delta.content
82
+ yield raw_response # stream raw text while tokens arrive
83
+
84
+ # ── Post-stream ────────────────────────────────────────────────────────────
85
+ # Save raw text to memory (preserves original markers for future context)
86
+ append_message(character_id, "user", message)
87
+ append_message(character_id, "assistant", raw_response)
88
+
89
+ # Final yield: formatted markdown (actions/thoughts styled)
90
+ segments = parse_response(raw_response)
91
+ formatted = segments_to_markdown(segments)
92
+ yield formatted
engine/memory.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Memory system — manages per-character memory in three layers.
3
+
4
+ recent_messages : list[dict] — last N raw conversation turns (short-term)
5
+ summary : str — rolling LLM-generated summary (mid-term)
6
+ long_term_memory : list[dict] — important facts with importance score (long-term)
7
+
8
+ Each character gets its own directory under memory/{character_id}/.
9
+ All reads return safe defaults if the file doesn't exist yet.
10
+ """
11
+
12
+ import json
13
+ import logging
14
+ import os
15
+ from datetime import datetime, timezone
16
+ from typing import Any
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ MEMORY_DIR = "memory"
21
+ MAX_RECENT = 20 # cap on messages kept in recent_messages.json
22
+
23
+
24
+ # ── Internal helpers ───────────────────────────────────────────────────────────
25
+
26
+ def _memory_dir(character_id: str) -> str:
27
+ """Return (and create if needed) the memory directory for a character."""
28
+ path = os.path.join(MEMORY_DIR, character_id)
29
+ os.makedirs(path, exist_ok=True)
30
+ return path
31
+
32
+
33
+ def _load(path: str, default: Any) -> Any:
34
+ """Load a JSON file; return `default` if the file is missing or unreadable."""
35
+ if not os.path.exists(path):
36
+ return default
37
+ try:
38
+ with open(path, "r", encoding="utf-8") as f:
39
+ return json.load(f)
40
+ except Exception as e:
41
+ logger.warning(f"[Memory] Could not read {path}: {e}")
42
+ return default
43
+
44
+
45
+ def _save(path: str, data: Any) -> None:
46
+ """Write data to a JSON file, pretty-printed."""
47
+ with open(path, "w", encoding="utf-8") as f:
48
+ json.dump(data, f, ensure_ascii=False, indent=2)
49
+
50
+
51
+ def _now() -> str:
52
+ return datetime.now(timezone.utc).isoformat()
53
+
54
+
55
+ # ── Recent messages ────────────────────────────────────────────────────────────
56
+
57
+ def load_recent_messages(character_id: str, max_messages: int = MAX_RECENT) -> list[dict]:
58
+ """Load the last `max_messages` conversation turns for a character."""
59
+ path = os.path.join(_memory_dir(character_id), "recent_messages.json")
60
+ data = _load(path, {"messages": []})
61
+ return data.get("messages", [])[-max_messages:]
62
+
63
+
64
+ def save_recent_messages(character_id: str, messages: list[dict]) -> None:
65
+ """Overwrite the recent messages file (trims to MAX_RECENT)."""
66
+ path = os.path.join(_memory_dir(character_id), "recent_messages.json")
67
+ _save(path, {"messages": messages[-MAX_RECENT:]})
68
+
69
+
70
+ def append_message(character_id: str, role: str, content: str) -> None:
71
+ """Append a single turn to recent messages and persist."""
72
+ messages = load_recent_messages(character_id, max_messages=MAX_RECENT)
73
+ messages.append({"role": role, "content": content})
74
+ save_recent_messages(character_id, messages)
75
+
76
+
77
+ # ── Summary ────────────────────────────────────────────────────────────────────
78
+
79
+ def load_summary(character_id: str) -> str:
80
+ """Load the rolling conversation summary string for a character."""
81
+ path = os.path.join(_memory_dir(character_id), "summary.json")
82
+ data = _load(path, {"summary": ""})
83
+ return data.get("summary", "")
84
+
85
+
86
+ def save_summary(character_id: str, summary: str) -> None:
87
+ """Overwrite the conversation summary for a character."""
88
+ path = os.path.join(_memory_dir(character_id), "summary.json")
89
+ _save(path, {"summary": summary, "updated_at": _now()})
90
+
91
+
92
+ # ── Long-term memory ───────────────────────────────────────────────────────────
93
+
94
+ def load_long_term_memory(character_id: str) -> list[dict]:
95
+ """Load all long-term memory entries for a character."""
96
+ path = os.path.join(_memory_dir(character_id), "long_term_memory.json")
97
+ data = _load(path, {"memories": []})
98
+ return data.get("memories", [])
99
+
100
+
101
+ def save_long_term_memory(character_id: str, memories: list[dict]) -> None:
102
+ """Overwrite the long-term memory file for a character."""
103
+ path = os.path.join(_memory_dir(character_id), "long_term_memory.json")
104
+ _save(path, {"memories": memories})
105
+
106
+
107
+ def add_memory(character_id: str, content: str, importance: str = "medium") -> None:
108
+ """Append a single long-term memory entry."""
109
+ memories = load_long_term_memory(character_id)
110
+ memories.append({
111
+ "content": content,
112
+ "importance": importance,
113
+ "created_at": _now(),
114
+ })
115
+ save_long_term_memory(character_id, memories)
engine/parser.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Response parser — splits a raw LLM response into typed segments.
3
+
4
+ Segment types:
5
+ 'dialogue' — plain spoken text
6
+ 'action' — content inside parentheses: (action)
7
+ 'thought' — content inside asterisks: *thought*
8
+
9
+ Usage:
10
+ segments = parse_response("(looks up) Hello. *This is awkward.*")
11
+ # [
12
+ # {'type': 'action', 'content': 'looks up'},
13
+ # {'type': 'dialogue', 'content': 'Hello.'},
14
+ # {'type': 'thought', 'content': 'This is awkward.'},
15
+ # ]
16
+ """
17
+
18
+ import re
19
+ from typing import TypedDict
20
+
21
+ # Matches (action) blocks or *thought* blocks
22
+ _PATTERN = re.compile(r"(\([^)]+\)|\*[^*]+\*)")
23
+
24
+
25
+ class Segment(TypedDict):
26
+ type: str # 'dialogue' | 'action' | 'thought'
27
+ content: str # text without surrounding markers
28
+
29
+
30
+ def parse_response(text: str) -> list[Segment]:
31
+ """
32
+ Parse a raw character response into a list of typed segments.
33
+ Preserves reading order. Skips empty parts.
34
+ """
35
+ segments: list[Segment] = []
36
+
37
+ for part in _PATTERN.split(text):
38
+ part = part.strip()
39
+ if not part:
40
+ continue
41
+
42
+ if part.startswith("(") and part.endswith(")"):
43
+ segments.append({"type": "action", "content": part[1:-1].strip()})
44
+ elif part.startswith("*") and part.endswith("*"):
45
+ segments.append({"type": "thought", "content": part[1:-1].strip()})
46
+ else:
47
+ segments.append({"type": "dialogue", "content": part})
48
+
49
+ return segments
50
+
51
+
52
+ def segments_to_markdown(segments: list[Segment]) -> str:
53
+ """
54
+ Convert segments to a Gradio-friendly markdown string.
55
+
56
+ Conventions (will be enhanced with CSS in Phase 9):
57
+ action → italic in parentheses: *(action)*
58
+ thought → italic with em dash: *— thought*
59
+ dialogue → plain text
60
+ """
61
+ lines: list[str] = []
62
+
63
+ for seg in segments:
64
+ if seg["type"] == "action":
65
+ lines.append(f"*({seg['content']})*")
66
+ elif seg["type"] == "thought":
67
+ lines.append(f"*— {seg['content']}*")
68
+ else:
69
+ lines.append(seg["content"])
70
+
71
+ return "\n".join(lines)
engine/prompt_builder.py ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Prompt builder — assembles the full system prompt from all parts.
3
+
4
+ Build order (each section separated by a blank line):
5
+ 1. Global rules (format rules, roleplay rules)
6
+ 2. Character definition (identity, personality, speech, world, examples)
7
+ 3. Character-specific instructions (system_prompt field in JSON)
8
+ 4. User persona (optional)
9
+ 5. Memory (conversation summary + long-term memories)
10
+ Then passed as the system message, followed by:
11
+ 6. Recent conversation history
12
+ 7. Current user message
13
+ """
14
+
15
+ import glob
16
+ import json
17
+ import logging
18
+ import os
19
+ from typing import Any
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ CHARACTERS_DIR = "characters"
24
+ PROMPTS_DIR = "prompts"
25
+ ASSETS_DIR = "assets"
26
+
27
+
28
+ # ── Character loading ──────────────────────────────────────────────────────────
29
+
30
+ def load_character(character_id: str) -> dict[str, Any]:
31
+ """Load and return a character dict from characters/{character_id}.json."""
32
+ path = os.path.join(CHARACTERS_DIR, f"{character_id}.json")
33
+ if not os.path.exists(path):
34
+ raise FileNotFoundError(f"Character file not found: {path}")
35
+ with open(path, "r", encoding="utf-8") as f:
36
+ return json.load(f)
37
+
38
+
39
+ def list_characters() -> list[dict[str, str]]:
40
+ """
41
+ Discover all characters from the characters/ directory.
42
+ Returns a list of dicts with id, name, and description.
43
+ Skips any files that fail to parse (logs a warning instead of raising).
44
+ """
45
+ characters = []
46
+ for path in sorted(glob.glob(os.path.join(CHARACTERS_DIR, "*.json"))):
47
+ try:
48
+ with open(path, "r", encoding="utf-8") as f:
49
+ data = json.load(f)
50
+ characters.append({
51
+ "id": data.get("id", os.path.splitext(os.path.basename(path))[0]),
52
+ "name": data.get("name", "Unknown"),
53
+ "description": data.get("description", ""),
54
+ })
55
+ except Exception as e:
56
+ logger.warning(f"[Characters] Skipping {path}: {e}")
57
+ return characters
58
+
59
+
60
+ def get_avatar_path(character_id: str) -> str | None:
61
+ """
62
+ Return the first avatar image path found for a character.
63
+ Logs a warning and returns None if no image exists — does not raise.
64
+ """
65
+ avatar_dir = os.path.join(ASSETS_DIR, "characters", character_id)
66
+ for pattern in ("*.png", "*.jpg", "*.jpeg", "*.webp"):
67
+ matches = glob.glob(os.path.join(avatar_dir, pattern))
68
+ if matches:
69
+ return sorted(matches)[0]
70
+ logger.warning(
71
+ f"[Avatar] No image found for '{character_id}' in '{avatar_dir}'. "
72
+ "A placeholder will be shown in the UI."
73
+ )
74
+ return None
75
+
76
+
77
+ # ── Prompt sections ────────────────────────────────────────────────────────────
78
+
79
+ def build_global_prompt() -> str:
80
+ """Load the global system prompt from prompts/system_prompt.txt."""
81
+ path = os.path.join(PROMPTS_DIR, "system_prompt.txt")
82
+ with open(path, "r", encoding="utf-8") as f:
83
+ return f.read().strip()
84
+
85
+
86
+ def build_character_prompt(character: dict[str, Any]) -> str:
87
+ """
88
+ Build the character definition block from a character dict.
89
+ Covers: identity, personality, speech style, behavior, world, roleplay rules,
90
+ and a capped set of example dialogues.
91
+ """
92
+ name = character.get("name", "Unknown")
93
+ sections = [f"You are {name}."]
94
+
95
+ # Identity
96
+ identity = character.get("identity", {})
97
+ if identity:
98
+ parts = []
99
+ if identity.get("role"):
100
+ parts.append(f"Role: {identity['role']}")
101
+ if identity.get("background"):
102
+ parts.append(f"Background: {identity['background']}")
103
+ if parts:
104
+ sections.append("IDENTITY\n" + "\n".join(parts))
105
+
106
+ # Personality
107
+ personality = character.get("personality", [])
108
+ if personality:
109
+ sections.append("PERSONALITY\n" + ", ".join(personality))
110
+
111
+ # Speech style
112
+ speech = character.get("speech_style", {})
113
+ if speech:
114
+ parts = []
115
+ if speech.get("tone"):
116
+ parts.append(f"Tone: {', '.join(speech['tone'])}")
117
+ if speech.get("formality"):
118
+ parts.append(f"Formality: {speech['formality']}")
119
+ if speech.get("sentence_style"):
120
+ parts.append(f"Sentence style: {speech['sentence_style']}")
121
+ flags = []
122
+ if speech.get("uses_humor"):
123
+ flags.append("humor")
124
+ if speech.get("uses_rhetorical_questions"):
125
+ flags.append("rhetorical questions")
126
+ if speech.get("avoids_excessive_enthusiasm"):
127
+ flags.append("avoids excessive enthusiasm")
128
+ if flags:
129
+ parts.append("Uses: " + ", ".join(flags))
130
+ if parts:
131
+ sections.append("SPEECH STYLE\n" + "\n".join(parts))
132
+
133
+ # Behavior
134
+ behavior = character.get("behavior", {})
135
+ if behavior:
136
+ lines = [
137
+ f"- {k.replace('_', ' ')}: {v}"
138
+ for k, v in behavior.items()
139
+ ]
140
+ sections.append("BEHAVIOR\n" + "\n".join(lines))
141
+
142
+ # World
143
+ world = character.get("world", {})
144
+ if world:
145
+ parts = []
146
+ if world.get("universe"):
147
+ parts.append(f"Universe: {world['universe']}")
148
+ if world.get("setting"):
149
+ parts.append(f"Setting: {world['setting']}")
150
+ if world.get("description"):
151
+ parts.append(world["description"])
152
+ sections.append("WORLD\n" + "\n".join(parts))
153
+
154
+ # Roleplay rules
155
+ roleplay = character.get("roleplay", {})
156
+ if roleplay:
157
+ parts = []
158
+ if roleplay.get("action_format"):
159
+ parts.append(f"Action format: {roleplay['action_format']}")
160
+ if roleplay.get("thought_format"):
161
+ parts.append(f"Thought format: {roleplay['thought_format']}")
162
+ if roleplay.get("break_character") is False:
163
+ parts.append("Never break character.")
164
+ if parts:
165
+ sections.append("ROLEPLAY RULES\n" + "\n".join(parts))
166
+
167
+ # Example dialogues (capped at 4 to manage token budget)
168
+ examples = character.get("example_dialogues", [])
169
+ if examples:
170
+ lines = ["EXAMPLE DIALOGUES"]
171
+ for ex in examples[:4]:
172
+ lines.append(f'User: {ex["user"]}')
173
+ lines.append(f'{name}: {ex["character"]}')
174
+ lines.append("")
175
+ sections.append("\n".join(lines).strip())
176
+
177
+ return "\n\n".join(sections)
178
+
179
+
180
+ def build_character_specific_prompt(character: dict[str, Any]) -> str:
181
+ """
182
+ Return the character's system_prompt field.
183
+ This is character-specific fine-tuning on top of the global rules —
184
+ it does not replace them.
185
+ """
186
+ sp = character.get("system_prompt", "").strip()
187
+ if sp:
188
+ return f"ADDITIONAL CHARACTER NOTES\n{sp}"
189
+ return ""
190
+
191
+
192
+ def build_persona_prompt(persona: dict[str, str] | None) -> str:
193
+ """
194
+ Build the user persona section.
195
+ Returns an empty string if persona is absent or all fields are blank.
196
+ """
197
+ if not persona or not any(persona.values()):
198
+ return ""
199
+ lines = ["THE USER IS PLAYING THE ROLE OF:"]
200
+ for key in ("name", "age", "gender", "personality", "background"):
201
+ val = persona.get(key, "").strip()
202
+ if val:
203
+ lines.append(f"{key.capitalize()}: {val}")
204
+ return "\n".join(lines)
205
+
206
+
207
+ def build_memory_prompt(summary: str, long_term_memory: list[dict]) -> str:
208
+ """Build the memory section from conversation summary and long-term memories."""
209
+ parts = []
210
+ if summary:
211
+ parts.append(f"CONVERSATION SUMMARY\n{summary}")
212
+ if long_term_memory:
213
+ lines = ["LONG-TERM MEMORY"]
214
+ for mem in long_term_memory:
215
+ importance = mem.get("importance", "medium").upper()
216
+ content = mem.get("content", "")
217
+ lines.append(f"[{importance}] {content}")
218
+ parts.append("\n".join(lines))
219
+ return "\n\n".join(parts)
220
+
221
+
222
+ # ── Final assembly ─────────────────────────────────────────────────────────────
223
+
224
+ def build_messages(
225
+ character: dict[str, Any],
226
+ persona: dict[str, str] | None,
227
+ summary: str,
228
+ long_term_memory: list[dict],
229
+ recent_messages: list[dict],
230
+ current_message: str,
231
+ ) -> list[dict[str, str]]:
232
+ """
233
+ Assemble the full messages array to send to the LLM.
234
+
235
+ System prompt is built by joining non-empty sections with double newlines.
236
+ Conversation history (recent_messages) follows as separate user/assistant turns.
237
+ The current user message is appended last.
238
+ """
239
+ system_sections: list[str] = []
240
+
241
+ global_prompt = build_global_prompt()
242
+ if global_prompt:
243
+ system_sections.append(global_prompt)
244
+
245
+ char_prompt = build_character_prompt(character)
246
+ if char_prompt:
247
+ system_sections.append(char_prompt)
248
+
249
+ char_specific = build_character_specific_prompt(character)
250
+ if char_specific:
251
+ system_sections.append(char_specific)
252
+
253
+ persona_prompt = build_persona_prompt(persona)
254
+ if persona_prompt:
255
+ system_sections.append(persona_prompt)
256
+
257
+ memory_prompt = build_memory_prompt(summary, long_term_memory)
258
+ if memory_prompt:
259
+ system_sections.append(memory_prompt)
260
+
261
+ system_content = "\n\n".join(system_sections)
262
+
263
+ messages: list[dict[str, str]] = [{"role": "system", "content": system_content}]
264
+ messages.extend(recent_messages)
265
+ messages.append({"role": "user", "content": current_message})
266
+
267
+ return messages
engine/relationship.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Relationship system — tracks how a character feels about the user over time.
3
+
4
+ Planned for a future phase. Will be injected into the prompt via
5
+ build_messages() so the character's tone shifts naturally as the
6
+ relationship evolves.
7
+
8
+ Relationship axes (planned):
9
+ trust : -100 (hostile) to +100 (fully trusting)
10
+ affection : -100 (dislikes) to +100 (fond of)
11
+ familiarity : 0 (strangers) to 100 (old friends)
12
+ """
13
+
14
+ from typing import Any
15
+
16
+
17
+ def get_default_relationship() -> dict[str, Any]:
18
+ """Return a neutral starting relationship state."""
19
+ return {
20
+ "trust": 0,
21
+ "affection": 0,
22
+ "familiarity": 0,
23
+ "notes": [],
24
+ }
25
+
26
+
27
+ def relationship_to_prompt(state: dict[str, Any]) -> str:
28
+ """
29
+ Convert a relationship state dict into a prompt snippet.
30
+ Placeholder — returns an empty string until this system is wired up.
31
+ """
32
+ return ""
memory/active_memory.json DELETED
@@ -1,10 +0,0 @@
1
- {
2
- "version": 1,
3
- "conversation_id": "",
4
- "character_id": "",
5
- "updated_at": "",
6
-
7
- "summary": "",
8
-
9
- "long_term_memory": []
10
- }
 
 
 
 
 
 
 
 
 
 
 
memory/{recent_messages/recent_messages.json → recent_messages.json} RENAMED
File without changes
prompts/memory_prompt.txt ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ You are a memory assistant. Review the conversation and extract two things:
2
+
3
+ 1. A short summary (2-3 sentences) of what happened.
4
+ 2. Any important facts about the user worth remembering long-term.
5
+
6
+ Return ONLY a valid JSON object -- no extra text, no markdown fences:
7
+ {
8
+ "summary": "2-3 sentence summary of the conversation",
9
+ "new_memories": [
10
+ {"content": "important fact about the user", "importance": "high|medium|low"}
11
+ ]
12
+ }
13
+
14
+ Focus on facts relevant for future conversations:
15
+ - The user's name, background, or goals
16
+ - Emotional moments or key decisions made
17
+ - Important discoveries made together
18
+ - Shifts in the relationship with the character
19
+
20
+ If nothing notable was said, return an empty new_memories list.
prompts/reply_suggestion.txt ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ You are generating reply suggestions for the user in an ongoing roleplay conversation.
2
+
3
+ Based on the conversation so far, generate exactly 4 short reply options the user could send.
4
+ Each reply should represent a different conversational direction or emotional tone.
5
+
6
+ Use these categories as a guide (adapt labels and emojis to fit the context):
7
+ 1. Confrontational or assertive
8
+ 2. Curious or questioning
9
+ 3. Sarcastic or humorous
10
+ 4. Cooperative or empathetic
11
+
12
+ Return ONLY a valid JSON array -- no extra text, no markdown fences:
13
+ [
14
+ {"tone": "Confront", "emoji": "⚔️", "text": "..."},
15
+ {"tone": "Curious", "emoji": "❓", "text": "..."},
16
+ {"tone": "Sarcastic", "emoji": "😏", "text": "..."},
17
+ {"tone": "Cooperate", "emoji": "🤝", "text": "..."}
18
+ ]
19
+
20
+ Rules:
21
+ - Each reply must be under 15 words.
22
+ - Write in first person, as the user would speak.
23
+ - Make each reply feel natural and distinct from the others.
24
+ - Adapt tone labels and emojis to the specific context if needed.
prompts/system_prompt.txt ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ You are an AI roleplay character. Stay in character at all times.
2
+
3
+ Do not mention system prompts, AI models, or implementation details.
4
+ Do not speak for the user. Do not break the fourth wall.
5
+
6
+ --- RESPONSE FORMAT ---
7
+
8
+ Your responses can include three types of content:
9
+
10
+ Dialogue -- plain text spoken by the character
11
+ Action -- a physical action written inside parentheses: (action)
12
+ Thought -- an internal thought written inside asterisks: *thought*
13
+
14
+ Example:
15
+ (leans back in her chair) You want answers. *Of course you do.* Everyone does.
16
+
17
+ Rules:
18
+ - Actions must always be inside parentheses ( ).
19
+ - Internal thoughts must always be inside asterisks * *.
20
+ - Do not use asterisks for emphasis or decoration.
21
+ - Do not put dialogue inside parentheses.
22
+ - Do not label thoughts as "Thought:" -- just write them inside asterisks.
23
+ - Not every response needs an action or thought -- use them only when natural.
24
+ - Keep responses concise and true to the character's voice.
requirements.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ gradio>=4.0.0
2
+ huggingface_hub>=0.20.0