test_deploy_check.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273
  1. """Synthetic deployment diagnostics; no external traffic or personal data."""
  2. import json
  3. from pathlib import Path
  4. import tempfile
  5. import unittest
  6. from unittest.mock import Mock, patch
  7. from scripts import deploy_check
  8. from src.dms_schema import DEFAULT_SCHEMA
  9. class FakeMilvus:
  10. def __init__(self, **kwargs):
  11. self.db = None
  12. self.index_count = 3
  13. def list_databases(self, **kwargs):
  14. return ['test_qa']
  15. def use_database(self, name, **kwargs):
  16. self.db = name
  17. def list_collections(self, **kwargs):
  18. return ['index_x', 'content_x']
  19. def query(self, name, **kwargs):
  20. return [{'count(*)': self.index_count if name == 'index_x' else 5}]
  21. class FakeQcc:
  22. def search(self, name):
  23. return {'candidates': [{'Name': name, 'KeyNo': 'synthetic'}]}
  24. def basic(self, company):
  25. return {'data': {'KeyNo': company['KeyNo']}}
  26. def honors(self, company):
  27. return {'complete': True, 'records': [], 'sources': [{}], 'error': None}
  28. class FakeDmsSessions:
  29. def __init__(self, config):
  30. self.config = config
  31. def connect(self):
  32. return self
  33. class FakeDmsCompany:
  34. connected = []
  35. def __init__(self, config):
  36. self.config = config
  37. def connect(self, identity):
  38. self.connected.append(identity)
  39. return self
  40. class DeployCheckTests(unittest.TestCase):
  41. def setUp(self):
  42. temporary = tempfile.TemporaryDirectory()
  43. self.addCleanup(temporary.cleanup)
  44. self.root = Path(temporary.name)
  45. (self.root / 'knowledge/prepared/ingestion').mkdir(parents=True)
  46. self.manifest = {'complete': True, 'snapshot': 'synthetic', 'counts': {'qa': 3},
  47. 'databases': {'qa': {'database': 'test_qa', 'index_collection': 'index_x',
  48. 'content_collection': 'content_x'}}}
  49. (self.root / 'knowledge/prepared/ingestion/manifest.json').write_text(json.dumps(self.manifest))
  50. (self.root / 'config').mkdir()
  51. self.schema = json.loads(json.dumps(DEFAULT_SCHEMA))
  52. (self.root / 'config/dms_schema.json').write_text(json.dumps(self.schema), encoding='utf-8')
  53. (self.root / '.env').write_text('DMS_BASE_URL=https://example.invalid/dms\nDMS_TOKEN=synthetic\n')
  54. def test_milvus_compares_published_counts(self):
  55. with (patch('src.step2_knowledge_ingest.ingest.config', return_value={
  56. 'MILVUS_URI': 'http://synthetic.invalid', 'MILVUS_TIMEOUT_SECONDS': 2,
  57. 'MILVUS_DB_PREFIX': 'test'}),
  58. patch('src.step2_knowledge_ingest.ingest.load', return_value=[{'kind': 'qa'}] * 3),
  59. patch('src.step2_knowledge_ingest.ingest.content_parts', side_effect=[[0, 1], [0, 1], [0]] * 3)):
  60. self._check_milvus_counts()
  61. def _check_milvus_counts(self):
  62. healthy = deploy_check.milvus(self.root, FakeMilvus)
  63. self.assertTrue(healthy['ok'])
  64. self.assertEqual(healthy['libraries']['qa']['content_segments'], 5)
  65. client = FakeMilvus()
  66. client.index_count = 2
  67. changed = deploy_check.milvus(self.root, lambda **kwargs: client)
  68. self.assertFalse(changed['ok'])
  69. self.assertEqual(changed['libraries']['qa']['index_delta'], -1)
  70. self.manifest['databases']['qa']['database'] = 'wrong_qa'
  71. (self.root / 'knowledge/prepared/ingestion/manifest.json').write_text(json.dumps(self.manifest))
  72. self.assertEqual(deploy_check.milvus(self.root, FakeMilvus)['libraries']['qa']['code'],
  73. 'database_missing_or_mismatched')
  74. def test_redis_pings_once_and_closes(self):
  75. client = Mock()
  76. client.ping.return_value = True
  77. with patch('src.step3_question_answer.context.load_context_config',
  78. return_value=Mock(url='redis://synthetic.invalid')):
  79. self.assertTrue(deploy_check.redis_check(self.root, lambda *args, **kwargs: client)['ok'])
  80. client.ping.assert_called_once()
  81. client.close.assert_called_once()
  82. def test_milvus_checks_talent_three_libraries_and_preserves_business_fields(self):
  83. self._check_multiple_agents()
  84. def test_milvus_talent_differences_do_not_hide_business_report(self):
  85. for fault in ('database', 'count', 'collection', 'identity', 'prefix'):
  86. with self.subTest(fault=fault):
  87. self._check_multiple_agents(fault)
  88. def _check_multiple_agents(self, fault=None):
  89. prepared = self.root / 'knowledge/prepared-talent'
  90. (prepared / 'ingestion').mkdir(parents=True, exist_ok=True)
  91. counts = {'article': 2, 'qa': 4, 'policy': 1}
  92. manifest = {'complete': True, 'snapshot': 'talent-synthetic', 'counts': counts,
  93. 'databases': {kind: {'database': 'talent_knowledge_' + kind,
  94. 'index_collection': 'index_x', 'content_collection': 'content_x'}
  95. for kind in counts}}
  96. (prepared / 'ingestion/manifest.json').write_text(json.dumps(manifest))
  97. data = [{'kind': 'qa'}] * 3
  98. talent = [{'kind': kind if kind != 'article' else 'general', 'knowledge_agent': 'talent', 'data': {},
  99. 'collection': {'article': '人才政策和服务', 'qa': '人才政策问答', 'policy': '青浦区惠企政策事项'}[kind]}
  100. for kind, count in counts.items() for _ in range(count)]
  101. if fault == 'identity':
  102. talent[0] = {**talent[0], 'knowledge_agent': 'business'}
  103. clients = []
  104. class MultiMilvus(FakeMilvus):
  105. def __init__(self, **kwargs):
  106. super().__init__(**kwargs)
  107. self.closed, self.queries = False, []
  108. clients.append(self)
  109. def list_databases(self, **kwargs):
  110. names = ['test_qa'] + ['talent_knowledge_' + kind for kind in counts]
  111. return names[:-1] if fault == 'database' else names
  112. def list_collections(self, **kwargs):
  113. return [] if fault == 'collection' and self.db == 'talent_knowledge_policy' else ['index_x', 'content_x']
  114. def query(self, name, **kwargs):
  115. self.queries.append(kwargs['output_fields'])
  116. total = 3 if self.db == 'test_qa' else counts[self.db.removeprefix('talent_knowledge_')]
  117. if fault == 'count' and self.db == 'talent_knowledge_policy':
  118. total -= 1
  119. return [{'count(*)': total}]
  120. def close(self):
  121. self.closed = True
  122. settings = {'MILVUS_URI': 'http://synthetic.invalid', 'MILVUS_TIMEOUT_SECONDS': 2,
  123. 'MILVUS_DB_PREFIX': 'test'}
  124. if fault == 'prefix':
  125. settings['TALENT_MILVUS_DB_PREFIX'] = 'test'
  126. with (patch('src.step2_knowledge_ingest.ingest.config', return_value=settings),
  127. patch('src.step2_knowledge_ingest.ingest.load', side_effect=lambda path: talent if path == prepared else data),
  128. patch('src.step2_knowledge_ingest.ingest.content_parts', return_value=[0])):
  129. report = deploy_check.milvus(self.root, MultiMilvus)
  130. self.assertTrue(report['agents']['business']['ok'])
  131. self.assertEqual(report['expected_total'], 3)
  132. self.assertEqual(report['agents']['talent']['ok'], fault is None)
  133. self.assertEqual(report['ok'], fault is None)
  134. self.assertTrue(all(client.closed for client in clients))
  135. self.assertTrue(all(fields == ['count(*)'] for client in clients for fields in client.queries))
  136. if fault is None:
  137. self.assertEqual(report['expected_total_all_agents'], 10)
  138. self.assertEqual(report['indexed_total_all_agents'], 10)
  139. self.assertEqual(report['agents']['talent']['libraries']['article']['database'], 'talent_knowledge_article')
  140. elif fault == 'count':
  141. self.assertEqual(report['agents']['talent']['libraries']['policy']['index_delta'], -1)
  142. def test_milvus_existing_talent_directory_without_manifest_is_not_ignored(self):
  143. (self.root / 'knowledge/prepared-talent').mkdir()
  144. with (patch('src.step2_knowledge_ingest.ingest.config', return_value={
  145. 'MILVUS_URI': 'http://synthetic.invalid', 'MILVUS_TIMEOUT_SECONDS': 2, 'MILVUS_DB_PREFIX': 'test'}),
  146. patch('src.step2_knowledge_ingest.ingest.load', return_value=[{'kind': 'qa'}] * 3),
  147. patch('src.step2_knowledge_ingest.ingest.content_parts', side_effect=[[0, 1], [0, 1], [0]])):
  148. report = deploy_check.milvus(self.root, FakeMilvus)
  149. self.assertTrue(report['agents']['business']['ok'])
  150. self.assertFalse(report['ok'])
  151. self.assertEqual(report['agents']['talent']['code'], 'manifest_missing')
  152. def test_dms_checks_session_and_company_schema_without_content_reads(self):
  153. FakeDmsCompany.connected = []
  154. with patch.dict('os.environ', {}, clear=True):
  155. report = deploy_check.dms(self.root, FakeDmsSessions, FakeDmsCompany)
  156. self.assertTrue(report['ok'])
  157. self.assertEqual(report['scopes']['sessions']['columns'], 3)
  158. self.assertEqual(report['scopes']['company']['columns'], 2)
  159. self.assertEqual(FakeDmsCompany.connected,
  160. [self.schema['columns'][alias]['id'] for alias in ('basic', 'honor')])
  161. def test_dms_uses_configured_column_ids_and_names(self):
  162. self.schema['columns']['basic']['id'] = 2888
  163. self.schema['columns']['honor']['id'] = 2886
  164. self.schema['columns']['basic']['name'] = '企业档案'
  165. self.schema['columns']['basic']['fields']['c_name'] = 'c_business_name'
  166. (self.root / 'config/dms_schema.json').write_text(json.dumps(self.schema), encoding='utf-8')
  167. FakeDmsCompany.connected = []
  168. observed = []
  169. class CapturingCompany(FakeDmsCompany):
  170. def __init__(self, config):
  171. super().__init__(config)
  172. observed.append(config)
  173. with patch.dict('os.environ', {}, clear=True):
  174. report = deploy_check.dms(self.root, FakeDmsSessions, CapturingCompany)
  175. self.assertTrue(report['ok'])
  176. self.assertEqual(FakeDmsCompany.connected, [2888, 2886])
  177. self.assertEqual(observed[0].scope_names, ('企业档案', '企业荣誉信息'))
  178. self.assertEqual(observed[0].schema['columns']['basic']['fields']['c_name'], 'c_business_name')
  179. def test_dms_reports_independent_scopes_and_redacts_errors(self):
  180. class BrokenSession(FakeDmsSessions):
  181. def connect(self):
  182. raise RuntimeError('secret-url')
  183. with patch.dict('os.environ', {}, clear=True):
  184. report = deploy_check.dms(self.root, BrokenSession, FakeDmsCompany)
  185. self.assertFalse(report['ok'])
  186. self.assertEqual(report['scopes']['sessions']['code'], 'request_failed')
  187. self.assertTrue(report['scopes']['company']['ok'])
  188. self.assertNotIn('secret-url', json.dumps(report))
  189. def test_deepseek_requires_actual_content(self):
  190. config = Mock(model='synthetic', api_key='secret')
  191. with patch('src.step1_knowledge_prepare.summarize.load_config', return_value=config):
  192. self.assertFalse(deploy_check.deepseek(self.root, lambda payload, cfg: {'choices': []})['ok'])
  193. self.assertTrue(deploy_check.deepseek(self.root, lambda payload, cfg: {
  194. 'choices': [{'message': {'content': 'OK'}}]})['ok'])
  195. def test_qcc_checks_three_interfaces_without_echoing_company(self):
  196. report = deploy_check.qcc(self.root, 'synthetic company', FakeQcc())
  197. self.assertTrue(report['ok'])
  198. self.assertEqual(set(report['interfaces']), {'886', '410', '1001'})
  199. self.assertNotIn('synthetic company', json.dumps(report))
  200. fake = FakeQcc()
  201. fake.search = lambda name: {'candidates': []}
  202. report = deploy_check.qcc(self.root, 'synthetic company', fake)
  203. self.assertFalse(report['ok'])
  204. self.assertEqual(report['interfaces']['410']['code'], 'exact_company_not_found')
  205. def test_run_continues_on_failure_and_hides_exception_message(self):
  206. with (patch.object(deploy_check, 'milvus', side_effect=RuntimeError('secret-url')),
  207. patch.object(deploy_check, 'redis_check', return_value=deploy_check.result(True, 'connected')),
  208. patch.object(deploy_check, 'dms', return_value=deploy_check.result(True, 'usable')),
  209. patch.object(deploy_check, 'deepseek', return_value=deploy_check.result(True, 'usable')),
  210. patch.object(deploy_check, 'qcc', return_value=deploy_check.result(True, 'usable'))):
  211. with patch('builtins.print') as printed:
  212. self.assertEqual(deploy_check.run(self.root, 'synthetic'), 1)
  213. output = printed.call_args.args[0]
  214. self.assertNotIn('secret-url', output)
  215. self.assertEqual(json.loads(output)['checks']['milvus']['code'], 'request_failed')
  216. def test_independent_checks_only_call_selected_service(self):
  217. for action, selected in (('check-milvus', 'milvus'), ('check-redis', 'redis'),
  218. ('check-dms', 'dms'), ('check-deepseek', 'deepseek'), ('check-qcc', 'qcc')):
  219. with self.subTest(action=action):
  220. probes = {name: Mock(return_value=deploy_check.result(True, 'usable'))
  221. for name in ('milvus', 'redis_check', 'dms', 'deepseek', 'qcc')}
  222. with (patch.object(deploy_check, 'milvus', probes['milvus']),
  223. patch.object(deploy_check, 'redis_check', probes['redis_check']),
  224. patch.object(deploy_check, 'dms', probes['dms']),
  225. patch.object(deploy_check, 'deepseek', probes['deepseek']),
  226. patch.object(deploy_check, 'qcc', probes['qcc']),
  227. patch('builtins.print') as printed):
  228. self.assertEqual(deploy_check.run(self.root, 'synthetic', action), 0)
  229. self.assertEqual(set(json.loads(printed.call_args.args[0])['checks']), {selected})
  230. for name, probe in probes.items():
  231. self.assertEqual(probe.call_count, int(name == ('redis_check' if selected == 'redis' else selected)))