test_answer_synthesis.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  1. import unittest
  2. from unittest.mock import Mock
  3. from src.step3_question_answer.knowledge_answers import synthesize_answers
  4. from src.step3_question_answer.recommendation import Recommender
  5. from src.step3_question_answer.tests.test_knowledge_answers import fixture, context, RECORD
  6. def entry(key, answer='提交申请表,再在线提交。'):
  7. return {'source_id': key, 'answer': answer, 'exact_qa': False,
  8. 'citations': [{'path': '/data/answer', 'quote': answer}]}
  9. class AnswerSynthesisTests(unittest.TestCase):
  10. def test_final_sources_repair_regenerates_from_original_evidence(self):
  11. model = Mock(side_effect=[{'text': '错误草稿', 'source_ids': ['unknown']},
  12. {'text': '重新核验后的回答', 'source_ids': ['a']}])
  13. result = synthesize_answers(model, '如何办理', [entry('a'), entry('b')], [], 'specific_need')
  14. self.assertEqual(result['status'], 'generated')
  15. self.assertEqual(result['text'], '重新核验后的回答')
  16. first, second = [c.args[1] for c in model.call_args_list]
  17. self.assertEqual(first['evidence'], second['evidence'])
  18. self.assertEqual(second['source_repair']['allowed_source_ids'], ['a', 'b'])
  19. self.assertNotIn('错误草稿', str(second))
  20. self.assertEqual(result['diagnostics']['source_repair_attempts'], 1)
  21. def test_reduction_sources_repair_is_batch_local_and_bounded(self):
  22. from src.step3_question_answer.knowledge_answers import REDUCE_PROMPT, input_size
  23. for recover in (True, False):
  24. calls = []
  25. def model(prompt, data):
  26. calls.append((prompt, data))
  27. self.assertLessEqual(input_size(prompt, data), 32768 - 16384 - 904)
  28. refs = list(dict.fromkeys(r for e in data['evidence']
  29. for r in e.get('source_ids', [e.get('source_id')])))
  30. if prompt.startswith(REDUCE_PROMPT) and len(calls) <= 2:
  31. if len(calls) == 1 or not recover:
  32. return {'text': '笔记', 'source_ids': ['other-batch']}
  33. self.assertEqual(data['source_repair']['allowed_source_ids'], sorted(refs))
  34. self.assertEqual(data['evidence'], calls[0][1]['evidence'])
  35. return {'text': '条件仍待核验。', 'source_ids': refs}
  36. result = synthesize_answers(model, '如何办理',
  37. [entry('a', '合成材料。' * 1000), entry('b')], [], 'specific_need')
  38. self.assertEqual(result['status'], 'generated' if recover else 'fallback')
  39. self.assertEqual(result['diagnostics']['source_repair_attempts'], 1)
  40. if not recover:
  41. self.assertEqual(len(calls), 2)
  42. self.assertEqual(result['error'], 'summary_invalid_sources')
  43. def test_short_guide_uses_synthesis_and_keeps_sources_in_both_modes(self):
  44. from src.step3_question_answer.report import markdown
  45. record = {**entry('guide', '合成事项可在合成窗口提交申请表,受理后审查并告知结果。'),
  46. 'knowledge_type': 'service_guide', 'title': '合成办事指南'}
  47. text = ('可在合成窗口申请该事项。\n\n### 办理渠道\n\n- 合成窗口。'
  48. '\n\n### 申请材料\n\n- 申请表。\n\n### 办理流程\n\n1. 提交申请。\n2. 受理后审查并告知结果。')
  49. for kind in ('policy_overview', 'specific_need'):
  50. model = Mock(return_value={'text': text, 'source_ids': ['guide']})
  51. result = synthesize_answers(model, '介绍办理指南', [record], [], kind)
  52. model.assert_called_once()
  53. self.assertEqual(result['status'], 'generated')
  54. self.assertEqual(model.call_args.args[1]['evidence'][0]['knowledge_type'], 'service_guide')
  55. self.assertEqual(result['citations'][0]['quote'], record['answer'])
  56. self.assertEqual(result['text'], text.replace('### 办理渠道', '### 一、办理渠道').replace('### 申请材料', '### 二、申请材料').replace('### 办理流程', '### 三、办理流程'))
  57. rendered = markdown(result['text'])
  58. self.assertLess(rendered.index('<h3>一、办理渠道'), rendered.index('<h3>二、申请材料'))
  59. self.assertLess(rendered.index('<h3>二、申请材料'), rendered.index('<h3>三、办理流程'))
  60. self.assertIn('<ol>', rendered)
  61. def test_short_finance_answer_is_formatted_without_losing_evidence(self):
  62. record = {**entry('finance', '合成周转贷最高500万元,须提供抵押。'),
  63. 'knowledge_type': 'finance', 'title': '合成周转贷'}
  64. text = '### 合成周转贷\n\n- **额度**:最高500万元。\n- **条件**:须提供抵押。'
  65. model = Mock(return_value={'text': text, 'source_ids': ['finance']})
  66. result = synthesize_answers(model, '融资300万元有哪些产品', [record], [], 'specific_need')
  67. model.assert_called_once()
  68. evidence = model.call_args.args[1]['evidence'][0]
  69. self.assertEqual(evidence['knowledge_type'], 'finance')
  70. self.assertEqual(evidence['title'], record['title'])
  71. self.assertEqual(result['text'], text)
  72. self.assertEqual(result['citations'][0]['quote'], record['citations'][0]['quote'])
  73. def test_short_nonfinance_answer_keeps_fast_path(self):
  74. model = Mock(side_effect=AssertionError('unnecessary generation'))
  75. record = entry('qa')
  76. result = synthesize_answers(model, '提交方式', [record], [], 'policy_overview')
  77. model.assert_not_called()
  78. self.assertEqual(result['text'], record['answer'])
  79. def test_large_input_is_fully_batched_with_wrapped_model(self):
  80. from types import SimpleNamespace
  81. from threading import Semaphore
  82. from src.step4_api.runtime import limited
  83. from src.step3_question_answer.knowledge_answers import input_size, REDUCE_PROMPT
  84. seen = []
  85. def call(prompt, data):
  86. self.assertLessEqual(input_size(prompt, data) + 16384 + 904, 32768)
  87. seen.append((prompt, data))
  88. refs = list(dict.fromkeys(r for e in data['evidence']
  89. for r in e.get('source_ids', [e.get('source_id')])))
  90. return {'text': '保留地区限制,先申请后办理。', 'source_ids': refs}
  91. call.config = SimpleNamespace(context_tokens=32768, margin=512)
  92. call.MAX_OUTPUT_TOKENS = 16384
  93. model = limited(call, Semaphore(1))
  94. records = [entry(str(i), ('地区限制不可忽略。' * 650) + str(i)) for i in range(4)]
  95. result = synthesize_answers(model, '如何办理', records, [], 'policy_overview')
  96. self.assertEqual(result['status'], 'generated', result.get('diagnostics'))
  97. self.assertEqual(set(result['source_ids']), {'0', '1', '2', '3'})
  98. self.assertEqual(len(result['citations']), 4)
  99. self.assertGreater(result['diagnostics']['model_calls'], 1)
  100. self.assertEqual(result['diagnostics']['context_limit'], 32768)
  101. fragments = [e for p, d in seen if p.startswith(REDUCE_PROMPT) for e in d['evidence']]
  102. import json
  103. for record in records:
  104. pieces = [e['fragment'] for e in fragments if e.get('source_ids') == [record['source_id']]]
  105. reconstructed = json.loads(''.join(pieces))
  106. self.assertEqual(reconstructed['answer'], record['answer'])
  107. self.assertEqual(reconstructed['citations'], record['citations'])
  108. def test_batch_failure_does_not_publish_partial_success(self):
  109. model = Mock(side_effect=RuntimeError('model_request_timeout'))
  110. result = synthesize_answers(model, '如何办理', [entry('a', '材料。' * 10000)], [], 'policy_overview')
  111. self.assertEqual(result['status'], 'fallback')
  112. self.assertEqual(result['error'], 'model_request_timeout')
  113. self.assertEqual(len(result['citations'][0]['quote']), 30000)
  114. def test_sources_and_cards_share_one_synthesis_without_appending(self):
  115. model = Mock(return_value={'text': '提交申请表后在线办理。', 'source_ids': ['a', 'b']})
  116. result = synthesize_answers(model, '如何办理', [entry('a')], [entry('b')], 'policy_overview')
  117. model.assert_called_once()
  118. self.assertEqual(result['text'], '提交申请表后在线办理。')
  119. self.assertEqual(len(model.call_args.args[1]['evidence']), 2)
  120. self.assertEqual(len(result['citations']), 2)
  121. def test_long_or_chunked_single_answer_is_synthesized(self):
  122. for answer in ('提交申请。\n提交申请。', '材料说明。' * 100):
  123. model = Mock(return_value={'text': '提交申请。', 'source_ids': ['a']})
  124. result = synthesize_answers(model, '如何办理', [entry('a', answer)], [], 'policy_overview')
  125. model.assert_called_once()
  126. self.assertEqual(result['text'], '提交申请。')
  127. def test_invalid_answers_are_still_rejected(self):
  128. for value, code in (({'text': '编造内容', 'source_ids': ['unknown']}, 'summary_invalid_sources'),
  129. ({}, 'summary_empty_text'), (None, 'summary_invalid_schema')):
  130. result = synthesize_answers(Mock(return_value=value), '如何办理',
  131. [entry('a'), entry('b')], [], 'policy_overview')
  132. self.assertEqual(result['error'], code)
  133. self.assertEqual(result['diagnostics']['stage'], 'validation')
  134. self.assertNotIn('提交申请表', result['text'])
  135. self.assertEqual(len(result['citations']), 2)
  136. def test_long_valid_answer_is_returned_without_length_retry_or_truncation(self):
  137. for length in (601, 1046, 1801, 3000):
  138. text = '字' * length
  139. model = Mock(return_value={'text': text, 'source_ids': ['a', 'b']})
  140. result = synthesize_answers(model, '如何办理', [entry('a'), entry('b')], [], 'policy_overview')
  141. self.assertEqual(result['status'], 'generated')
  142. self.assertEqual(result['text'], text)
  143. self.assertNotIn('error', result)
  144. self.assertNotIn('text_limit', result['diagnostics'])
  145. self.assertEqual(result['diagnostics']['output_chars'], length)
  146. model.assert_called_once()
  147. self.assertEqual(len(result['citations']), 2)
  148. def test_model_failures_keep_only_safe_codes_and_numeric_budget(self):
  149. from types import SimpleNamespace
  150. for code in ('analysis_context_limit', 'model_request_timeout', 'model_invalid_content_json',
  151. 'model_http_429', 'SECRET_EXCEPTION_BODY'):
  152. model = Mock(side_effect=RuntimeError(code))
  153. model.config = SimpleNamespace(context_tokens=32768, margin=100)
  154. model.MAX_OUTPUT_TOKENS = 16384
  155. result = synthesize_answers(model, '如何办理', [entry('a'), entry('b')], [], 'policy_overview')
  156. self.assertEqual(result['error'], 'summary_generation_failed' if code.startswith('SECRET') else code)
  157. d = result['diagnostics']
  158. self.assertEqual(d['stage'], 'model')
  159. self.assertEqual(d['reserved_total'], d['input_bytes'] + 16384 + 904)
  160. self.assertEqual(d['context_limit'], 32768)
  161. self.assertNotIn('SECRET', str(result))
  162. def test_explicit_detail_can_exceed_default_without_truncation(self):
  163. text = '资料' * 400
  164. result = synthesize_answers(Mock(return_value={'text': text, 'source_ids': ['a']}),
  165. '请详细介绍', [entry('a'), entry('b')], [], 'policy_overview')
  166. self.assertEqual(result['text'], text)
  167. self.assertEqual([c['source_id'] for c in result['citations']], ['a'])
  168. def test_recommender_merges_multiple_sources_and_preserves_full_records(self):
  169. records = [{**RECORD, 'id': key} for key in ('a', 'b')]
  170. def model(prompt, data):
  171. if prompt.startswith('将已核验'):
  172. return {'text': '接受电子材料,无需复印件。', 'source_ids': ['a', 'b']}
  173. return {'relevant': True, 'reason': '相关', 'answer': RECORD['data']['answer'],
  174. 'citations': [{'path': '/data/answer', 'quote': RECORD['data']['answer']}],
  175. 'conditions': [], 'card': None, 'match_basis': []}
  176. mock = Mock(side_effect=model)
  177. result = Recommender(mock).answer(context('该事项怎么交材料'), fixture(records))
  178. self.assertEqual(result['summary']['text'], '接受电子材料,无需复印件。')
  179. self.assertEqual(mock.call_count, 3)
  180. self.assertEqual([s['full_record'] for s in result['sources']], records)
  181. self.assertEqual(len(result['knowledge_answers']), 2)