import unittest
from unittest.mock import Mock
from src.step3_question_answer.knowledge_answers import synthesize_answers
from src.step3_question_answer.recommendation import Recommender
from src.step3_question_answer.tests.test_knowledge_answers import fixture, context, RECORD
def entry(key, answer='提交申请表,再在线提交。'):
return {'source_id': key, 'answer': answer, 'exact_qa': False,
'citations': [{'path': '/data/answer', 'quote': answer}]}
class AnswerSynthesisTests(unittest.TestCase):
def test_final_sources_repair_regenerates_from_original_evidence(self):
model = Mock(side_effect=[{'text': '错误草稿', 'source_ids': ['unknown']},
{'text': '重新核验后的回答', 'source_ids': ['a']}])
result = synthesize_answers(model, '如何办理', [entry('a'), entry('b')], [], 'specific_need')
self.assertEqual(result['status'], 'generated')
self.assertEqual(result['text'], '重新核验后的回答')
first, second = [c.args[1] for c in model.call_args_list]
self.assertEqual(first['evidence'], second['evidence'])
self.assertEqual(second['source_repair']['allowed_source_ids'], ['a', 'b'])
self.assertNotIn('错误草稿', str(second))
self.assertEqual(result['diagnostics']['source_repair_attempts'], 1)
def test_reduction_sources_repair_is_batch_local_and_bounded(self):
from src.step3_question_answer.knowledge_answers import REDUCE_PROMPT, input_size
for recover in (True, False):
calls = []
def model(prompt, data):
calls.append((prompt, data))
self.assertLessEqual(input_size(prompt, data), 32768 - 16384 - 904)
refs = list(dict.fromkeys(r for e in data['evidence']
for r in e.get('source_ids', [e.get('source_id')])))
if prompt.startswith(REDUCE_PROMPT) and len(calls) <= 2:
if len(calls) == 1 or not recover:
return {'text': '笔记', 'source_ids': ['other-batch']}
self.assertEqual(data['source_repair']['allowed_source_ids'], sorted(refs))
self.assertEqual(data['evidence'], calls[0][1]['evidence'])
return {'text': '条件仍待核验。', 'source_ids': refs}
result = synthesize_answers(model, '如何办理',
[entry('a', '合成材料。' * 1000), entry('b')], [], 'specific_need')
self.assertEqual(result['status'], 'generated' if recover else 'fallback')
self.assertEqual(result['diagnostics']['source_repair_attempts'], 1)
if not recover:
self.assertEqual(len(calls), 2)
self.assertEqual(result['error'], 'summary_invalid_sources')
def test_short_guide_uses_synthesis_and_keeps_sources_in_both_modes(self):
from src.step3_question_answer.report import markdown
record = {**entry('guide', '合成事项可在合成窗口提交申请表,受理后审查并告知结果。'),
'knowledge_type': 'service_guide', 'title': '合成办事指南'}
text = ('可在合成窗口申请该事项。\n\n### 办理渠道\n\n- 合成窗口。'
'\n\n### 申请材料\n\n- 申请表。\n\n### 办理流程\n\n1. 提交申请。\n2. 受理后审查并告知结果。')
for kind in ('policy_overview', 'specific_need'):
model = Mock(return_value={'text': text, 'source_ids': ['guide']})
result = synthesize_answers(model, '介绍办理指南', [record], [], kind)
model.assert_called_once()
self.assertEqual(result['status'], 'generated')
self.assertEqual(model.call_args.args[1]['evidence'][0]['knowledge_type'], 'service_guide')
self.assertEqual(result['citations'][0]['quote'], record['answer'])
self.assertEqual(result['text'], text.replace('### 办理渠道', '### 一、办理渠道').replace('### 申请材料', '### 二、申请材料').replace('### 办理流程', '### 三、办理流程'))
rendered = markdown(result['text'])
self.assertLess(rendered.index('
一、办理渠道'), rendered.index('二、申请材料'))
self.assertLess(rendered.index('二、申请材料'), rendered.index('三、办理流程'))
self.assertIn('', rendered)
def test_short_finance_answer_is_formatted_without_losing_evidence(self):
record = {**entry('finance', '合成周转贷最高500万元,须提供抵押。'),
'knowledge_type': 'finance', 'title': '合成周转贷'}
text = '### 合成周转贷\n\n- **额度**:最高500万元。\n- **条件**:须提供抵押。'
model = Mock(return_value={'text': text, 'source_ids': ['finance']})
result = synthesize_answers(model, '融资300万元有哪些产品', [record], [], 'specific_need')
model.assert_called_once()
evidence = model.call_args.args[1]['evidence'][0]
self.assertEqual(evidence['knowledge_type'], 'finance')
self.assertEqual(evidence['title'], record['title'])
self.assertEqual(result['text'], text)
self.assertEqual(result['citations'][0]['quote'], record['citations'][0]['quote'])
def test_short_nonfinance_answer_keeps_fast_path(self):
model = Mock(side_effect=AssertionError('unnecessary generation'))
record = entry('qa')
result = synthesize_answers(model, '提交方式', [record], [], 'policy_overview')
model.assert_not_called()
self.assertEqual(result['text'], record['answer'])
def test_large_input_is_fully_batched_with_wrapped_model(self):
from types import SimpleNamespace
from threading import Semaphore
from src.step4_api.runtime import limited
from src.step3_question_answer.knowledge_answers import input_size, REDUCE_PROMPT
seen = []
def call(prompt, data):
self.assertLessEqual(input_size(prompt, data) + 16384 + 904, 32768)
seen.append((prompt, data))
refs = list(dict.fromkeys(r for e in data['evidence']
for r in e.get('source_ids', [e.get('source_id')])))
return {'text': '保留地区限制,先申请后办理。', 'source_ids': refs}
call.config = SimpleNamespace(context_tokens=32768, margin=512)
call.MAX_OUTPUT_TOKENS = 16384
model = limited(call, Semaphore(1))
records = [entry(str(i), ('地区限制不可忽略。' * 650) + str(i)) for i in range(4)]
result = synthesize_answers(model, '如何办理', records, [], 'policy_overview')
self.assertEqual(result['status'], 'generated', result.get('diagnostics'))
self.assertEqual(set(result['source_ids']), {'0', '1', '2', '3'})
self.assertEqual(len(result['citations']), 4)
self.assertGreater(result['diagnostics']['model_calls'], 1)
self.assertEqual(result['diagnostics']['context_limit'], 32768)
fragments = [e for p, d in seen if p.startswith(REDUCE_PROMPT) for e in d['evidence']]
import json
for record in records:
pieces = [e['fragment'] for e in fragments if e.get('source_ids') == [record['source_id']]]
reconstructed = json.loads(''.join(pieces))
self.assertEqual(reconstructed['answer'], record['answer'])
self.assertEqual(reconstructed['citations'], record['citations'])
def test_batch_failure_does_not_publish_partial_success(self):
model = Mock(side_effect=RuntimeError('model_request_timeout'))
result = synthesize_answers(model, '如何办理', [entry('a', '材料。' * 10000)], [], 'policy_overview')
self.assertEqual(result['status'], 'fallback')
self.assertEqual(result['error'], 'model_request_timeout')
self.assertEqual(len(result['citations'][0]['quote']), 30000)
def test_sources_and_cards_share_one_synthesis_without_appending(self):
model = Mock(return_value={'text': '提交申请表后在线办理。', 'source_ids': ['a', 'b']})
result = synthesize_answers(model, '如何办理', [entry('a')], [entry('b')], 'policy_overview')
model.assert_called_once()
self.assertEqual(result['text'], '提交申请表后在线办理。')
self.assertEqual(len(model.call_args.args[1]['evidence']), 2)
self.assertEqual(len(result['citations']), 2)
def test_long_or_chunked_single_answer_is_synthesized(self):
for answer in ('提交申请。\n提交申请。', '材料说明。' * 100):
model = Mock(return_value={'text': '提交申请。', 'source_ids': ['a']})
result = synthesize_answers(model, '如何办理', [entry('a', answer)], [], 'policy_overview')
model.assert_called_once()
self.assertEqual(result['text'], '提交申请。')
def test_invalid_answers_are_still_rejected(self):
for value, code in (({'text': '编造内容', 'source_ids': ['unknown']}, 'summary_invalid_sources'),
({}, 'summary_empty_text'), (None, 'summary_invalid_schema')):
result = synthesize_answers(Mock(return_value=value), '如何办理',
[entry('a'), entry('b')], [], 'policy_overview')
self.assertEqual(result['error'], code)
self.assertEqual(result['diagnostics']['stage'], 'validation')
self.assertNotIn('提交申请表', result['text'])
self.assertEqual(len(result['citations']), 2)
def test_long_valid_answer_is_returned_without_length_retry_or_truncation(self):
for length in (601, 1046, 1801, 3000):
text = '字' * length
model = Mock(return_value={'text': text, 'source_ids': ['a', 'b']})
result = synthesize_answers(model, '如何办理', [entry('a'), entry('b')], [], 'policy_overview')
self.assertEqual(result['status'], 'generated')
self.assertEqual(result['text'], text)
self.assertNotIn('error', result)
self.assertNotIn('text_limit', result['diagnostics'])
self.assertEqual(result['diagnostics']['output_chars'], length)
model.assert_called_once()
self.assertEqual(len(result['citations']), 2)
def test_model_failures_keep_only_safe_codes_and_numeric_budget(self):
from types import SimpleNamespace
for code in ('analysis_context_limit', 'model_request_timeout', 'model_invalid_content_json',
'model_http_429', 'SECRET_EXCEPTION_BODY'):
model = Mock(side_effect=RuntimeError(code))
model.config = SimpleNamespace(context_tokens=32768, margin=100)
model.MAX_OUTPUT_TOKENS = 16384
result = synthesize_answers(model, '如何办理', [entry('a'), entry('b')], [], 'policy_overview')
self.assertEqual(result['error'], 'summary_generation_failed' if code.startswith('SECRET') else code)
d = result['diagnostics']
self.assertEqual(d['stage'], 'model')
self.assertEqual(d['reserved_total'], d['input_bytes'] + 16384 + 904)
self.assertEqual(d['context_limit'], 32768)
self.assertNotIn('SECRET', str(result))
def test_explicit_detail_can_exceed_default_without_truncation(self):
text = '资料' * 400
result = synthesize_answers(Mock(return_value={'text': text, 'source_ids': ['a']}),
'请详细介绍', [entry('a'), entry('b')], [], 'policy_overview')
self.assertEqual(result['text'], text)
self.assertEqual([c['source_id'] for c in result['citations']], ['a'])
def test_recommender_merges_multiple_sources_and_preserves_full_records(self):
records = [{**RECORD, 'id': key} for key in ('a', 'b')]
def model(prompt, data):
if prompt.startswith('将已核验'):
return {'text': '接受电子材料,无需复印件。', 'source_ids': ['a', 'b']}
return {'relevant': True, 'reason': '相关', 'answer': RECORD['data']['answer'],
'citations': [{'path': '/data/answer', 'quote': RECORD['data']['answer']}],
'conditions': [], 'card': None, 'match_basis': []}
mock = Mock(side_effect=model)
result = Recommender(mock).answer(context('该事项怎么交材料'), fixture(records))
self.assertEqual(result['summary']['text'], '接受电子材料,无需复印件。')
self.assertEqual(mock.call_count, 3)
self.assertEqual([s['full_record'] for s in result['sources']], records)
self.assertEqual(len(result['knowledge_answers']), 2)