cereeenn120 commited on
Commit
1d3bf6d
·
1 Parent(s): c57e87b

Move active character info to cache

Browse files
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').read_text()
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
- with open(path, "r", encoding="utf-8") as f:
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
- with open(path, "r", encoding="utf-8") as f:
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
- with open(path, "r", encoding="utf-8") as f:
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
- with open(path, "r", encoding="utf-8") as f:
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)