Spaces:
Running
Running
Commit ·
1d3bf6d
1
Parent(s): c57e87b
Move active character info to cache
Browse files- README.md +14 -0
- engine/memory.py +2 -1
- engine/prompt_builder.py +5 -7
- engine/static_files.py +48 -0
- engine/suggestions.py +2 -2
- tests/test_static_files.py +68 -0
README.md
CHANGED
|
@@ -39,6 +39,7 @@ CharacterChatBot/
|
|
| 39 |
├── engine/ # Core logic (no UI dependencies)
|
| 40 |
│ ├── chat.py # LLM streaming generator
|
| 41 |
│ ├── prompt_builder.py # System prompt assembly
|
|
|
|
| 42 |
│ ├── memory.py # Three-layer memory system
|
| 43 |
│ ├── memory_controls.py # Manual facts and source exclusions
|
| 44 |
│ ├── memory_jobs.py # Bounded background extraction
|
|
@@ -229,6 +230,19 @@ model calls. Source-linked events allow edits and retries to roll back affected
|
|
| 229 |
developments and prevent counting the same extraction twice. Save / Load preserves
|
| 230 |
this state; older saves begin with optional axes disabled.
|
| 231 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
## Tests
|
| 233 |
|
| 234 |
```bash
|
|
|
|
| 39 |
├── engine/ # Core logic (no UI dependencies)
|
| 40 |
│ ├── chat.py # LLM streaming generator
|
| 41 |
│ ├── prompt_builder.py # System prompt assembly
|
| 42 |
+
│ ├── static_files.py # Bounded static JSON/text cache
|
| 43 |
│ ├── memory.py # Three-layer memory system
|
| 44 |
│ ├── memory_controls.py # Manual facts and source exclusions
|
| 45 |
│ ├── memory_jobs.py # Bounded background extraction
|
|
|
|
| 230 |
developments and prevent counting the same extraction twice. Save / Load preserves
|
| 231 |
this state; older saves begin with optional axes disabled.
|
| 232 |
|
| 233 |
+
## Static file cache
|
| 234 |
+
|
| 235 |
+
Character JSON and the system, reply-suggestion, and memory-extraction prompt
|
| 236 |
+
files are cached in process RAM (up to 64 entries, least recently used eviction).
|
| 237 |
+
Each access checks file metadata; edits and atomic replacements refresh the
|
| 238 |
+
entry. Missing or invalid files raise errors instead of silently reusing old
|
| 239 |
+
content. Character data is deep-copied for each caller to avoid shared mutations.
|
| 240 |
+
The cache is protected by a lock for chat and background worker access.
|
| 241 |
+
|
| 242 |
+
Conversation history, personas, summaries, and user memories are not cached here.
|
| 243 |
+
This saves local file reads and JSON parsing only; model calls and token usage
|
| 244 |
+
remain unchanged. Restarting the process clears the cache.
|
| 245 |
+
|
| 246 |
## Tests
|
| 247 |
|
| 248 |
```bash
|
engine/memory.py
CHANGED
|
@@ -8,6 +8,7 @@ import json
|
|
| 8 |
import logging
|
| 9 |
import os
|
| 10 |
from pathlib import Path
|
|
|
|
| 11 |
import re
|
| 12 |
import tempfile
|
| 13 |
import threading
|
|
@@ -182,7 +183,7 @@ def update_memory(session_id, character_id, conversation, client, model, persona
|
|
| 182 |
try:
|
| 183 |
from engine.prompt_builder import load_character
|
| 184 |
character = load_character(character_id)
|
| 185 |
-
prompt = (Path(__file__).resolve().parent.parent / 'prompts/memory_prompt.txt')
|
| 186 |
response = client.chat_completion(
|
| 187 |
messages=[{'role': 'system', 'content': prompt}, {'role': 'user', 'content': json.dumps({
|
| 188 |
'previous_summary': conversation['summary'],
|
|
|
|
| 8 |
import logging
|
| 9 |
import os
|
| 10 |
from pathlib import Path
|
| 11 |
+
from engine.static_files import load_static_text
|
| 12 |
import re
|
| 13 |
import tempfile
|
| 14 |
import threading
|
|
|
|
| 183 |
try:
|
| 184 |
from engine.prompt_builder import load_character
|
| 185 |
character = load_character(character_id)
|
| 186 |
+
prompt = load_static_text(Path(__file__).resolve().parent.parent / 'prompts/memory_prompt.txt')
|
| 187 |
response = client.chat_completion(
|
| 188 |
messages=[{'role': 'system', 'content': prompt}, {'role': 'user', 'content': json.dumps({
|
| 189 |
'previous_summary': conversation['summary'],
|
engine/prompt_builder.py
CHANGED
|
@@ -13,13 +13,14 @@ Then passed as the system message, followed by:
|
|
| 13 |
"""
|
| 14 |
|
| 15 |
import glob
|
| 16 |
-
import json
|
| 17 |
import logging
|
| 18 |
import os
|
| 19 |
import re
|
| 20 |
from pathlib import Path
|
| 21 |
from typing import Any
|
| 22 |
|
|
|
|
|
|
|
| 23 |
logger = logging.getLogger(__name__)
|
| 24 |
|
| 25 |
ROOT = Path(__file__).resolve().parent.parent
|
|
@@ -37,8 +38,7 @@ def load_character(character_id: str) -> dict[str, Any]:
|
|
| 37 |
path = os.path.join(CHARACTERS_DIR, f"{character_id}.json")
|
| 38 |
if not os.path.exists(path):
|
| 39 |
raise FileNotFoundError(f"Character file not found: {path}")
|
| 40 |
-
|
| 41 |
-
return json.load(f)
|
| 42 |
|
| 43 |
|
| 44 |
def list_characters() -> list[dict[str, str]]:
|
|
@@ -50,8 +50,7 @@ def list_characters() -> list[dict[str, str]]:
|
|
| 50 |
characters = []
|
| 51 |
for path in sorted(glob.glob(os.path.join(CHARACTERS_DIR, "*.json"))):
|
| 52 |
try:
|
| 53 |
-
|
| 54 |
-
data = json.load(f)
|
| 55 |
characters.append({
|
| 56 |
"id": data.get("id", os.path.splitext(os.path.basename(path))[0]),
|
| 57 |
"name": data.get("name", "Unknown"),
|
|
@@ -84,8 +83,7 @@ def get_avatar_path(character_id: str) -> str | None:
|
|
| 84 |
def build_global_prompt() -> str:
|
| 85 |
"""Load the global system prompt from prompts/system_prompt.txt."""
|
| 86 |
path = os.path.join(PROMPTS_DIR, "system_prompt.txt")
|
| 87 |
-
|
| 88 |
-
return f.read().strip()
|
| 89 |
|
| 90 |
|
| 91 |
def build_character_prompt(character: dict[str, Any]) -> str:
|
|
|
|
| 13 |
"""
|
| 14 |
|
| 15 |
import glob
|
|
|
|
| 16 |
import logging
|
| 17 |
import os
|
| 18 |
import re
|
| 19 |
from pathlib import Path
|
| 20 |
from typing import Any
|
| 21 |
|
| 22 |
+
from engine.static_files import load_static_json, load_static_text
|
| 23 |
+
|
| 24 |
logger = logging.getLogger(__name__)
|
| 25 |
|
| 26 |
ROOT = Path(__file__).resolve().parent.parent
|
|
|
|
| 38 |
path = os.path.join(CHARACTERS_DIR, f"{character_id}.json")
|
| 39 |
if not os.path.exists(path):
|
| 40 |
raise FileNotFoundError(f"Character file not found: {path}")
|
| 41 |
+
return load_static_json(path)
|
|
|
|
| 42 |
|
| 43 |
|
| 44 |
def list_characters() -> list[dict[str, str]]:
|
|
|
|
| 50 |
characters = []
|
| 51 |
for path in sorted(glob.glob(os.path.join(CHARACTERS_DIR, "*.json"))):
|
| 52 |
try:
|
| 53 |
+
data = load_static_json(path)
|
|
|
|
| 54 |
characters.append({
|
| 55 |
"id": data.get("id", os.path.splitext(os.path.basename(path))[0]),
|
| 56 |
"name": data.get("name", "Unknown"),
|
|
|
|
| 83 |
def build_global_prompt() -> str:
|
| 84 |
"""Load the global system prompt from prompts/system_prompt.txt."""
|
| 85 |
path = os.path.join(PROMPTS_DIR, "system_prompt.txt")
|
| 86 |
+
return load_static_text(path).strip()
|
|
|
|
| 87 |
|
| 88 |
|
| 89 |
def build_character_prompt(character: dict[str, Any]) -> str:
|
engine/static_files.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Small process-local cache for static character and prompt files only."""
|
| 2 |
+
|
| 3 |
+
from collections import OrderedDict
|
| 4 |
+
from copy import deepcopy
|
| 5 |
+
import json
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from threading import RLock
|
| 8 |
+
|
| 9 |
+
_MAX_ENTRIES = 64
|
| 10 |
+
_CACHE = OrderedDict()
|
| 11 |
+
_LOCK = RLock()
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _signature(path):
|
| 15 |
+
stat = path.stat()
|
| 16 |
+
return (stat.st_dev, stat.st_ino, stat.st_size, stat.st_mtime_ns, stat.st_ctime_ns)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _load(path, as_json):
|
| 20 |
+
path = Path(path).resolve()
|
| 21 |
+
key = (path, as_json)
|
| 22 |
+
with _LOCK:
|
| 23 |
+
# Always stat: changed or deleted files must never silently use stale data.
|
| 24 |
+
signature = _signature(path)
|
| 25 |
+
cached = _CACHE.get(key)
|
| 26 |
+
if cached is not None and cached[0] == signature:
|
| 27 |
+
_CACHE.move_to_end(key)
|
| 28 |
+
value = cached[1]
|
| 29 |
+
else:
|
| 30 |
+
_CACHE.pop(key, None)
|
| 31 |
+
text = path.read_text(encoding='utf-8')
|
| 32 |
+
value = json.loads(text) if as_json else text
|
| 33 |
+
# A concurrent file edit must not be cached under its older signature.
|
| 34 |
+
if _signature(path) == signature:
|
| 35 |
+
_CACHE[key] = (signature, value)
|
| 36 |
+
while len(_CACHE) > _MAX_ENTRIES:
|
| 37 |
+
_CACHE.popitem(last=False)
|
| 38 |
+
return deepcopy(value) if as_json else value
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def load_static_json(path):
|
| 42 |
+
"""Return a private copy of parsed JSON, refreshing when file metadata changes."""
|
| 43 |
+
return _load(path, as_json=True)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def load_static_text(path):
|
| 47 |
+
"""Return cached UTF-8 text without altering whitespace."""
|
| 48 |
+
return _load(path, as_json=False)
|
engine/suggestions.py
CHANGED
|
@@ -18,6 +18,7 @@ from typing import Any
|
|
| 18 |
|
| 19 |
from huggingface_hub import InferenceClient
|
| 20 |
from engine.persona import validate_persona
|
|
|
|
| 21 |
|
| 22 |
logger = logging.getLogger(__name__)
|
| 23 |
|
|
@@ -60,8 +61,7 @@ def fallback_suggestions(language):
|
|
| 60 |
|
| 61 |
def _load_prompt() -> str:
|
| 62 |
path = PROMPTS_DIR / "reply_suggestion.txt"
|
| 63 |
-
|
| 64 |
-
return f.read().strip()
|
| 65 |
|
| 66 |
|
| 67 |
def _parse(raw: str) -> list[dict[str, str]]:
|
|
|
|
| 18 |
|
| 19 |
from huggingface_hub import InferenceClient
|
| 20 |
from engine.persona import validate_persona
|
| 21 |
+
from engine.static_files import load_static_text
|
| 22 |
|
| 23 |
logger = logging.getLogger(__name__)
|
| 24 |
|
|
|
|
| 61 |
|
| 62 |
def _load_prompt() -> str:
|
| 63 |
path = PROMPTS_DIR / "reply_suggestion.txt"
|
| 64 |
+
return load_static_text(path).strip()
|
|
|
|
| 65 |
|
| 66 |
|
| 67 |
def _parse(raw: str) -> list[dict[str, str]]:
|
tests/test_static_files.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
import tempfile
|
| 4 |
+
import unittest
|
| 5 |
+
from unittest.mock import patch
|
| 6 |
+
|
| 7 |
+
from engine import static_files, prompt_builder
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class StaticFilesTests(unittest.TestCase):
|
| 11 |
+
def setUp(self):
|
| 12 |
+
self.directory = tempfile.TemporaryDirectory()
|
| 13 |
+
self.addCleanup(self.directory.cleanup)
|
| 14 |
+
self.path = Path(self.directory.name) / 'character.json'
|
| 15 |
+
static_files._CACHE.clear()
|
| 16 |
+
self.addCleanup(static_files._CACHE.clear)
|
| 17 |
+
|
| 18 |
+
def test_character_reads_once_and_returns_independent_nested_data(self):
|
| 19 |
+
self.path.write_text('{"personality": ["curious"]}')
|
| 20 |
+
original = Path.read_text
|
| 21 |
+
with patch.object(prompt_builder, 'CHARACTERS_DIR', self.path.parent), patch.object(
|
| 22 |
+
Path, 'read_text', autospec=True, side_effect=original
|
| 23 |
+
) as read:
|
| 24 |
+
first = prompt_builder.load_character('character')
|
| 25 |
+
first['personality'].append('changed')
|
| 26 |
+
self.assertEqual(prompt_builder.load_character('character')['personality'], ['curious'])
|
| 27 |
+
self.assertEqual(read.call_count, 1)
|
| 28 |
+
|
| 29 |
+
def test_edit_replace_delete_and_invalid_json_do_not_return_stale_data(self):
|
| 30 |
+
self.path.write_text('{"name":"a"}')
|
| 31 |
+
self.assertEqual(static_files.load_static_json(self.path), {'name': 'a'})
|
| 32 |
+
self.path.write_text('{"name":"updated"}')
|
| 33 |
+
self.assertEqual(static_files.load_static_json(self.path), {'name': 'updated'})
|
| 34 |
+
replacement = self.path.with_suffix('.new')
|
| 35 |
+
replacement.write_text('{"name":"replacement"}')
|
| 36 |
+
replacement.replace(self.path)
|
| 37 |
+
self.assertEqual(static_files.load_static_json(self.path), {'name': 'replacement'})
|
| 38 |
+
self.path.write_text('{invalid')
|
| 39 |
+
with self.assertRaises(json.JSONDecodeError):
|
| 40 |
+
static_files.load_static_json(self.path)
|
| 41 |
+
self.path.unlink()
|
| 42 |
+
with self.assertRaises(FileNotFoundError):
|
| 43 |
+
static_files.load_static_json(self.path)
|
| 44 |
+
self.path.write_text('{}')
|
| 45 |
+
self.assertEqual(static_files.load_static_json(self.path), {})
|
| 46 |
+
|
| 47 |
+
def test_prompt_text_is_cached_and_refreshes(self):
|
| 48 |
+
self.path.write_text(' Türkçe\n')
|
| 49 |
+
original = Path.read_text
|
| 50 |
+
with patch.object(Path, 'read_text', autospec=True, side_effect=original) as read:
|
| 51 |
+
for _ in range(2):
|
| 52 |
+
self.assertEqual(static_files.load_static_text(self.path), ' Türkçe\n')
|
| 53 |
+
self.assertEqual(read.call_count, 1)
|
| 54 |
+
self.path.write_text('New prompt')
|
| 55 |
+
self.assertEqual(static_files.load_static_text(self.path), 'New prompt')
|
| 56 |
+
self.assertEqual(read.call_count, 2)
|
| 57 |
+
|
| 58 |
+
def test_cache_evicts_least_recently_used_file(self):
|
| 59 |
+
with patch.object(static_files, '_MAX_ENTRIES', 2):
|
| 60 |
+
paths = [self.path.with_name(str(i)) for i in range(3)]
|
| 61 |
+
for path in paths:
|
| 62 |
+
path.write_text(path.name)
|
| 63 |
+
for path in paths[:2]:
|
| 64 |
+
static_files.load_static_text(path)
|
| 65 |
+
static_files.load_static_text(paths[0])
|
| 66 |
+
static_files.load_static_text(paths[2])
|
| 67 |
+
self.assertEqual(len(static_files._CACHE), 2)
|
| 68 |
+
self.assertNotIn((paths[1].resolve(), False), static_files._CACHE)
|