| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298 |
- import hashlib
- import unittest
- from pathlib import Path
- from tempfile import TemporaryDirectory
- from unittest.mock import patch
- from urllib.parse import parse_qs, urlsplit
- from langgraph.types import Command
- from src.step3_question_answer.graph import build_graph
- from src.step3_question_answer.qcc import Config, QccClient, QccError, load_config
- COMPANY = {'KeyNo': 'synthetic-a', 'Name': '合成甲企业', 'CreditCode': 'SYNTHETIC-A'}
- OTHER = {'KeyNo': 'synthetic-b', 'Name': '合成乙企业', 'CreditCode': 'SYNTHETIC-B'}
- class Wire:
- def __init__(self, *responses):
- self.responses = list(responses)
- self.calls = []
- def __call__(self, url, headers, timeout):
- self.calls.append((url, headers, timeout))
- result = self.responses.pop(0)
- if isinstance(result, Exception):
- raise result
- return result
- def ok(result):
- return {'Status': '200', 'Result': result}
- def client(wire, pages=3):
- return QccClient(Config('synthetic-key', 'synthetic-secret', honor_max_pages=pages), wire, lambda: 1700000000)
- class ProtocolTests(unittest.TestCase):
- def test_config_only_synthetic_secrets(self):
- with TemporaryDirectory() as directory:
- path = Path(directory) / '.env'
- path.write_text('QCC_KEY=synthetic\nQCC_SECRET_KEY=hidden\n', encoding='utf-8')
- config = load_config(path, {'QCC_TIMEOUT_SECONDS': '12'})
- self.assertEqual(config.timeout, 12)
- self.assertNotIn('hidden', repr(config))
- with self.assertRaisesRegex(ValueError, '^invalid_qcc_configuration$'):
- load_config(path, {'QCC_TIMEOUT_SECONDS': 'secret-like-invalid-value'})
- def test_configured_qcc_urls_are_used_for_all_requests(self):
- with TemporaryDirectory() as directory:
- path = Path(directory) / '.env'
- path.write_text('QCC_KEY=synthetic\nQCC_SECRET_KEY=hidden\n'
- 'QCC_SEARCH_URL=https://gateway.example.test/search\n'
- 'QCC_BASIC_URL=https://gateway.example.test/basic\n'
- 'QCC_HONOR_URL=https://gateway.example.test/honor\n', encoding='utf-8')
- config = load_config(path, {})
- wire = Wire(ok([COMPANY]), ok(COMPANY), ok({'VerifyResult': 0, 'Data': []}))
- adapter = QccClient(config, wire, lambda: 1700000000)
- adapter.search('合成')
- adapter.basic(COMPANY)
- adapter.honors(COMPANY)
- self.assertEqual([urlsplit(call[0]).path for call in wire.calls],
- ['/search', '/basic', '/honor'])
- self.assertEqual([urlsplit(call[0]).hostname for call in wire.calls],
- ['gateway.example.test'] * 3)
- self.assertNotIn('gateway.example.test', repr(config))
- override = load_config(path, {'QCC_SEARCH_URL': 'https://override.example.test/search'})
- self.assertEqual(override.search_url, 'https://override.example.test/search')
- def test_invalid_qcc_urls_are_rejected(self):
- for value in ('', 'http://example.test/search', 'https://user:pass@example.test/search',
- 'https://example.test/search?token=secret', 'https://example.test/search#fragment',
- 'https://example.test:bad/search', 'https://example.test/search path'):
- with self.subTest(value=value), self.assertRaisesRegex(ValueError, 'invalid_qcc_url'):
- Config('synthetic-key', 'synthetic-secret', search_url=value)
- def test_exact_full_page_stops_by_total(self):
- data = [{'Name': '合成荣誉'} for _ in range(20)]
- wire = Wire({**ok({'VerifyResult': 1, 'Data': data}), 'Paging': {'TotalRecords': 20}})
- self.assertTrue(client(wire).honors(COMPANY)['complete'])
- self.assertEqual(len(wire.calls), 1)
- candidates = [{**COMPANY, 'KeyNo': str(i)} for i in range(5)]
- result = client(Wire({**ok(candidates), 'Paging': {'TotalRecords': 5}})).search('合成')
- self.assertFalse(result['may_have_more'])
- def test_signature_query_and_provenance(self):
- wire = Wire(ok([COMPANY]))
- result = client(wire).search('合成 企业&分支', 2)
- url, headers, timeout = wire.calls[0]
- self.assertEqual(urlsplit(url).path, '/FuzzySearch/GetList')
- self.assertEqual(parse_qs(urlsplit(url).query)['searchKey'], ['合成 企业&分支'])
- self.assertEqual(parse_qs(urlsplit(url).query)['pageIndex'], ['2'])
- self.assertEqual(headers['Token'], hashlib.md5(b'synthetic-key1700000000synthetic-secret').hexdigest().upper())
- self.assertEqual(headers['Timespan'], '1700000000')
- self.assertEqual(result['source']['api_code'], '886')
- self.assertIn('2023', result['source']['fetched_at'])
- self.assertNotIn('synthetic-key', repr(client(wire).config))
- def test_empty_distinct_from_service_error(self):
- self.assertEqual(client(Wire(ok([]))).search('合成')['candidates'], [])
- for status in ('201', 201):
- for extra in ({}, {'Result': None}, {'Result': []}):
- with self.subTest(status=status, extra=extra):
- result = client(Wire({'Status': status, **extra})).search('合成')
- self.assertEqual(result['candidates'], [])
- self.assertFalse(result['may_have_more'])
- self.assertEqual(result['source']['api_code'], '886')
- self.assertEqual(result['source']['total_records'], 0)
- for payload in ({'Status': '201', 'Result': [COMPANY]}, {'Status': '102'}, {'Status': '999'}):
- with self.assertRaises(QccError):
- client(Wire(payload)).search('合成')
- with self.assertRaisesRegex(QccError, 'provider_failure'):
- client(Wire({'Status': '201'})).basic(COMPANY)
- for payload in [{'Status': '500', 'Message': 'private-secret'}, {}, ok(None), ok({})]:
- with self.subTest(payload=payload):
- with self.assertRaises(QccError) as raised:
- client(Wire(payload)).search('合成')
- self.assertNotIn('private-secret', str(raised.exception))
- def test_basic_identity(self):
- wire = Wire(ok(COMPANY))
- result = client(wire).basic(COMPANY)
- self.assertEqual(result['source']['company_key'], COMPANY['KeyNo'])
- self.assertEqual(parse_qs(urlsplit(wire.calls[0][0]).query)['keyword'], [COMPANY['Name']])
- for row in [OTHER, {**COMPANY, 'CreditCode': 'other'}, None]:
- with self.assertRaises(QccError):
- client(Wire(ok(row))).basic(COMPANY)
- def test_honor_pagination_fields_and_partial(self):
- data = [{'Name': f'合成荣誉{i}', 'BegingDate': '2020-01-01', 'DeadLine': None} for i in range(20)]
- wire = Wire(ok({'VerifyResult': 1, 'Data': data}), ok({'VerifyResult': 1, 'Data': [data[0]]}))
- result = client(wire).honors(COMPANY)
- self.assertTrue(result['complete'])
- self.assertEqual(len(result['records']), 21)
- self.assertIsNone(result['records'][0]['data']['DeadLine'])
- self.assertEqual(result['records'][-1]['source']['page_index'], 2)
- self.assertEqual(parse_qs(urlsplit(wire.calls[1][0]).query)['pageSize'], ['20'])
- partial = client(Wire(ok({'VerifyResult': 1, 'Data': data}), QccError('network_failure'))).honors(COMPANY)
- self.assertFalse(partial['complete'])
- self.assertEqual(len(partial['records']), 20)
- self.assertEqual(partial['error'], 'network_failure')
- limited = client(Wire(ok({'VerifyResult': 1, 'Data': data})), pages=1).honors(COMPANY)
- self.assertEqual(limited['error'], 'page_limit')
- def test_honor_empty_and_invalid(self):
- self.assertTrue(client(Wire(ok({'VerifyResult': 0, 'Data': None}))).honors(COMPANY)['complete'])
- for data in [{'VerifyResult': 1, 'Data': None}, {'VerifyResult': 0, 'Data': [{}]}, {}]:
- result = client(Wire(ok(data))).honors(COMPANY)
- self.assertFalse(result['complete'])
- self.assertIsNotNone(result['error'])
- class GraphTests(unittest.TestCase):
- def test_unique_exact_name_or_code_skips_confirmation(self):
- for hint in (COMPANY['Name'], COMPANY['CreditCode']):
- with self.subTest(hint=hint):
- wire = Wire(ok([OTHER, COMPANY]), ok(COMPANY), ok({'VerifyResult': 0, 'Data': []}))
- graph = build_graph(client(wire), lambda q: {
- 'conversation_kind': 'specific_need', 'needs_company': True, 'company_hint': hint})
- question = f'我们是{hint},想扩产有什么支持?'
- result = graph.invoke({'question': question}, self.config())
- self.assertNotIn('__interrupt__', result)
- self.assertEqual(result['selected_company']['KeyNo'], COMPANY['KeyNo'])
- self.assertEqual(result['compang_info']['profile']['data'], COMPANY)
- self.assertEqual(result['handoff']['original_question'], question)
- self.assertEqual(len(wire.calls), 3)
- def test_duplicate_exact_names_still_require_confirmation(self):
- wire = Wire(ok([COMPANY, {**OTHER, 'Name': COMPANY['Name']}]))
- graph = build_graph(client(wire), lambda q: {
- 'conversation_kind': 'specific_need', 'needs_company': True, 'company_hint': COMPANY['Name']})
- result = graph.invoke({'question': COMPANY['Name'] + '想扩产'}, self.config())
- self.assertIn('__interrupt__', result)
- self.assertIsNone(result['selected_company'])
- self.assertEqual(len(wire.calls), 1)
- def test_refined_exact_name_continues_without_second_confirmation(self):
- wire = Wire(ok([]), ok([COMPANY]), ok(COMPANY), ok({'VerifyResult': 0, 'Data': []}))
- graph = build_graph(client(wire), lambda q: {
- 'conversation_kind': 'specific_need', 'needs_company': True, 'company_hint': '简称'})
- graph.invoke({'question': '简称想扩产'}, self.config())
- result = graph.invoke(Command(resume={'action': 'refine', 'keyword': COMPANY['Name']}), self.config())
- self.assertNotIn('__interrupt__', result)
- self.assertEqual(result['handoff']['original_question'], '简称想扩产')
- self.assertEqual(result['searches'], 2)
- def test_auto_selected_identity_still_validated_by_basic(self):
- wire = Wire(ok([COMPANY]), ok(OTHER))
- graph = build_graph(client(wire), lambda q: {
- 'conversation_kind': 'specific_need', 'needs_company': True, 'company_hint': COMPANY['Name']})
- result = graph.invoke({'question': COMPANY['Name'] + '想扩产'}, self.config())
- self.assertIn('basic:company_identity_mismatch', result['errors'])
- self.assertIsNone(result['honors'])
- self.assertEqual(result['compang_info'], {})
- self.assertEqual(len(wire.calls), 2)
- def test_model_invented_full_name_cannot_trigger_auto_selection(self):
- wire = Wire()
- graph = build_graph(client(wire), lambda q: {
- 'conversation_kind': 'specific_need', 'needs_company': True, 'company_hint': COMPANY['Name']})
- result = graph.invoke({'question': '简称想扩产'}, self.config())
- self.assertIn('intent_analysis_failed', result['errors'])
- self.assertIsNone(result['selected_company'])
- self.assertEqual(wire.calls, [])
- def config(self, thread='one'):
- return {'configurable': {'thread_id': thread}, 'recursion_limit': 100}
- def test_confirm_required_even_single_and_no_replayed_search(self):
- wire = Wire(ok([COMPANY]), ok(COMPANY), ok({'VerifyResult': 0, 'Data': []}))
- graph = build_graph(client(wire), lambda q: {'conversation_kind': 'specific_need', 'needs_company': True, 'company_hint': '合成'})
- result = graph.invoke({'question': '合成公司如何招人'}, self.config())
- self.assertIn('__interrupt__', result)
- self.assertEqual(len(wire.calls), 1)
- invalid = graph.invoke(Command(resume={'action': 'select', 'key_no': OTHER['KeyNo']}), self.config())
- self.assertIn('__interrupt__', invalid)
- self.assertEqual(len(wire.calls), 1)
- final = graph.invoke(Command(resume={'action': 'select', 'key_no': COMPANY['KeyNo']}), self.config())
- self.assertEqual(len(wire.calls), 3)
- self.assertEqual(final['handoff']['original_question'], '合成公司如何招人')
- self.assertEqual(final['handoff']['qualification_status'], 'not_evaluated')
- self.assertEqual(final['handoff']['policy_evidence'], [])
- def test_personal_skip_and_reset_and_threads(self):
- wire = Wire(ok([COMPANY]), ok(COMPANY), ok({'VerifyResult': 0, 'Data': []}))
- graph = build_graph(client(wire), lambda q: {'conversation_kind': 'specific_need', 'needs_company': '公司' in q, 'company_hint': '合成' if '公司' in q else ''})
- graph.invoke({'question': '合成公司咨询'}, self.config())
- personal = graph.invoke({'question': '个人办事'}, self.config('two'))
- self.assertIsNone(personal['selected_company'])
- self.assertEqual(len(wire.calls), 1)
- graph.invoke(Command(resume={'action': 'select', 'key_no': COMPANY['KeyNo']}), self.config())
- reset = graph.invoke({'question': '个人咨询'}, self.config())
- self.assertIsNone(reset['profile'])
- self.assertIsNone(reset['honors'])
- self.assertIsNone(reset['selected_company'])
- self.assertEqual(len(wire.calls), 3)
- def test_refine_empty_and_cancel(self):
- wire = Wire(ok([]), ok([COMPANY, OTHER]))
- graph = build_graph(client(wire))
- graph.invoke({'question': '扩产想找支持'}, self.config())
- graph.invoke(Command(resume={'needs_company': True, 'company_hint': '模糊名称'}), self.config())
- result = graph.invoke(Command(resume={'action': 'refine', 'keyword': '新名称'}), self.config())
- self.assertEqual(len(result['search_result']['candidates']), 2)
- result = graph.invoke(Command(resume={'action': 'cancel'}), self.config())
- self.assertFalse(result['handoff']['ready_for_retrieval'])
- self.assertEqual(len(wire.calls), 2)
- def test_identity_mismatch_stops_honors(self):
- wire = Wire(ok([COMPANY]), ok(OTHER))
- graph = build_graph(client(wire), lambda q: {'conversation_kind': 'specific_need', 'needs_company': True, 'company_hint': '合成'})
- graph.invoke({'question': '合成融资'}, self.config())
- result = graph.invoke(Command(resume={'action': 'select', 'key_no': COMPANY['KeyNo']}), self.config())
- self.assertIsNone(result['honors'])
- self.assertFalse(result['handoff']['ready_for_retrieval'])
- self.assertIn('basic:company_identity_mismatch', result['errors'])
- self.assertEqual(len(wire.calls), 2)
- def test_untrusted_model_hint_requires_clarification(self):
- wire = Wire()
- graph = build_graph(client(wire), lambda q: {'conversation_kind': 'specific_need', 'needs_company': True, 'company_hint': '编造全名'})
- result = graph.invoke({'question': '我厂扩产缺资金'}, self.config())
- self.assertNotIn('__interrupt__', result)
- self.assertEqual(result['conversation_kind'], 'policy_overview')
- self.assertEqual(result['recommendation'], {})
- self.assertEqual(wire.calls, [])
- def test_search_errors_retry_bounded(self):
- wire = Wire(*[QccError('network_failure') for _ in range(5)])
- graph = build_graph(client(wire), lambda q: {'conversation_kind': 'specific_need', 'needs_company': True, 'company_hint': '合成'})
- result = graph.invoke({'question': '合成融资'}, self.config())
- for _ in range(5):
- result = graph.invoke(Command(resume={'action': 'retry'}), self.config())
- self.assertNotIn('__interrupt__', result)
- self.assertIn('search_limit', result['errors'])
- self.assertEqual(len(wire.calls), 5)
- class IntentTests(unittest.TestCase):
- def test_model_json_and_truncation(self):
- from src.step3_question_answer.intent import deepseek_analyzer
- from src.step1_knowledge_prepare.summarize import Config as ModelConfig
- config = ModelConfig('fake-secret', 'https://example.invalid', 'synthetic-model')
- response = {'choices': [{'finish_reason': 'stop', 'message': {
- 'content': '{"needs_company":false,"company_hint":"","cipa":false}'}}]}
- with patch('src.step3_question_answer.intent.load_config', return_value=config), patch(
- 'src.step3_question_answer.intent.transport', return_value=response) as send:
- analyzer = deepseek_analyzer(Path('unused'))
- self.assertFalse(analyzer('个人咨询')['needs_company'])
- self.assertEqual(send.call_args.args[0]['messages'][-1]['content'], '个人咨询')
- response['choices'][0]['finish_reason'] = 'length'
- with self.assertRaisesRegex(ValueError, 'incomplete_intent'):
- analyzer('个人咨询')
- if __name__ == '__main__':
- unittest.main()
|