Spaces:
Running
Running
Commit ·
e29c7ce
1
Parent(s): 8088f0e
Add chatbot engine and memory system phase-1
Browse files- app.py +60 -44
- engine/chat.py +92 -0
- engine/memory.py +115 -0
- engine/parser.py +71 -0
- engine/prompt_builder.py +267 -0
- engine/relationship.py +32 -0
- memory/active_memory.json +0 -10
- memory/{recent_messages/recent_messages.json → recent_messages.json} +0 -0
- prompts/memory_prompt.txt +20 -0
- prompts/reply_suggestion.txt +24 -0
- prompts/system_prompt.txt +24 -0
- requirements.txt +2 -0
app.py
CHANGED
|
@@ -1,69 +1,85 @@
|
|
|
|
|
| 1 |
import gradio as gr
|
| 2 |
from huggingface_hub import InferenceClient
|
|
|
|
|
|
|
| 3 |
|
|
|
|
|
|
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 20 |
|
| 21 |
-
messages.extend(history)
|
| 22 |
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
-
|
| 26 |
|
| 27 |
-
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 45 |
-
"""
|
| 46 |
chatbot = gr.ChatInterface(
|
| 47 |
-
respond,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
additional_inputs=[
|
| 49 |
-
gr.
|
| 50 |
-
gr.Slider(minimum=1, maximum=
|
| 51 |
-
gr.Slider(minimum=0.1, maximum=
|
| 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
|