test_session_archive.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. import json
  2. import os
  3. import threading
  4. import unittest
  5. from concurrent.futures import ThreadPoolExecutor
  6. from pathlib import Path
  7. from tempfile import TemporaryDirectory
  8. from types import SimpleNamespace
  9. from unittest.mock import patch
  10. from src.step3_question_answer.graph import build_graph
  11. from src.step3_question_answer.session_archive import (
  12. SessionArchive, SessionArchiveError, invoke_recorded, load_session_dir, resolve_session_dir,
  13. )
  14. from src.step3_question_answer.tests.test_context import fake_saver
  15. from src.step4_api.runtime import GraphRuntime
  16. class SessionArchiveTests(unittest.TestCase):
  17. def setUp(self):
  18. self.temp = TemporaryDirectory()
  19. self.addCleanup(self.temp.cleanup)
  20. self.root = Path(self.temp.name)
  21. self.archive = SessionArchive(self.root / '中文 会话')
  22. def read(self, thread='thread'):
  23. return json.loads(self.archive.path_for(thread).read_text(encoding='utf-8'))
  24. def run_turn(self, runtime, question, request_id, thread='thread', stopped=None):
  25. events = []
  26. def emit(event, data):
  27. if event == 'done':
  28. self.assertEqual(self.read(thread)['turns'][-1]['status'], data['status'])
  29. events.append((event, data))
  30. runtime.run(thread, question, emit, stopped or threading.Event(), request_id=request_id)
  31. return events
  32. def test_paths_config_quotes_env_override_and_foreign_absolute(self):
  33. config = self.root / 'synthetic.env'
  34. config.write_text('SESSION_DIR="资料\\会话 记录" # comment\nUNRELATED=unused\n', encoding='utf-8')
  35. self.assertEqual(load_session_dir(config, {}, self.root), self.root / '资料/会话 记录')
  36. self.assertEqual(load_session_dir(config, {'SESSION_DIR': 'other/session'}, self.root), self.root / 'other/session')
  37. self.assertEqual(resolve_session_dir(str(self.root / 'absolute'), self.root), self.root / 'absolute')
  38. self.assertEqual(load_session_dir(self.root / 'missing', {}, self.root), self.root / 'session')
  39. for value in ('', 'C:relative', '/var/lib/session' if os.name == 'nt' else 'C:\\session'):
  40. with self.subTest(value=value), self.assertRaises(SessionArchiveError):
  41. resolve_session_dir(value, self.root)
  42. if os.name == 'nt':
  43. self.assertEqual(resolve_session_dir('C:/archive/session'), Path('C:/archive/session'))
  44. self.assertEqual(resolve_session_dir(r'\\server\share\session'), Path(r'\\server\share\session'))
  45. else:
  46. self.assertEqual(resolve_session_dir('/var/lib/session'), Path('/var/lib/session'))
  47. def test_restart_clarifications_and_isolation_no_truncation(self):
  48. saver = fake_saver()
  49. runtime = GraphRuntime(build_graph(None, checkpointer=saver), self.archive)
  50. self.run_turn(runtime, '合成需求', 'r1')
  51. runtime = GraphRuntime(build_graph(None, checkpointer=saver), SessionArchive(self.archive.directory))
  52. self.run_turn(runtime, '不需要', 'r2')
  53. answer = '中文回答'
  54. runtime = GraphRuntime(build_graph(None, lambda _: {'conversation_kind': 'smalltalk', 'direct_response': answer},
  55. checkpointer=saver), self.archive)
  56. self.run_turn(runtime, '新问题', 'r3')
  57. self.run_turn(runtime, '其他窗口', 'r4', thread='../CON/另一个')
  58. doc = self.read()
  59. self.assertEqual(doc['thread_id'], 'thread')
  60. self.assertEqual([r['question'] for r in doc['turns']], ['合成需求', '不需要', '新问题'])
  61. self.assertEqual(doc['turns'][0]['status'], 'needs_input')
  62. self.assertTrue(doc['turns'][0]['ai_answer'])
  63. self.assertEqual(doc['turns'][0]['events'][-1]['event'], 'interrupt')
  64. self.assertEqual(doc['turns'][2]['ai_answer'], answer)
  65. self.assertEqual(doc['turns'][2]['result']['response'], answer)
  66. self.assertEqual(len(self.read('../CON/另一个')['turns']), 1)
  67. self.assertEqual(self.archive.path_for('../CON/另一个').parent, self.archive.directory)
  68. saver.delete_thread('thread')
  69. self.assertEqual(len(self.read()['turns']), 3)
  70. def test_full_structured_answer_snapshot_and_error(self):
  71. class Runtime(GraphRuntime):
  72. def _run(self, thread_id, question, emit, stopped, record):
  73. emit('answer', {'text': '开场'})
  74. emit('summary', {'text': '**结论**\n下一步' * 5000, 'source_ids': ['s1']})
  75. emit('result', {'response': '开场', 'recommendation': {
  76. 'items': [{'id': 'i1', 'title': '合成卡片'}],
  77. 'sources': [{'id': 's1', 'text': '合成原文'}]}})
  78. emit('done', {'status': 'completed'})
  79. runtime = Runtime(SimpleNamespace(checkpointer=None), self.archive)
  80. events = self.run_turn(runtime, '问题', 'r1')
  81. row = self.read()['turns'][0]
  82. self.assertEqual(row['ai_answer'], '开场\n\n' + '**结论**\n下一步' * 5000)
  83. self.assertEqual(row['result'], dict(events)['result'])
  84. def fail(*args):
  85. raise RuntimeError('PRIVATE_EXCEPTION')
  86. with patch.object(runtime, '_run', side_effect=fail), self.assertRaises(RuntimeError):
  87. self.run_turn(runtime, '失败问题', 'r2')
  88. row = self.read()['turns'][1]
  89. self.assertEqual(row['status'], 'failed')
  90. self.assertTrue(row['ai_answer'])
  91. self.assertNotIn('PRIVATE_EXCEPTION', json.dumps(row))
  92. def test_stop_and_write_failure_never_announce_done(self):
  93. runtime = GraphRuntime(build_graph(None), self.archive)
  94. stop = threading.Event()
  95. stop.set()
  96. self.run_turn(runtime, '停止问题', 'r1', stopped=stop)
  97. self.assertEqual(self.read()['turns'][0]['status'], 'stopped')
  98. runtime = GraphRuntime(build_graph(None, lambda _: {'conversation_kind': 'smalltalk', 'direct_response': '你好'}), self.archive)
  99. put = self.archive.put
  100. def fail_final(record):
  101. if record['finished_at']:
  102. raise SessionArchiveError('session_archive_write_failed')
  103. put(record)
  104. events = []
  105. with patch.object(self.archive, 'put', side_effect=fail_final), self.assertRaises(SessionArchiveError):
  106. runtime.run('thread', '你好', lambda e, d: events.append(e), threading.Event(), request_id='r2')
  107. self.assertNotIn('done', events)
  108. self.assertEqual(self.read()['turns'][1]['status'], 'running')
  109. def test_atomic_failure_corruption_and_idempotent_concurrency(self):
  110. def write(index):
  111. archive = SessionArchive(self.archive.directory)
  112. archive.put({'thread_id': 'thread', 'request_id': str(index), 'question': '合成'})
  113. with ThreadPoolExecutor(max_workers=8) as pool:
  114. list(pool.map(write, range(20)))
  115. write(0)
  116. self.assertEqual(len(self.read()['turns']), 20)
  117. before = self.archive.path_for('thread').read_bytes()
  118. with patch('src.step3_question_answer.session_archive.os.replace', side_effect=OSError), self.assertRaises(SessionArchiveError):
  119. write(21)
  120. self.assertEqual(self.archive.path_for('thread').read_bytes(), before)
  121. self.assertEqual(list(self.archive.directory.glob('*.tmp')), [])
  122. path = self.archive.path_for('thread')
  123. path.write_text('{broken', encoding='utf-8')
  124. with self.assertRaises(SessionArchiveError):
  125. write(22)
  126. self.assertEqual(path.read_text(), '{broken')
  127. def test_readable_filename_and_legacy_migration(self):
  128. import hashlib
  129. legacy = self.archive.directory / (hashlib.sha256(b'thread').hexdigest() + '.json')
  130. legacy.write_text(json.dumps({'schema_version': 1, 'thread_id': 'thread', 'turns': [
  131. {'request_id': 'old', 'thread_id': 'thread', 'question': 'old'}]}), encoding='utf-8')
  132. self.archive.put({'request_id': 'new', 'thread_id': 'thread', 'question': 'new'})
  133. self.assertEqual(self.archive.path_for('thread').name, 'thread.json')
  134. self.assertFalse(legacy.exists())
  135. self.assertEqual([r['request_id'] for r in self.read()['turns']], ['old', 'new'])
  136. for identity in ('CON', '../bad', 'thread/unsafe'):
  137. self.assertTrue(self.archive.path_for(identity).name.startswith('~'))
  138. self.archive.put({'request_id': 'upper', 'thread_id': 'THREAD', 'question': 'upper'})
  139. self.assertEqual(self.read()['thread_id'], 'thread')
  140. self.assertEqual(self.read('THREAD')['thread_id'], 'THREAD')
  141. def test_cli_records_question_and_clarification(self):
  142. from langgraph.types import Command
  143. graph = build_graph(None)
  144. config = {'configurable': {'thread_id': 'thread'}}
  145. invoke_recorded(graph, {'question': '合成问题'}, config, self.archive, '合成问题')
  146. invoke_recorded(graph, Command(resume={'needs_company': False, 'company_hint': ''}),
  147. config, self.archive, '', 'clarification_reply')
  148. rows = self.read()['turns']
  149. self.assertEqual([r['status'] for r in rows], ['needs_input', 'completed'])
  150. self.assertEqual(rows[1]['question'], '')
  151. self.assertTrue(all(r['ai_answer'] for r in rows))
  152. def test_http_sse_runtime_uses_dms_observer_without_local_archive(self):
  153. from starlette.testclient import TestClient
  154. from src.step4_api.app import create_app
  155. from src.step4_api.config import Settings
  156. from src.step4_api.runtime import Runtime
  157. from src.step3_question_answer.context import RedisContextSaver, ContextConfig
  158. from src.step3_question_answer.tests.test_context import FakeRedis
  159. class Saver(RedisContextSaver):
  160. def __init__(self, config):
  161. client = FakeRedis()
  162. client.ping = lambda: True
  163. super().__init__(ContextConfig('redis://localhost/0', 30), client)
  164. with patch('src.step4_api.runtime.RedisContextSaver', Saver), \
  165. patch('src.step4_api.runtime.TalentRuntime'), \
  166. patch('src.step4_api.runtime.load_context_config'), \
  167. patch('src.step4_api.runtime.Retriever'), \
  168. patch('src.step4_api.runtime.Recommender'), \
  169. patch('src.step4_api.runtime.JsonModel'), \
  170. patch('src.step4_api.runtime.deepseek_analyzer', return_value=lambda _: {
  171. 'conversation_kind': 'smalltalk', 'direct_response': '合成完整回答'}), \
  172. patch('src.step4_api.runtime.create_dms_observer') as observer_factory, \
  173. patch('src.step3_question_answer.session_archive.SessionArchive') as archive_factory, \
  174. TestClient(create_app(Settings(), Runtime)) as client:
  175. response = client.post('/api/chat', json={'thread_id': 'thread', 'question': '你好'})
  176. self.assertEqual(response.status_code, 200)
  177. self.assertIn('合成完整回答', response.text)
  178. self.assertFalse(self.archive.path_for('thread').exists())
  179. observer_factory.return_value.start.assert_called_once()
  180. observer_factory.return_value.close.assert_called_once()
  181. archive_factory.assert_not_called()
  182. if __name__ == '__main__':
  183. unittest.main()