import unittest from unittest.mock import Mock from src.step3_question_answer.finance_display import add_detail_links, answer_link_position from src.step3_question_answer.knowledge_answers import synthesize_answers, LINK_RULE, synthesis_prompts def entry(index, kind='service_guide'): return {'source_id': str(index), 'title': f'合成事项{index}', 'knowledge_type': kind, 'detail_url': f'https://example.invalid/product/{index}', 'service_link': {'url': f'https://example.invalid/service/{index}', 'title': f'合成事项{index}', 'label': '查看办事指南'}} class LinkPlacementTests(unittest.TestCase): def test_original_and_other_question_bind_model_anchors_without_extra_calls(self): for question in ('我想开奶茶店,有什么惠企政策', '经营许可延续要多久'): records = [entry(0), entry(1, 'finance')] for record in records: record.update(answer='合成支持说明。', citations=[{'path': '/data/body', 'quote': '合成支持说明。'}]) model = Mock(return_value={'text': '### 支持\n\n事项办理说明。\n\n产品条件说明。', 'source_ids': ['0', '1'], 'link_sections': [ {'source_id': '0', 'heading': '支持', 'after': '事项办理说明。'}, {'source_id': '1', 'heading': '支持', 'after': '产品条件说明。'}]}) result = synthesize_answers(model, question, records, [], 'policy_overview') model.assert_called_once() self.assertIn(LINK_RULE, model.call_args.args[0]) self.assertEqual(model.call_args.args[1]['question'], question) text = result['text'] self.assertLess(text.index('事项办理说明。'), text.index('/service/0')) self.assertLess(text.index('/service/0'), text.index('产品条件说明。')) self.assertLess(text.index('产品条件说明。'), text.index('/product/1')) prompt, _ = synthesis_prompts({'question': '普通咨询', 'evidence': [{}]}) self.assertNotIn(LINK_RULE, prompt) def test_channel_link_follows_explanation_and_precedes_materials(self): for channel, materials in (('**办理渠道**', '**申请材料**'), ('#### 办理渠道', '#### 申请材料')): body = f'### 合成事项0\n\n{channel}\n\n支持在线提交。\n\n{materials}\n\n提交申请表。' text, links = add_detail_links(body, [entry(0)], ['0']) pos = text.index('https://') self.assertGreater(pos, text.index('支持在线提交。')) self.assertLess(pos, text.index(materials)) self.assertEqual(len(links), 1) def test_finance_and_service_anchors_follow_each_answer_in_shared_chapter(self): for count in (1, 3, 7): entries = [entry(i, 'finance' if i % 2 else 'service_guide') for i in range(count)] paragraphs = [f'第{i}项支持说明,限合成地区的对应主体。' for i in range(count)] body = '### 支持与办理\n\n' + '\n\n'.join(paragraphs) + '\n\n### 其他说明\n\n其他。' mapping = [{'source_id': str(i), 'heading': '支持与办理', 'after': p} for i, p in enumerate(paragraphs)] text, links = add_detail_links(body, entries, [str(i) for i in range(count)], mapping) self.assertEqual(len(links), count) for i, link in enumerate(links): pos = text.index(link['url']) self.assertGreater(pos, text.index(paragraphs[i])) following = paragraphs[i + 1] if i + 1 < count else '### 其他说明' self.assertLess(pos, text.index(following)) again, again_links = add_detail_links(text, entries, [str(i) for i in range(count)], mapping) self.assertEqual((again, again_links), (text, links)) def test_anchor_without_headings_keeps_each_list_answer_and_qualifier_intact(self): body = '- 第一项支持。限合成主体。\n- 第二项说明。\n\n补充说明。' text, _ = add_detail_links(body, [entry(0)], ['0'], [ {'source_id': '0', 'heading': '', 'after': '第一项支持。'}]) self.assertGreater(text.index('https://'), text.index('限合成主体。')) self.assertLess(text.index('https://'), text.index('第二项说明。')) def test_short_answer_early_inline_and_duplicate_markers_move_after_answer(self): for kind, prefix in (('finance', 'finance'), ('service_guide', 'service')): marker = f'[[{prefix}_detail:0]]' for body in (marker + '\n\n合成事项需要三个工作日。', '合成事项' + marker + '需要三个工作日。', '合成事项需要三个工作日。' + marker + marker): text, links = add_detail_links(body, [entry(0, kind)], ['0'], []) self.assertEqual(len(links), 1) self.assertGreater(text.index('https://'), text.index('需要三个工作日。')) self.assertNotIn('[[', text) self.assertEqual(add_detail_links(text, [entry(0, kind)], ['0'], []), (text, links)) def test_empty_answer_and_ambiguous_multi_source_footer_do_not_gain_links(self): for body in ('### 合成事项0\n\n[[finance_detail:0]]', '[[finance_detail:0]]'): text, links = add_detail_links(body, [entry(0, 'finance')], ['0']) self.assertEqual(links, []) self.assertNotIn('https://', text) body = '甲段说明内容。\n\n乙段说明内容。\n\n[[finance_detail:0]]\n\n[[finance_detail:1]]' text, links = add_detail_links(body, [entry(0, 'finance'), entry(1, 'finance')], ['0', '1']) self.assertEqual(links, []) self.assertNotIn('https://', text) def test_wrong_footer_marker_cannot_override_a_known_chapter(self): body = ('### 合成事项0\n\n本项支持说明。\n\n### 无关事项\n\n其他说明。' '\n\n[[finance_detail:0]]') text, _ = add_detail_links(body, [entry(0, 'finance')], ['0']) self.assertGreater(text.index('https://'), text.index('本项支持说明。')) self.assertLess(text.index('https://'), text.index('### 无关事项')) def test_invalid_or_ambiguous_anchors_do_not_attach_to_unrelated_footer(self): body = '重复说明内容。\n\n重复说明内容。' for anchor in ('重复说明内容。', '不存在的说明内容。'): text, links = add_detail_links(body, [entry(0, 'finance')], ['0'], [ {'source_id': '0', 'heading': '', 'after': anchor}]) self.assertEqual(text, body) self.assertEqual(links, []) self.assertIsNone(answer_link_position('### 办理渠道\n\n实际办理说明。', '办理渠道')) def test_finance_nested_sections_are_complete_before_link(self): body = '### 合成事项0\n\n#### 额度\n\n上限说明。\n\n#### 条件\n\n限制说明。\n\n### 其他\n\n其他说明。' text, _ = add_detail_links(body, [entry(0, 'finance')], ['0']) self.assertGreater(text.index('https://'), text.index('限制说明。')) self.assertLess(text.index('https://'), text.index('### 其他'))