Spaces:
Running
Running
Commit ·
31404a1
1
Parent(s): addec5c
Improve suggestions with adding persona based system
Browse files- README.md +19 -5
- app.py +22 -5
- engine/chat.py +4 -7
- engine/memory.py +65 -27
- engine/memory_jobs.py +48 -0
- engine/suggestions.py +52 -8
- tests/test_app.py +20 -0
- tests/test_conversations.py +4 -2
- tests/test_memory_jobs.py +139 -0
- tests/test_suggestions.py +54 -0
README.md
CHANGED
|
@@ -144,11 +144,19 @@ The complete transcript is retained for display and export. Once the unsummarize
|
|
| 144 |
history exceeds 20 messages (not 20 user/assistant pairs), a batch of about 10 older
|
| 145 |
messages is summarized **after the answer is displayed**. This periodic model
|
| 146 |
call also extracts long-term facts. Subsequent prompts contain the cumulative
|
| 147 |
-
summary, up to 40 deduplicated facts, and the remaining recent messages.
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
|
| 153 |
Storage is isolated by browser session, not an authenticated user account.
|
| 154 |
Refreshing the page or opening a new browser session starts fresh; use **Save /
|
|
@@ -170,6 +178,12 @@ in the selected language. If translation is enabled explicitly, the displayed
|
|
| 170 |
translation is also the persisted conversation, so reload and subsequent context
|
| 171 |
match what the user saw.
|
| 172 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
## Tests
|
| 174 |
|
| 175 |
```bash
|
|
|
|
| 144 |
history exceeds 20 messages (not 20 user/assistant pairs), a batch of about 10 older
|
| 145 |
messages is summarized **after the answer is displayed**. This periodic model
|
| 146 |
call also extracts long-term facts. Subsequent prompts contain the cumulative
|
| 147 |
+
summary, up to 40 deduplicated facts, and the remaining recent messages.
|
| 148 |
+
Extraction runs in a background pool with at most two jobs across the app and
|
| 149 |
+
one per conversation. The chat request completes without waiting for extraction.
|
| 150 |
+
There is no growing job queue: if the pool is busy, messages remain intact and
|
| 151 |
+
scheduling is tried after a later response. The same retry policy applies to
|
| 152 |
+
extraction errors.
|
| 153 |
+
|
| 154 |
+
Before committing, a worker checks the transcript revision and the summary it
|
| 155 |
+
started from. A newer completed turn, edit, reset, or import invalidates the job.
|
| 156 |
+
Memory-only updates do not advance the transcript revision. A response being
|
| 157 |
+
streamed can therefore append to the current snapshot without losing a summary
|
| 158 |
+
that finished in the meantime. Only successfully summarized messages leave the
|
| 159 |
+
model context; the full transcript remains available for display and export.
|
| 160 |
|
| 161 |
Storage is isolated by browser session, not an authenticated user account.
|
| 162 |
Refreshing the page or opening a new browser session starts fresh; use **Save /
|
|
|
|
| 178 |
translation is also the persisted conversation, so reload and subsequent context
|
| 179 |
match what the user saw.
|
| 180 |
|
| 181 |
+
Reply suggestions remain opt-in through the **Suggest replies** button. Their
|
| 182 |
+
single model call receives the selected language and the applied user persona.
|
| 183 |
+
On failure, four neutral fallback suggestions are returned in the selected UI
|
| 184 |
+
language without another model call. Changing language, persona, character, or
|
| 185 |
+
conversation clears the old suggestion buttons.
|
| 186 |
+
|
| 187 |
## Tests
|
| 188 |
|
| 189 |
```bash
|
app.py
CHANGED
|
@@ -80,7 +80,6 @@ char_id_state = gr.State(DEFAULT_CHARACTER)
|
|
| 80 |
language_state = gr.State(DEFAULT_LANGUAGE)
|
| 81 |
model_state = gr.State(DEFAULT_MODEL)
|
| 82 |
use_translator_state = gr.State(False) # True = separate translator model, False = main model handles language
|
| 83 |
-
suggestion_texts = [gr.State("") for _ in range(4)]
|
| 84 |
|
| 85 |
# ── Gradio respond function ────────────────────────────────────────────────────
|
| 86 |
|
|
@@ -236,7 +235,12 @@ def clear_persona_fn():
|
|
| 236 |
return get_empty_persona(), "", "", "", "", "", "*Persona cleared.*"
|
| 237 |
|
| 238 |
|
| 239 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
session_id = session_key(request)
|
| 241 |
logger.info(f"[App] Suggestion request: char='{char_id}', model='{model_id}'")
|
| 242 |
if not HF_TOKEN:
|
|
@@ -249,7 +253,7 @@ def get_suggestions_fn(char_id: str, model_id: str, request: gr.Request):
|
|
| 249 |
try:
|
| 250 |
char = load_character(char_id)
|
| 251 |
client = InferenceClient(token=HF_TOKEN, timeout=60)
|
| 252 |
-
suggestions = generate_suggestions(char, recent, client, model_id)
|
| 253 |
logger.info(f"[App] Suggestions generated: {[s['tone'] for s in suggestions]}")
|
| 254 |
except Exception as e:
|
| 255 |
logger.error(f"[App] Suggestion error: {e}")
|
|
@@ -337,6 +341,8 @@ def load_conversation_fn(file_path, current_char_id: str, request: gr.Request):
|
|
| 337 |
|
| 338 |
with gr.Blocks(title=f"Chat with {char_name}") as demo:
|
| 339 |
|
|
|
|
|
|
|
| 340 |
with gr.Sidebar():
|
| 341 |
# ── Character selector ───────────────────────────────────────────────
|
| 342 |
char_selector = gr.Dropdown(
|
|
@@ -478,12 +484,23 @@ with gr.Blocks(title=f"Chat with {char_name}") as demo:
|
|
| 478 |
outputs=[persona_state, p_name, p_age, p_gender, p_personality, p_background, persona_status],
|
| 479 |
)
|
| 480 |
|
| 481 |
-
suggest_btn.click(
|
| 482 |
fn=get_suggestions_fn,
|
| 483 |
-
inputs=[char_id_state, model_state],
|
| 484 |
outputs=[suggestion_group] + s_btns + suggestion_texts,
|
| 485 |
)
|
| 486 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 487 |
for btn, text_state in zip(s_btns, suggestion_texts):
|
| 488 |
btn.click(
|
| 489 |
fn=lambda t: t,
|
|
|
|
| 80 |
language_state = gr.State(DEFAULT_LANGUAGE)
|
| 81 |
model_state = gr.State(DEFAULT_MODEL)
|
| 82 |
use_translator_state = gr.State(False) # True = separate translator model, False = main model handles language
|
|
|
|
| 83 |
|
| 84 |
# ── Gradio respond function ────────────────────────────────────────────────────
|
| 85 |
|
|
|
|
| 235 |
return get_empty_persona(), "", "", "", "", "", "*Persona cleared.*"
|
| 236 |
|
| 237 |
|
| 238 |
+
|
| 239 |
+
def clear_suggestions_fn():
|
| 240 |
+
return [gr.update(visible=False)] * 5 + [""] * 4
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def get_suggestions_fn(char_id: str, model_id: str, language: str, persona: dict, request: gr.Request):
|
| 244 |
session_id = session_key(request)
|
| 245 |
logger.info(f"[App] Suggestion request: char='{char_id}', model='{model_id}'")
|
| 246 |
if not HF_TOKEN:
|
|
|
|
| 253 |
try:
|
| 254 |
char = load_character(char_id)
|
| 255 |
client = InferenceClient(token=HF_TOKEN, timeout=60)
|
| 256 |
+
suggestions = generate_suggestions(char, recent, client, model_id, language=language, persona=persona)
|
| 257 |
logger.info(f"[App] Suggestions generated: {[s['tone'] for s in suggestions]}")
|
| 258 |
except Exception as e:
|
| 259 |
logger.error(f"[App] Suggestion error: {e}")
|
|
|
|
| 341 |
|
| 342 |
with gr.Blocks(title=f"Chat with {char_name}") as demo:
|
| 343 |
|
| 344 |
+
suggestion_texts = [gr.State("") for _ in range(4)]
|
| 345 |
+
|
| 346 |
with gr.Sidebar():
|
| 347 |
# ── Character selector ───────────────────────────────────────────────
|
| 348 |
char_selector = gr.Dropdown(
|
|
|
|
| 484 |
outputs=[persona_state, p_name, p_age, p_gender, p_personality, p_background, persona_status],
|
| 485 |
)
|
| 486 |
|
| 487 |
+
suggestion_event = suggest_btn.click(
|
| 488 |
fn=get_suggestions_fn,
|
| 489 |
+
inputs=[char_id_state, model_state, language_state, persona_state],
|
| 490 |
outputs=[suggestion_group] + s_btns + suggestion_texts,
|
| 491 |
)
|
| 492 |
|
| 493 |
+
gr.on(
|
| 494 |
+
triggers=[language_selector.change, apply_btn.click, clear_btn.click,
|
| 495 |
+
char_selector.input, load_upload.change, chatbot.textbox.submit,
|
| 496 |
+
chatbot.chatbot.retry, chatbot.chatbot.clear, chatbot.chatbot.undo],
|
| 497 |
+
fn=clear_suggestions_fn,
|
| 498 |
+
inputs=[],
|
| 499 |
+
outputs=[suggestion_group] + s_btns + suggestion_texts,
|
| 500 |
+
cancels=[suggestion_event],
|
| 501 |
+
queue=False,
|
| 502 |
+
)
|
| 503 |
+
|
| 504 |
for btn, text_state in zip(s_btns, suggestion_texts):
|
| 505 |
btn.click(
|
| 506 |
fn=lambda t: t,
|
engine/chat.py
CHANGED
|
@@ -14,7 +14,8 @@ Separation of concerns:
|
|
| 14 |
|
| 15 |
import logging
|
| 16 |
from huggingface_hub import InferenceClient
|
| 17 |
-
from engine.memory import sync_history,
|
|
|
|
| 18 |
from engine.parser import parse_response, segments_to_markdown
|
| 19 |
from engine.prompt_builder import build_messages, load_character
|
| 20 |
from engine.translator import translate_from_english, translate_to_english
|
|
@@ -194,12 +195,8 @@ def generate_response(
|
|
| 194 |
formatted = segments_to_markdown(segments)
|
| 195 |
logger.info(f"[Chat] ✓ Response ready: {len(formatted)} chars, {len(segments)} segments")
|
| 196 |
# Store exactly what the user sees, including translated text.
|
| 197 |
-
conversation['recent_messages'].extend([
|
| 198 |
-
{'role': 'user', 'content': message},
|
| 199 |
-
{'role': 'assistant', 'content': formatted},
|
| 200 |
-
])
|
| 201 |
try:
|
| 202 |
-
saved =
|
| 203 |
except Exception:
|
| 204 |
logger.exception("Could not save conversation memory.")
|
| 205 |
yield formatted + "\n\n*(This response could not be saved. Please try again.)*"
|
|
@@ -208,4 +205,4 @@ def generate_response(
|
|
| 208 |
yield "*(Conversation changed while generating. Please try again.)*"
|
| 209 |
return
|
| 210 |
yield formatted
|
| 211 |
-
|
|
|
|
| 14 |
|
| 15 |
import logging
|
| 16 |
from huggingface_hub import InferenceClient
|
| 17 |
+
from engine.memory import sync_history, append_exchange
|
| 18 |
+
from engine.memory_jobs import schedule_memory_update
|
| 19 |
from engine.parser import parse_response, segments_to_markdown
|
| 20 |
from engine.prompt_builder import build_messages, load_character
|
| 21 |
from engine.translator import translate_from_english, translate_to_english
|
|
|
|
| 195 |
formatted = segments_to_markdown(segments)
|
| 196 |
logger.info(f"[Chat] ✓ Response ready: {len(formatted)} chars, {len(segments)} segments")
|
| 197 |
# Store exactly what the user sees, including translated text.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
try:
|
| 199 |
+
saved = append_exchange(session_id, character_id, conversation, message, formatted)
|
| 200 |
except Exception:
|
| 201 |
logger.exception("Could not save conversation memory.")
|
| 202 |
yield formatted + "\n\n*(This response could not be saved. Please try again.)*"
|
|
|
|
| 205 |
yield "*(Conversation changed while generating. Please try again.)*"
|
| 206 |
return
|
| 207 |
yield formatted
|
| 208 |
+
schedule_memory_update(session_id, character_id, saved, client, model)
|
engine/memory.py
CHANGED
|
@@ -61,19 +61,56 @@ def save_conversation(session_id, character_id, conversation, expected_revision=
|
|
| 61 |
return False
|
| 62 |
data = copy.deepcopy(conversation)
|
| 63 |
data['revision'] = current['revision'] + 1
|
| 64 |
-
|
| 65 |
-
fd, temporary = tempfile.mkstemp(dir=path.parent, suffix='.tmp')
|
| 66 |
-
try:
|
| 67 |
-
with os.fdopen(fd, 'w', encoding='utf-8') as handle:
|
| 68 |
-
json.dump(data, handle, ensure_ascii=False)
|
| 69 |
-
os.replace(temporary, path)
|
| 70 |
-
finally:
|
| 71 |
-
if os.path.exists(temporary):
|
| 72 |
-
os.unlink(temporary)
|
| 73 |
conversation['revision'] = data['revision']
|
| 74 |
return True
|
| 75 |
|
| 76 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
def normalize_history(history):
|
| 78 |
"""Accept Gradio 6 text blocks as well as simple message dictionaries."""
|
| 79 |
result = []
|
|
@@ -92,23 +129,24 @@ def normalize_history(history):
|
|
| 92 |
|
| 93 |
|
| 94 |
def sync_history(session_id, character_id, history):
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
|
|
|
| 112 |
|
| 113 |
|
| 114 |
def reset_conversation(session_id, character_id):
|
|
@@ -152,7 +190,7 @@ def update_memory(session_id, character_id, conversation, client, model):
|
|
| 152 |
updated = copy.deepcopy(conversation)
|
| 153 |
updated.update(summary=summary, summarized_count=end,
|
| 154 |
long_term_memory=sorted(combined.values(), key=lambda m: rank[m['importance']])[:MAX_MEMORIES])
|
| 155 |
-
return
|
| 156 |
except Exception:
|
| 157 |
logger.warning('Memory update failed; unsummarized messages retained.', exc_info=True)
|
| 158 |
return False
|
|
|
|
| 61 |
return False
|
| 62 |
data = copy.deepcopy(conversation)
|
| 63 |
data['revision'] = current['revision'] + 1
|
| 64 |
+
_write_snapshot(path, data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
conversation['revision'] = data['revision']
|
| 66 |
return True
|
| 67 |
|
| 68 |
|
| 69 |
+
def _write_snapshot(path, data):
|
| 70 |
+
"""Caller holds _LOCK; replacing the file makes every layer atomic."""
|
| 71 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 72 |
+
fd, temporary = tempfile.mkstemp(dir=path.parent, suffix='.tmp')
|
| 73 |
+
try:
|
| 74 |
+
with os.fdopen(fd, 'w', encoding='utf-8') as handle:
|
| 75 |
+
json.dump(data, handle, ensure_ascii=False)
|
| 76 |
+
os.replace(temporary, path)
|
| 77 |
+
finally:
|
| 78 |
+
if os.path.exists(temporary):
|
| 79 |
+
os.unlink(temporary)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def append_exchange(session_id, character_id, snapshot, message, response):
|
| 83 |
+
"""Keep a summary completed during streaming, but reject edited/reset history."""
|
| 84 |
+
with _LOCK:
|
| 85 |
+
current = load_conversation(session_id, character_id)
|
| 86 |
+
if current['revision'] != snapshot['revision']:
|
| 87 |
+
return None
|
| 88 |
+
current['recent_messages'].extend([
|
| 89 |
+
{'role': 'user', 'content': message},
|
| 90 |
+
{'role': 'assistant', 'content': response},
|
| 91 |
+
])
|
| 92 |
+
save_conversation(session_id, character_id, current, snapshot['revision'])
|
| 93 |
+
return current
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def _commit_memory(session_id, character_id, snapshot, updated):
|
| 97 |
+
"""Derived memory does not change transcript revision or replace its messages.
|
| 98 |
+
|
| 99 |
+
Check both the transcript version and the memory base, so neither a newer
|
| 100 |
+
turn/reset/import nor another summary can be overwritten by a late result.
|
| 101 |
+
"""
|
| 102 |
+
with _LOCK:
|
| 103 |
+
current = load_conversation(session_id, character_id)
|
| 104 |
+
if current['revision'] != snapshot['revision']:
|
| 105 |
+
return False
|
| 106 |
+
fields = ('summary', 'summarized_count', 'long_term_memory')
|
| 107 |
+
if any(current[key] != snapshot[key] for key in fields):
|
| 108 |
+
return False
|
| 109 |
+
current.update({key: updated[key] for key in fields})
|
| 110 |
+
_write_snapshot(_path(session_id, character_id), current)
|
| 111 |
+
return True
|
| 112 |
+
|
| 113 |
+
|
| 114 |
def normalize_history(history):
|
| 115 |
"""Accept Gradio 6 text blocks as well as simple message dictionaries."""
|
| 116 |
result = []
|
|
|
|
| 129 |
|
| 130 |
|
| 131 |
def sync_history(session_id, character_id, history):
|
| 132 |
+
with _LOCK:
|
| 133 |
+
conversation = load_conversation(session_id, character_id)
|
| 134 |
+
messages = normalize_history(history)
|
| 135 |
+
if messages != conversation['recent_messages']:
|
| 136 |
+
# Preserve derived memory only while the summarized prefix is unchanged.
|
| 137 |
+
# Retry/undo of recent turns must not erase unrelated earlier facts.
|
| 138 |
+
count = conversation['summarized_count']
|
| 139 |
+
old = conversation['recent_messages']
|
| 140 |
+
prefix_unchanged = bool(messages) and messages[:count] == old[:count] and len(messages) >= count
|
| 141 |
+
if count == 0:
|
| 142 |
+
shared = min(len(messages), len(old))
|
| 143 |
+
prefix_unchanged = bool(messages) and messages[:shared] == old[:shared]
|
| 144 |
+
if not prefix_unchanged:
|
| 145 |
+
conversation.update(summary='', long_term_memory=[], summarized_count=0)
|
| 146 |
+
conversation['recent_messages'] = messages
|
| 147 |
+
if not save_conversation(session_id, character_id, conversation, conversation['revision']):
|
| 148 |
+
raise ValueError('Conversation changed. Please try again.')
|
| 149 |
+
return conversation
|
| 150 |
|
| 151 |
|
| 152 |
def reset_conversation(session_id, character_id):
|
|
|
|
| 190 |
updated = copy.deepcopy(conversation)
|
| 191 |
updated.update(summary=summary, summarized_count=end,
|
| 192 |
long_term_memory=sorted(combined.values(), key=lambda m: rank[m['importance']])[:MAX_MEMORIES])
|
| 193 |
+
return _commit_memory(session_id, character_id, conversation, updated)
|
| 194 |
except Exception:
|
| 195 |
logger.warning('Memory update failed; unsummarized messages retained.', exc_info=True)
|
| 196 |
return False
|
engine/memory_jobs.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Small bounded pool for periodic memory extraction; no extra service required."""
|
| 2 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 3 |
+
from copy import deepcopy
|
| 4 |
+
import logging
|
| 5 |
+
import threading
|
| 6 |
+
|
| 7 |
+
from engine.memory import MAX_RECENT, update_memory
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class MemoryScheduler:
|
| 13 |
+
def __init__(self, workers=2):
|
| 14 |
+
self._executor = ThreadPoolExecutor(max_workers=workers, thread_name_prefix='memory')
|
| 15 |
+
self._capacity = workers
|
| 16 |
+
self._pending = set()
|
| 17 |
+
self._lock = threading.Lock()
|
| 18 |
+
|
| 19 |
+
def schedule(self, session_id, character_id, conversation, client, model):
|
| 20 |
+
if len(conversation['recent_messages']) - conversation['summarized_count'] <= MAX_RECENT:
|
| 21 |
+
return None
|
| 22 |
+
key = (session_id, character_id)
|
| 23 |
+
with self._lock:
|
| 24 |
+
# No unbounded queue and at most one job for each conversation.
|
| 25 |
+
# Skipped batches remain intact and are retried after a later reply.
|
| 26 |
+
if key in self._pending or len(self._pending) >= self._capacity:
|
| 27 |
+
return None
|
| 28 |
+
self._pending.add(key)
|
| 29 |
+
try:
|
| 30 |
+
snapshot = deepcopy(conversation)
|
| 31 |
+
future = self._executor.submit(update_memory, session_id, character_id, snapshot, client, model)
|
| 32 |
+
except Exception:
|
| 33 |
+
self._release(key)
|
| 34 |
+
logger.warning('Could not schedule memory update; messages retained.', exc_info=True)
|
| 35 |
+
return None
|
| 36 |
+
future.add_done_callback(lambda _: self._release(key))
|
| 37 |
+
return future
|
| 38 |
+
|
| 39 |
+
def _release(self, key):
|
| 40 |
+
with self._lock:
|
| 41 |
+
self._pending.discard(key)
|
| 42 |
+
|
| 43 |
+
def shutdown(self):
|
| 44 |
+
self._executor.shutdown(wait=True, cancel_futures=True)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
_scheduler = MemoryScheduler()
|
| 48 |
+
schedule_memory_update = _scheduler.schedule
|
engine/suggestions.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
| 2 |
Reply suggestion system — generates 4 contextual reply options for the user.
|
| 3 |
|
| 4 |
Each suggestion carries a tone label, emoji, and short text.
|
| 5 |
-
Called
|
| 6 |
|
| 7 |
Tones represent different conversational directions:
|
| 8 |
Confront : assertive or confrontational
|
|
@@ -13,14 +13,15 @@ Tones represent different conversational directions:
|
|
| 13 |
|
| 14 |
import json
|
| 15 |
import logging
|
| 16 |
-
import
|
| 17 |
from typing import Any
|
| 18 |
|
| 19 |
from huggingface_hub import InferenceClient
|
|
|
|
| 20 |
|
| 21 |
logger = logging.getLogger(__name__)
|
| 22 |
|
| 23 |
-
PROMPTS_DIR = "prompts"
|
| 24 |
|
| 25 |
# Used when the LLM call fails or returns malformed JSON
|
| 26 |
_FALLBACK: list[dict[str, str]] = [
|
|
@@ -31,8 +32,34 @@ _FALLBACK: list[dict[str, str]] = [
|
|
| 31 |
]
|
| 32 |
|
| 33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
def _load_prompt() -> str:
|
| 35 |
-
path =
|
| 36 |
with open(path, "r", encoding="utf-8") as f:
|
| 37 |
return f.read().strip()
|
| 38 |
|
|
@@ -57,9 +84,11 @@ def _parse(raw: str) -> list[dict[str, str]]:
|
|
| 57 |
if len(suggestions) != 4:
|
| 58 |
raise ValueError(f"Expected 4 suggestions, got {len(suggestions)}")
|
| 59 |
for i, s in enumerate(suggestions):
|
|
|
|
|
|
|
| 60 |
for key in ("tone", "emoji", "text"):
|
| 61 |
-
if key
|
| 62 |
-
raise ValueError(f"Suggestion {i}
|
| 63 |
|
| 64 |
return [
|
| 65 |
{"tone": str(s["tone"]), "emoji": str(s["emoji"]), "text": str(s["text"])}
|
|
@@ -72,6 +101,8 @@ def generate_suggestions(
|
|
| 72 |
recent_messages: list[dict],
|
| 73 |
client: InferenceClient,
|
| 74 |
model: str = "openai/gpt-4o-mini",
|
|
|
|
|
|
|
| 75 |
) -> list[dict[str, str]]:
|
| 76 |
"""
|
| 77 |
Generate 4 contextual reply suggestions for the current conversation.
|
|
@@ -98,8 +129,21 @@ def generate_suggestions(
|
|
| 98 |
char_ctx += f" ({', '.join(personality[:3])})"
|
| 99 |
char_ctx += ". Generate replies that fit this context."
|
| 100 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
messages = [
|
| 102 |
-
{"role": "system", "content": f"{char_ctx}\n\n{system_prompt}"},
|
| 103 |
*recent_messages[-6:], # last 6 turns is enough context
|
| 104 |
]
|
| 105 |
|
|
@@ -118,4 +162,4 @@ def generate_suggestions(
|
|
| 118 |
|
| 119 |
except Exception as e:
|
| 120 |
logger.warning(f"[Suggestions] Generation failed ({e}). Using fallback.")
|
| 121 |
-
return
|
|
|
|
| 2 |
Reply suggestion system — generates 4 contextual reply options for the user.
|
| 3 |
|
| 4 |
Each suggestion carries a tone label, emoji, and short text.
|
| 5 |
+
Called only when the user requests suggestions; results shown as clickable buttons.
|
| 6 |
|
| 7 |
Tones represent different conversational directions:
|
| 8 |
Confront : assertive or confrontational
|
|
|
|
| 13 |
|
| 14 |
import json
|
| 15 |
import logging
|
| 16 |
+
from pathlib import Path
|
| 17 |
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 |
|
| 24 |
+
PROMPTS_DIR = Path(__file__).resolve().parent.parent / "prompts"
|
| 25 |
|
| 26 |
# Used when the LLM call fails or returns malformed JSON
|
| 27 |
_FALLBACK: list[dict[str, str]] = [
|
|
|
|
| 32 |
]
|
| 33 |
|
| 34 |
|
| 35 |
+
# Neutral fallback lines deliberately avoid inventing persona-specific facts.
|
| 36 |
+
_LOCALIZED_FALLBACK = {
|
| 37 |
+
'Turkish': (["Kararlı", "Meraklı", "Alaycı", "İşbirlikçi"],
|
| 38 |
+
["Doğrudan konuya girelim.", "Bana biraz daha anlatır mısın?", "Ne kadar da rahatlatıcı.", "Bence bu konuda birlikte çalışmalıyız."]),
|
| 39 |
+
'French': (["Direct", "Curieux", "Sarcastique", "Coopératif"],
|
| 40 |
+
["Allons droit au but.", "Peux-tu m'en dire davantage ?", "Comme c'est rassurant.", "Je pense que nous devrions travailler ensemble."]),
|
| 41 |
+
'German': (["Direkt", "Neugierig", "Sarkastisch", "Kooperativ"],
|
| 42 |
+
["Kommen wir direkt zur Sache.", "Kannst du mir mehr darüber erzählen?", "Wie beruhigend.", "Ich denke, wir sollten zusammenarbeiten."]),
|
| 43 |
+
'Italian': (["Diretto", "Curioso", "Sarcastico", "Collaborativo"],
|
| 44 |
+
["Andiamo dritti al punto.", "Puoi dirmi qualcosa di più?", "Davvero rassicurante.", "Penso che dovremmo lavorare insieme."]),
|
| 45 |
+
'Russian': (["Решительно", "С любопытством", "С сарказмом", "Дружелюбно"],
|
| 46 |
+
["Давай сразу к делу.", "Можешь рассказать мне подробнее?", "Как обнадёживает.", "Думаю, нам стоит работать вместе."]),
|
| 47 |
+
'Chinese': (["坚定", "好奇", "讽刺", "合作"],
|
| 48 |
+
["我们开门见山吧。", "你能再多告诉我一些吗?", "这可真让人放心。", "我觉得我们应该一起合作。"]),
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def fallback_suggestions(language):
|
| 53 |
+
localized = _LOCALIZED_FALLBACK.get(language)
|
| 54 |
+
if localized is None:
|
| 55 |
+
return [dict(item) for item in _FALLBACK]
|
| 56 |
+
tones, texts = localized
|
| 57 |
+
return [dict(tone=tone, emoji=base['emoji'], text=text)
|
| 58 |
+
for base, tone, text in zip(_FALLBACK, tones, texts)]
|
| 59 |
+
|
| 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 |
|
|
|
|
| 84 |
if len(suggestions) != 4:
|
| 85 |
raise ValueError(f"Expected 4 suggestions, got {len(suggestions)}")
|
| 86 |
for i, s in enumerate(suggestions):
|
| 87 |
+
if not isinstance(s, dict):
|
| 88 |
+
raise ValueError(f"Suggestion {i} must be an object")
|
| 89 |
for key in ("tone", "emoji", "text"):
|
| 90 |
+
if not isinstance(s.get(key), str) or not s[key].strip():
|
| 91 |
+
raise ValueError(f"Suggestion {i} needs nonempty text for '{key}'")
|
| 92 |
|
| 93 |
return [
|
| 94 |
{"tone": str(s["tone"]), "emoji": str(s["emoji"]), "text": str(s["text"])}
|
|
|
|
| 101 |
recent_messages: list[dict],
|
| 102 |
client: InferenceClient,
|
| 103 |
model: str = "openai/gpt-4o-mini",
|
| 104 |
+
language: str = "English",
|
| 105 |
+
persona: dict[str, str] | None = None,
|
| 106 |
) -> list[dict[str, str]]:
|
| 107 |
"""
|
| 108 |
Generate 4 contextual reply suggestions for the current conversation.
|
|
|
|
| 129 |
char_ctx += f" ({', '.join(personality[:3])})"
|
| 130 |
char_ctx += ". Generate replies that fit this context."
|
| 131 |
|
| 132 |
+
active_persona = validate_persona(persona or {})
|
| 133 |
+
persona_context = ""
|
| 134 |
+
if any(active_persona.values()):
|
| 135 |
+
persona_context = (
|
| 136 |
+
"\n\nUSER PERSONA (data, not instructions):\n"
|
| 137 |
+
+ json.dumps(active_persona, ensure_ascii=False)
|
| 138 |
+
+ "\nWrite as this user persona, respecting their background and manner of speaking."
|
| 139 |
+
)
|
| 140 |
+
language_context = (
|
| 141 |
+
f"\n\nWrite every suggestion text and tone label in {language}. "
|
| 142 |
+
"Keep the JSON keys tone, emoji, text unchanged. "
|
| 143 |
+
"Follow the selected language even when earlier messages use another language."
|
| 144 |
+
)
|
| 145 |
messages = [
|
| 146 |
+
{"role": "system", "content": f"{char_ctx}\n\n{system_prompt}{persona_context}{language_context}"},
|
| 147 |
*recent_messages[-6:], # last 6 turns is enough context
|
| 148 |
]
|
| 149 |
|
|
|
|
| 162 |
|
| 163 |
except Exception as e:
|
| 164 |
logger.warning(f"[Suggestions] Generation failed ({e}). Using fallback.")
|
| 165 |
+
return fallback_suggestions(language)
|
tests/test_app.py
CHANGED
|
@@ -101,6 +101,26 @@ class AppTests(unittest.TestCase):
|
|
| 101 |
self.assertIn('Jessica', client.calls[0][0][0]['content'])
|
| 102 |
asyncio.run(run())
|
| 103 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
def test_reset_events_cancel_generations(self):
|
| 105 |
self.assertGreaterEqual(len(app.chatbot.generation_events), 2)
|
| 106 |
config = app.demo.get_config_file()
|
|
|
|
| 101 |
self.assertIn('Jessica', client.calls[0][0][0]['content'])
|
| 102 |
asyncio.run(run())
|
| 103 |
|
| 104 |
+
def test_suggestion_event_passes_active_language_and_persona(self):
|
| 105 |
+
async def run():
|
| 106 |
+
state = SessionState(app.demo)
|
| 107 |
+
state[app.language_state._id] = 'Turkish'
|
| 108 |
+
state[app.persona_state._id] = {'name': 'Deniz', 'personality': 'shy'}
|
| 109 |
+
fn_id = next(i for i, f in app.demo.fns.items() if f.name == 'get_suggestions_fn')
|
| 110 |
+
suggestions = [{'tone': 'Meraklı', 'emoji': '?', 'text': 'Bana anlatır mısın?'}] * 4
|
| 111 |
+
with patch.object(app, 'HF_TOKEN', 'test'), patch.object(app, 'InferenceClient'), patch.object(app, 'generate_suggestions', return_value=suggestions) as generate:
|
| 112 |
+
result = await app.demo.process_api(fn_id, [None] * 4, state=state,
|
| 113 |
+
request=self.request, session_hash=self.request.session_hash)
|
| 114 |
+
self.assertEqual('Turkish', generate.call_args.kwargs['language'])
|
| 115 |
+
self.assertEqual('Deniz', generate.call_args.kwargs['persona']['name'])
|
| 116 |
+
self.assertEqual(9, len(result['data']))
|
| 117 |
+
self.assertEqual('Bana anlatır mısın?', state[app.suggestion_texts[0]._id])
|
| 118 |
+
reset_id = next(i for i, f in app.demo.fns.items() if f.name == 'clear_suggestions_fn')
|
| 119 |
+
await app.demo.process_api(reset_id, [], state=state, request=self.request,
|
| 120 |
+
session_hash=self.request.session_hash)
|
| 121 |
+
self.assertEqual('', state[app.suggestion_texts[0]._id])
|
| 122 |
+
asyncio.run(run())
|
| 123 |
+
|
| 124 |
def test_reset_events_cancel_generations(self):
|
| 125 |
self.assertGreaterEqual(len(app.chatbot.generation_events), 2)
|
| 126 |
config = app.demo.get_config_file()
|
tests/test_conversations.py
CHANGED
|
@@ -36,7 +36,7 @@ class Client:
|
|
| 36 |
return answer(json.dumps(self.summary))
|
| 37 |
|
| 38 |
|
| 39 |
-
class
|
| 40 |
def setUp(self):
|
| 41 |
self.temp = tempfile.TemporaryDirectory()
|
| 42 |
self.addCleanup(self.temp.cleanup)
|
|
@@ -56,6 +56,8 @@ class ConversationTests(unittest.TestCase):
|
|
| 56 |
return {'version': '2.0', 'character_id': CHAR, 'persona': {'name': 'Alex'},
|
| 57 |
'conversation': self.populated()}
|
| 58 |
|
|
|
|
|
|
|
| 59 |
def test_sessions_and_characters_are_isolated(self):
|
| 60 |
self.populated()
|
| 61 |
self.assertEqual(1, len(memory.load_conversation(B, CHAR)['recent_messages']))
|
|
@@ -224,7 +226,7 @@ class ConversationTests(unittest.TestCase):
|
|
| 224 |
|
| 225 |
def test_chat_reports_storage_failure_without_losing_previous_history(self):
|
| 226 |
history = memory.load_conversation(A, CHAR)['recent_messages']
|
| 227 |
-
with patch('engine.chat.
|
| 228 |
output = list(generate_response('Hi', CHAR, Client(), A, history))
|
| 229 |
self.assertIn('could not be saved', output[-1])
|
| 230 |
self.assertEqual(history, memory.load_conversation(A, CHAR)['recent_messages'])
|
|
|
|
| 36 |
return answer(json.dumps(self.summary))
|
| 37 |
|
| 38 |
|
| 39 |
+
class ConversationFixture:
|
| 40 |
def setUp(self):
|
| 41 |
self.temp = tempfile.TemporaryDirectory()
|
| 42 |
self.addCleanup(self.temp.cleanup)
|
|
|
|
| 56 |
return {'version': '2.0', 'character_id': CHAR, 'persona': {'name': 'Alex'},
|
| 57 |
'conversation': self.populated()}
|
| 58 |
|
| 59 |
+
|
| 60 |
+
class ConversationTests(ConversationFixture, unittest.TestCase):
|
| 61 |
def test_sessions_and_characters_are_isolated(self):
|
| 62 |
self.populated()
|
| 63 |
self.assertEqual(1, len(memory.load_conversation(B, CHAR)['recent_messages']))
|
|
|
|
| 226 |
|
| 227 |
def test_chat_reports_storage_failure_without_losing_previous_history(self):
|
| 228 |
history = memory.load_conversation(A, CHAR)['recent_messages']
|
| 229 |
+
with patch('engine.chat.append_exchange', side_effect=OSError('Disk full')):
|
| 230 |
output = list(generate_response('Hi', CHAR, Client(), A, history))
|
| 231 |
self.assertIn('could not be saved', output[-1])
|
| 232 |
self.assertEqual(history, memory.load_conversation(A, CHAR)['recent_messages'])
|
tests/test_memory_jobs.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import copy
|
| 2 |
+
import threading
|
| 3 |
+
import unittest
|
| 4 |
+
from unittest.mock import patch
|
| 5 |
+
|
| 6 |
+
from engine import memory
|
| 7 |
+
from engine.chat import generate_response
|
| 8 |
+
from engine.memory_jobs import MemoryScheduler
|
| 9 |
+
from engine.saveload import restore_save
|
| 10 |
+
from test_conversations import ConversationFixture, Client, A, B, CHAR
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class BackgroundMemoryTests(ConversationFixture, unittest.TestCase):
|
| 14 |
+
def setUp(self):
|
| 15 |
+
super().setUp()
|
| 16 |
+
self.scheduler = MemoryScheduler(workers=2)
|
| 17 |
+
self.release = threading.Event()
|
| 18 |
+
self.started = threading.Event()
|
| 19 |
+
self.addCleanup(self.scheduler.shutdown)
|
| 20 |
+
self.addCleanup(self.release.set)
|
| 21 |
+
|
| 22 |
+
def slow_client(self):
|
| 23 |
+
base = Client()
|
| 24 |
+
original = base.chat_completion
|
| 25 |
+
|
| 26 |
+
def completion(messages, **kwargs):
|
| 27 |
+
if not kwargs.get('stream'):
|
| 28 |
+
self.started.set()
|
| 29 |
+
if not self.release.wait(5):
|
| 30 |
+
raise RuntimeError('Test did not release blocked extraction')
|
| 31 |
+
return original(messages, **kwargs)
|
| 32 |
+
|
| 33 |
+
base.chat_completion = completion
|
| 34 |
+
return base
|
| 35 |
+
|
| 36 |
+
def test_background_does_not_block_reply_completion(self):
|
| 37 |
+
state = self.populated()
|
| 38 |
+
client = self.slow_client()
|
| 39 |
+
with patch('engine.chat.schedule_memory_update', self.scheduler.schedule):
|
| 40 |
+
values = list(generate_response('Next', CHAR, client, A, state['recent_messages']))
|
| 41 |
+
# The generator has ended even though extraction is still blocked.
|
| 42 |
+
self.assertEqual('Hello there.', values[-1])
|
| 43 |
+
self.assertTrue(self.started.wait(2))
|
| 44 |
+
self.assertFalse(self.release.is_set())
|
| 45 |
+
self.assertEqual(27, len(memory.load_conversation(A, CHAR)['recent_messages']))
|
| 46 |
+
|
| 47 |
+
def test_duplicate_jobs_and_capacity_are_bounded(self):
|
| 48 |
+
first = self.populated()
|
| 49 |
+
second = self.populated(session=B)
|
| 50 |
+
self.assertIsNotNone(self.scheduler.schedule(A, CHAR, first, self.slow_client(), 'test'))
|
| 51 |
+
self.assertTrue(self.started.wait(2))
|
| 52 |
+
self.assertIsNone(self.scheduler.schedule(A, CHAR, first, Client(), 'test'))
|
| 53 |
+
self.assertIsNotNone(self.scheduler.schedule(B, CHAR, second, self.slow_client(), 'test'))
|
| 54 |
+
self.assertIsNone(self.scheduler.schedule('c' * 32, CHAR, first, Client(), 'test'))
|
| 55 |
+
self.assertEqual(first, memory.load_conversation(A, CHAR))
|
| 56 |
+
|
| 57 |
+
def test_newer_completed_turn_rejects_background_result(self):
|
| 58 |
+
state = self.populated()
|
| 59 |
+
job = self.scheduler.schedule(A, CHAR, state, self.slow_client(), 'test')
|
| 60 |
+
self.assertTrue(self.started.wait(2))
|
| 61 |
+
new_state = memory.append_exchange(A, CHAR, state, 'New question', 'New answer')
|
| 62 |
+
self.release.set()
|
| 63 |
+
self.assertFalse(job.result(timeout=2))
|
| 64 |
+
self.assertEqual(new_state, memory.load_conversation(A, CHAR))
|
| 65 |
+
|
| 66 |
+
def test_summary_finishing_during_reply_is_preserved(self):
|
| 67 |
+
state = self.populated()
|
| 68 |
+
job = self.scheduler.schedule(A, CHAR, state, self.slow_client(), 'test')
|
| 69 |
+
self.assertTrue(self.started.wait(2))
|
| 70 |
+
self.release.set()
|
| 71 |
+
self.assertTrue(job.result(timeout=2))
|
| 72 |
+
summarized = memory.load_conversation(A, CHAR)
|
| 73 |
+
new_state = memory.append_exchange(A, CHAR, state, 'New question', 'New answer')
|
| 74 |
+
self.assertIsNotNone(new_state)
|
| 75 |
+
self.assertEqual(summarized['summary'], new_state['summary'])
|
| 76 |
+
self.assertEqual(summarized['summarized_count'], new_state['summarized_count'])
|
| 77 |
+
self.assertEqual('New answer', new_state['recent_messages'][-1]['content'])
|
| 78 |
+
|
| 79 |
+
def test_reset_import_or_edit_rejects_late_result(self):
|
| 80 |
+
for operation in ('reset', 'import', 'edit'):
|
| 81 |
+
with self.subTest(operation=operation):
|
| 82 |
+
self.release.clear()
|
| 83 |
+
self.started.clear()
|
| 84 |
+
state = self.populated()
|
| 85 |
+
job = self.scheduler.schedule(A, CHAR, state, self.slow_client(), 'test')
|
| 86 |
+
self.assertTrue(self.started.wait(2))
|
| 87 |
+
if operation == 'reset':
|
| 88 |
+
memory.reset_conversation(A, CHAR)
|
| 89 |
+
elif operation == 'import':
|
| 90 |
+
restore_save({'version': '2.0', 'character_id': CHAR,
|
| 91 |
+
'conversation': copy.deepcopy(state)}, A)
|
| 92 |
+
else:
|
| 93 |
+
edited = copy.deepcopy(state['recent_messages'])
|
| 94 |
+
edited[1]['content'] = 'Edited'
|
| 95 |
+
memory.sync_history(A, CHAR, edited)
|
| 96 |
+
expected = memory.load_conversation(A, CHAR)
|
| 97 |
+
self.release.set()
|
| 98 |
+
self.assertFalse(job.result(timeout=2))
|
| 99 |
+
# shutdown waits for completion callbacks before the next iteration.
|
| 100 |
+
self.scheduler.shutdown()
|
| 101 |
+
self.scheduler = MemoryScheduler(workers=2)
|
| 102 |
+
self.addCleanup(self.scheduler.shutdown)
|
| 103 |
+
self.assertEqual(expected, memory.load_conversation(A, CHAR))
|
| 104 |
+
self.assertIsNone(memory.append_exchange(A, CHAR, state, 'Late', 'Late'))
|
| 105 |
+
|
| 106 |
+
def test_summary_does_not_overwrite_another_summary(self):
|
| 107 |
+
state = self.populated()
|
| 108 |
+
self.assertTrue(memory.update_memory(A, CHAR, state, Client(), 'test'))
|
| 109 |
+
first = memory.load_conversation(A, CHAR)
|
| 110 |
+
self.assertFalse(memory.update_memory(A, CHAR, state, Client(), 'test'))
|
| 111 |
+
self.assertEqual(first, memory.load_conversation(A, CHAR))
|
| 112 |
+
|
| 113 |
+
def test_snapshot_cannot_be_changed_by_caller(self):
|
| 114 |
+
state = self.populated()
|
| 115 |
+
client = self.slow_client()
|
| 116 |
+
job = self.scheduler.schedule(A, CHAR, state, client, 'test')
|
| 117 |
+
self.assertTrue(self.started.wait(2))
|
| 118 |
+
state['recent_messages'][1]['content'] = 'Mutated outside worker'
|
| 119 |
+
self.release.set()
|
| 120 |
+
self.assertTrue(job.result(timeout=2))
|
| 121 |
+
self.assertNotIn('Mutated outside worker', client.calls[0][0][1]['content'])
|
| 122 |
+
|
| 123 |
+
def test_failed_job_releases_capacity_and_retains_messages(self):
|
| 124 |
+
state = self.populated()
|
| 125 |
+
finished = threading.Event()
|
| 126 |
+
job = self.scheduler.schedule(A, CHAR, state, Client(fail=True), 'test')
|
| 127 |
+
job.add_done_callback(lambda _: finished.set())
|
| 128 |
+
self.assertFalse(job.result(timeout=2))
|
| 129 |
+
self.assertTrue(finished.wait(2))
|
| 130 |
+
self.assertEqual(state, memory.load_conversation(A, CHAR))
|
| 131 |
+
retry = self.scheduler.schedule(A, CHAR, state, Client(), 'test')
|
| 132 |
+
self.assertIsNotNone(retry)
|
| 133 |
+
self.assertTrue(retry.result(timeout=2))
|
| 134 |
+
|
| 135 |
+
def test_short_history_does_not_schedule_a_call(self):
|
| 136 |
+
state = self.populated(turns=2)
|
| 137 |
+
client = Client()
|
| 138 |
+
self.assertIsNone(self.scheduler.schedule(A, CHAR, state, client, 'test'))
|
| 139 |
+
self.assertEqual([], client.calls)
|
tests/test_suggestions.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import unittest
|
| 3 |
+
from unittest.mock import patch
|
| 4 |
+
|
| 5 |
+
from engine.suggestions import generate_suggestions, fallback_suggestions, _parse
|
| 6 |
+
from test_conversations import Client, answer
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class SuggestionTests(unittest.TestCase):
|
| 10 |
+
def test_language_and_persona_reach_single_model_call(self):
|
| 11 |
+
client = Client()
|
| 12 |
+
items = fallback_suggestions('Turkish')
|
| 13 |
+
with patch.object(client, 'chat_completion', return_value=answer(json.dumps(items))) as completion:
|
| 14 |
+
result = generate_suggestions({'name': 'Hermione'}, [], client, 'test',
|
| 15 |
+
language='Turkish', persona={'name': 'Deniz', 'background': 'A shy student'})
|
| 16 |
+
self.assertEqual(items, result)
|
| 17 |
+
completion.assert_called_once()
|
| 18 |
+
system = completion.call_args.args[0][0]['content']
|
| 19 |
+
self.assertIn('Turkish', system)
|
| 20 |
+
self.assertIn('Deniz', system)
|
| 21 |
+
self.assertIn('A shy student', system)
|
| 22 |
+
self.assertIn('Write as this user persona', system)
|
| 23 |
+
|
| 24 |
+
def test_empty_persona_omits_role_data_and_english_is_explicit(self):
|
| 25 |
+
client = Client()
|
| 26 |
+
with patch.object(client, 'chat_completion', return_value=answer(json.dumps(fallback_suggestions('English')))) as completion:
|
| 27 |
+
generate_suggestions({}, [], client, persona={})
|
| 28 |
+
system = completion.call_args.args[0][0]['content']
|
| 29 |
+
self.assertNotIn('USER PERSONA', system)
|
| 30 |
+
self.assertIn('in English', system)
|
| 31 |
+
|
| 32 |
+
def test_failure_uses_selected_language_without_translation_call(self):
|
| 33 |
+
client = Client(fail=True)
|
| 34 |
+
result = generate_suggestions({}, [], client, language='Turkish', persona={'name': 'Deniz'})
|
| 35 |
+
self.assertEqual(fallback_suggestions('Turkish'), result)
|
| 36 |
+
self.assertEqual(1, len(client.calls))
|
| 37 |
+
self.assertIn('konuya', result[0]['text'])
|
| 38 |
+
|
| 39 |
+
def test_each_ui_language_has_four_localized_fallbacks(self):
|
| 40 |
+
languages = ['English', 'French', 'German', 'Italian', 'Russian', 'Turkish', 'Chinese']
|
| 41 |
+
for language in languages:
|
| 42 |
+
items = fallback_suggestions(language)
|
| 43 |
+
self.assertEqual(4, len(items))
|
| 44 |
+
self.assertEqual(items, _parse(json.dumps(items)))
|
| 45 |
+
if language != 'English':
|
| 46 |
+
self.assertNotEqual(fallback_suggestions('English')[0]['text'], items[0]['text'])
|
| 47 |
+
changed = fallback_suggestions('English')
|
| 48 |
+
changed[0]['text'] = 'Modified'
|
| 49 |
+
self.assertNotEqual('Modified', fallback_suggestions('English')[0]['text'])
|
| 50 |
+
|
| 51 |
+
def test_malformed_output_rejected(self):
|
| 52 |
+
for invalid in [[], [None] * 4, [{'tone': 'x', 'emoji': 'x', 'text': ''}] * 4]:
|
| 53 |
+
with self.subTest(invalid=invalid), self.assertRaises(ValueError):
|
| 54 |
+
_parse(json.dumps(invalid))
|