test_agent.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298
  1. import hashlib
  2. import unittest
  3. from pathlib import Path
  4. from tempfile import TemporaryDirectory
  5. from unittest.mock import patch
  6. from urllib.parse import parse_qs, urlsplit
  7. from langgraph.types import Command
  8. from src.step3_question_answer.graph import build_graph
  9. from src.step3_question_answer.qcc import Config, QccClient, QccError, load_config
  10. COMPANY = {'KeyNo': 'synthetic-a', 'Name': '合成甲企业', 'CreditCode': 'SYNTHETIC-A'}
  11. OTHER = {'KeyNo': 'synthetic-b', 'Name': '合成乙企业', 'CreditCode': 'SYNTHETIC-B'}
  12. class Wire:
  13. def __init__(self, *responses):
  14. self.responses = list(responses)
  15. self.calls = []
  16. def __call__(self, url, headers, timeout):
  17. self.calls.append((url, headers, timeout))
  18. result = self.responses.pop(0)
  19. if isinstance(result, Exception):
  20. raise result
  21. return result
  22. def ok(result):
  23. return {'Status': '200', 'Result': result}
  24. def client(wire, pages=3):
  25. return QccClient(Config('synthetic-key', 'synthetic-secret', honor_max_pages=pages), wire, lambda: 1700000000)
  26. class ProtocolTests(unittest.TestCase):
  27. def test_config_only_synthetic_secrets(self):
  28. with TemporaryDirectory() as directory:
  29. path = Path(directory) / '.env'
  30. path.write_text('QCC_KEY=synthetic\nQCC_SECRET_KEY=hidden\n', encoding='utf-8')
  31. config = load_config(path, {'QCC_TIMEOUT_SECONDS': '12'})
  32. self.assertEqual(config.timeout, 12)
  33. self.assertNotIn('hidden', repr(config))
  34. with self.assertRaisesRegex(ValueError, '^invalid_qcc_configuration$'):
  35. load_config(path, {'QCC_TIMEOUT_SECONDS': 'secret-like-invalid-value'})
  36. def test_configured_qcc_urls_are_used_for_all_requests(self):
  37. with TemporaryDirectory() as directory:
  38. path = Path(directory) / '.env'
  39. path.write_text('QCC_KEY=synthetic\nQCC_SECRET_KEY=hidden\n'
  40. 'QCC_SEARCH_URL=https://gateway.example.test/search\n'
  41. 'QCC_BASIC_URL=https://gateway.example.test/basic\n'
  42. 'QCC_HONOR_URL=https://gateway.example.test/honor\n', encoding='utf-8')
  43. config = load_config(path, {})
  44. wire = Wire(ok([COMPANY]), ok(COMPANY), ok({'VerifyResult': 0, 'Data': []}))
  45. adapter = QccClient(config, wire, lambda: 1700000000)
  46. adapter.search('合成')
  47. adapter.basic(COMPANY)
  48. adapter.honors(COMPANY)
  49. self.assertEqual([urlsplit(call[0]).path for call in wire.calls],
  50. ['/search', '/basic', '/honor'])
  51. self.assertEqual([urlsplit(call[0]).hostname for call in wire.calls],
  52. ['gateway.example.test'] * 3)
  53. self.assertNotIn('gateway.example.test', repr(config))
  54. override = load_config(path, {'QCC_SEARCH_URL': 'https://override.example.test/search'})
  55. self.assertEqual(override.search_url, 'https://override.example.test/search')
  56. def test_invalid_qcc_urls_are_rejected(self):
  57. for value in ('', 'http://example.test/search', 'https://user:pass@example.test/search',
  58. 'https://example.test/search?token=secret', 'https://example.test/search#fragment',
  59. 'https://example.test:bad/search', 'https://example.test/search path'):
  60. with self.subTest(value=value), self.assertRaisesRegex(ValueError, 'invalid_qcc_url'):
  61. Config('synthetic-key', 'synthetic-secret', search_url=value)
  62. def test_exact_full_page_stops_by_total(self):
  63. data = [{'Name': '合成荣誉'} for _ in range(20)]
  64. wire = Wire({**ok({'VerifyResult': 1, 'Data': data}), 'Paging': {'TotalRecords': 20}})
  65. self.assertTrue(client(wire).honors(COMPANY)['complete'])
  66. self.assertEqual(len(wire.calls), 1)
  67. candidates = [{**COMPANY, 'KeyNo': str(i)} for i in range(5)]
  68. result = client(Wire({**ok(candidates), 'Paging': {'TotalRecords': 5}})).search('合成')
  69. self.assertFalse(result['may_have_more'])
  70. def test_signature_query_and_provenance(self):
  71. wire = Wire(ok([COMPANY]))
  72. result = client(wire).search('合成 企业&分支', 2)
  73. url, headers, timeout = wire.calls[0]
  74. self.assertEqual(urlsplit(url).path, '/FuzzySearch/GetList')
  75. self.assertEqual(parse_qs(urlsplit(url).query)['searchKey'], ['合成 企业&分支'])
  76. self.assertEqual(parse_qs(urlsplit(url).query)['pageIndex'], ['2'])
  77. self.assertEqual(headers['Token'], hashlib.md5(b'synthetic-key1700000000synthetic-secret').hexdigest().upper())
  78. self.assertEqual(headers['Timespan'], '1700000000')
  79. self.assertEqual(result['source']['api_code'], '886')
  80. self.assertIn('2023', result['source']['fetched_at'])
  81. self.assertNotIn('synthetic-key', repr(client(wire).config))
  82. def test_empty_distinct_from_service_error(self):
  83. self.assertEqual(client(Wire(ok([]))).search('合成')['candidates'], [])
  84. for status in ('201', 201):
  85. for extra in ({}, {'Result': None}, {'Result': []}):
  86. with self.subTest(status=status, extra=extra):
  87. result = client(Wire({'Status': status, **extra})).search('合成')
  88. self.assertEqual(result['candidates'], [])
  89. self.assertFalse(result['may_have_more'])
  90. self.assertEqual(result['source']['api_code'], '886')
  91. self.assertEqual(result['source']['total_records'], 0)
  92. for payload in ({'Status': '201', 'Result': [COMPANY]}, {'Status': '102'}, {'Status': '999'}):
  93. with self.assertRaises(QccError):
  94. client(Wire(payload)).search('合成')
  95. with self.assertRaisesRegex(QccError, 'provider_failure'):
  96. client(Wire({'Status': '201'})).basic(COMPANY)
  97. for payload in [{'Status': '500', 'Message': 'private-secret'}, {}, ok(None), ok({})]:
  98. with self.subTest(payload=payload):
  99. with self.assertRaises(QccError) as raised:
  100. client(Wire(payload)).search('合成')
  101. self.assertNotIn('private-secret', str(raised.exception))
  102. def test_basic_identity(self):
  103. wire = Wire(ok(COMPANY))
  104. result = client(wire).basic(COMPANY)
  105. self.assertEqual(result['source']['company_key'], COMPANY['KeyNo'])
  106. self.assertEqual(parse_qs(urlsplit(wire.calls[0][0]).query)['keyword'], [COMPANY['Name']])
  107. for row in [OTHER, {**COMPANY, 'CreditCode': 'other'}, None]:
  108. with self.assertRaises(QccError):
  109. client(Wire(ok(row))).basic(COMPANY)
  110. def test_honor_pagination_fields_and_partial(self):
  111. data = [{'Name': f'合成荣誉{i}', 'BegingDate': '2020-01-01', 'DeadLine': None} for i in range(20)]
  112. wire = Wire(ok({'VerifyResult': 1, 'Data': data}), ok({'VerifyResult': 1, 'Data': [data[0]]}))
  113. result = client(wire).honors(COMPANY)
  114. self.assertTrue(result['complete'])
  115. self.assertEqual(len(result['records']), 21)
  116. self.assertIsNone(result['records'][0]['data']['DeadLine'])
  117. self.assertEqual(result['records'][-1]['source']['page_index'], 2)
  118. self.assertEqual(parse_qs(urlsplit(wire.calls[1][0]).query)['pageSize'], ['20'])
  119. partial = client(Wire(ok({'VerifyResult': 1, 'Data': data}), QccError('network_failure'))).honors(COMPANY)
  120. self.assertFalse(partial['complete'])
  121. self.assertEqual(len(partial['records']), 20)
  122. self.assertEqual(partial['error'], 'network_failure')
  123. limited = client(Wire(ok({'VerifyResult': 1, 'Data': data})), pages=1).honors(COMPANY)
  124. self.assertEqual(limited['error'], 'page_limit')
  125. def test_honor_empty_and_invalid(self):
  126. self.assertTrue(client(Wire(ok({'VerifyResult': 0, 'Data': None}))).honors(COMPANY)['complete'])
  127. for data in [{'VerifyResult': 1, 'Data': None}, {'VerifyResult': 0, 'Data': [{}]}, {}]:
  128. result = client(Wire(ok(data))).honors(COMPANY)
  129. self.assertFalse(result['complete'])
  130. self.assertIsNotNone(result['error'])
  131. class GraphTests(unittest.TestCase):
  132. def test_unique_exact_name_or_code_skips_confirmation(self):
  133. for hint in (COMPANY['Name'], COMPANY['CreditCode']):
  134. with self.subTest(hint=hint):
  135. wire = Wire(ok([OTHER, COMPANY]), ok(COMPANY), ok({'VerifyResult': 0, 'Data': []}))
  136. graph = build_graph(client(wire), lambda q: {
  137. 'conversation_kind': 'specific_need', 'needs_company': True, 'company_hint': hint})
  138. question = f'我们是{hint},想扩产有什么支持?'
  139. result = graph.invoke({'question': question}, self.config())
  140. self.assertNotIn('__interrupt__', result)
  141. self.assertEqual(result['selected_company']['KeyNo'], COMPANY['KeyNo'])
  142. self.assertEqual(result['compang_info']['profile']['data'], COMPANY)
  143. self.assertEqual(result['handoff']['original_question'], question)
  144. self.assertEqual(len(wire.calls), 3)
  145. def test_duplicate_exact_names_still_require_confirmation(self):
  146. wire = Wire(ok([COMPANY, {**OTHER, 'Name': COMPANY['Name']}]))
  147. graph = build_graph(client(wire), lambda q: {
  148. 'conversation_kind': 'specific_need', 'needs_company': True, 'company_hint': COMPANY['Name']})
  149. result = graph.invoke({'question': COMPANY['Name'] + '想扩产'}, self.config())
  150. self.assertIn('__interrupt__', result)
  151. self.assertIsNone(result['selected_company'])
  152. self.assertEqual(len(wire.calls), 1)
  153. def test_refined_exact_name_continues_without_second_confirmation(self):
  154. wire = Wire(ok([]), ok([COMPANY]), ok(COMPANY), ok({'VerifyResult': 0, 'Data': []}))
  155. graph = build_graph(client(wire), lambda q: {
  156. 'conversation_kind': 'specific_need', 'needs_company': True, 'company_hint': '简称'})
  157. graph.invoke({'question': '简称想扩产'}, self.config())
  158. result = graph.invoke(Command(resume={'action': 'refine', 'keyword': COMPANY['Name']}), self.config())
  159. self.assertNotIn('__interrupt__', result)
  160. self.assertEqual(result['handoff']['original_question'], '简称想扩产')
  161. self.assertEqual(result['searches'], 2)
  162. def test_auto_selected_identity_still_validated_by_basic(self):
  163. wire = Wire(ok([COMPANY]), ok(OTHER))
  164. graph = build_graph(client(wire), lambda q: {
  165. 'conversation_kind': 'specific_need', 'needs_company': True, 'company_hint': COMPANY['Name']})
  166. result = graph.invoke({'question': COMPANY['Name'] + '想扩产'}, self.config())
  167. self.assertIn('basic:company_identity_mismatch', result['errors'])
  168. self.assertIsNone(result['honors'])
  169. self.assertEqual(result['compang_info'], {})
  170. self.assertEqual(len(wire.calls), 2)
  171. def test_model_invented_full_name_cannot_trigger_auto_selection(self):
  172. wire = Wire()
  173. graph = build_graph(client(wire), lambda q: {
  174. 'conversation_kind': 'specific_need', 'needs_company': True, 'company_hint': COMPANY['Name']})
  175. result = graph.invoke({'question': '简称想扩产'}, self.config())
  176. self.assertIn('intent_analysis_failed', result['errors'])
  177. self.assertIsNone(result['selected_company'])
  178. self.assertEqual(wire.calls, [])
  179. def config(self, thread='one'):
  180. return {'configurable': {'thread_id': thread}, 'recursion_limit': 100}
  181. def test_confirm_required_even_single_and_no_replayed_search(self):
  182. wire = Wire(ok([COMPANY]), ok(COMPANY), ok({'VerifyResult': 0, 'Data': []}))
  183. graph = build_graph(client(wire), lambda q: {'conversation_kind': 'specific_need', 'needs_company': True, 'company_hint': '合成'})
  184. result = graph.invoke({'question': '合成公司如何招人'}, self.config())
  185. self.assertIn('__interrupt__', result)
  186. self.assertEqual(len(wire.calls), 1)
  187. invalid = graph.invoke(Command(resume={'action': 'select', 'key_no': OTHER['KeyNo']}), self.config())
  188. self.assertIn('__interrupt__', invalid)
  189. self.assertEqual(len(wire.calls), 1)
  190. final = graph.invoke(Command(resume={'action': 'select', 'key_no': COMPANY['KeyNo']}), self.config())
  191. self.assertEqual(len(wire.calls), 3)
  192. self.assertEqual(final['handoff']['original_question'], '合成公司如何招人')
  193. self.assertEqual(final['handoff']['qualification_status'], 'not_evaluated')
  194. self.assertEqual(final['handoff']['policy_evidence'], [])
  195. def test_personal_skip_and_reset_and_threads(self):
  196. wire = Wire(ok([COMPANY]), ok(COMPANY), ok({'VerifyResult': 0, 'Data': []}))
  197. graph = build_graph(client(wire), lambda q: {'conversation_kind': 'specific_need', 'needs_company': '公司' in q, 'company_hint': '合成' if '公司' in q else ''})
  198. graph.invoke({'question': '合成公司咨询'}, self.config())
  199. personal = graph.invoke({'question': '个人办事'}, self.config('two'))
  200. self.assertIsNone(personal['selected_company'])
  201. self.assertEqual(len(wire.calls), 1)
  202. graph.invoke(Command(resume={'action': 'select', 'key_no': COMPANY['KeyNo']}), self.config())
  203. reset = graph.invoke({'question': '个人咨询'}, self.config())
  204. self.assertIsNone(reset['profile'])
  205. self.assertIsNone(reset['honors'])
  206. self.assertIsNone(reset['selected_company'])
  207. self.assertEqual(len(wire.calls), 3)
  208. def test_refine_empty_and_cancel(self):
  209. wire = Wire(ok([]), ok([COMPANY, OTHER]))
  210. graph = build_graph(client(wire))
  211. graph.invoke({'question': '扩产想找支持'}, self.config())
  212. graph.invoke(Command(resume={'needs_company': True, 'company_hint': '模糊名称'}), self.config())
  213. result = graph.invoke(Command(resume={'action': 'refine', 'keyword': '新名称'}), self.config())
  214. self.assertEqual(len(result['search_result']['candidates']), 2)
  215. result = graph.invoke(Command(resume={'action': 'cancel'}), self.config())
  216. self.assertFalse(result['handoff']['ready_for_retrieval'])
  217. self.assertEqual(len(wire.calls), 2)
  218. def test_identity_mismatch_stops_honors(self):
  219. wire = Wire(ok([COMPANY]), ok(OTHER))
  220. graph = build_graph(client(wire), lambda q: {'conversation_kind': 'specific_need', 'needs_company': True, 'company_hint': '合成'})
  221. graph.invoke({'question': '合成融资'}, self.config())
  222. result = graph.invoke(Command(resume={'action': 'select', 'key_no': COMPANY['KeyNo']}), self.config())
  223. self.assertIsNone(result['honors'])
  224. self.assertFalse(result['handoff']['ready_for_retrieval'])
  225. self.assertIn('basic:company_identity_mismatch', result['errors'])
  226. self.assertEqual(len(wire.calls), 2)
  227. def test_untrusted_model_hint_requires_clarification(self):
  228. wire = Wire()
  229. graph = build_graph(client(wire), lambda q: {'conversation_kind': 'specific_need', 'needs_company': True, 'company_hint': '编造全名'})
  230. result = graph.invoke({'question': '我厂扩产缺资金'}, self.config())
  231. self.assertNotIn('__interrupt__', result)
  232. self.assertEqual(result['conversation_kind'], 'policy_overview')
  233. self.assertEqual(result['recommendation'], {})
  234. self.assertEqual(wire.calls, [])
  235. def test_search_errors_retry_bounded(self):
  236. wire = Wire(*[QccError('network_failure') for _ in range(5)])
  237. graph = build_graph(client(wire), lambda q: {'conversation_kind': 'specific_need', 'needs_company': True, 'company_hint': '合成'})
  238. result = graph.invoke({'question': '合成融资'}, self.config())
  239. for _ in range(5):
  240. result = graph.invoke(Command(resume={'action': 'retry'}), self.config())
  241. self.assertNotIn('__interrupt__', result)
  242. self.assertIn('search_limit', result['errors'])
  243. self.assertEqual(len(wire.calls), 5)
  244. class IntentTests(unittest.TestCase):
  245. def test_model_json_and_truncation(self):
  246. from src.step3_question_answer.intent import deepseek_analyzer
  247. from src.step1_knowledge_prepare.summarize import Config as ModelConfig
  248. config = ModelConfig('fake-secret', 'https://example.invalid', 'synthetic-model')
  249. response = {'choices': [{'finish_reason': 'stop', 'message': {
  250. 'content': '{"needs_company":false,"company_hint":"","cipa":false}'}}]}
  251. with patch('src.step3_question_answer.intent.load_config', return_value=config), patch(
  252. 'src.step3_question_answer.intent.transport', return_value=response) as send:
  253. analyzer = deepseek_analyzer(Path('unused'))
  254. self.assertFalse(analyzer('个人咨询')['needs_company'])
  255. self.assertEqual(send.call_args.args[0]['messages'][-1]['content'], '个人咨询')
  256. response['choices'][0]['finish_reason'] = 'length'
  257. with self.assertRaisesRegex(ValueError, 'incomplete_intent'):
  258. analyzer('个人咨询')
  259. if __name__ == '__main__':
  260. unittest.main()