test_service_links.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180
  1. import unittest
  2. from unittest.mock import Mock
  3. from src.step3_question_answer.service_links import record_service_link
  4. from src.step3_question_answer.finance_display import add_detail_links
  5. from src.step3_question_answer.knowledge_answers import synthesize_answers, number_sections
  6. from src.step3_question_answer.report import markdown
  7. class ServiceLinksTests(unittest.TestCase):
  8. def test_same_entry_url_deduplicates_sources_and_existing_markdown(self):
  9. entries = [self.entry(), self.entry('b')]
  10. entries[1]['service_link'] = entries[0]['service_link']
  11. body = ('### 登记\n\n**办理渠道**\n\n- 网上办理:[入口](https://example.invalid/a)\n'
  12. '\n可通过[登记平台](https://example.invalid/a)提交申请。[[service_detail:0]]\n[[service_detail:1]]')
  13. mapping = [{'source_id': e['source_id'], 'heading': '登记'} for e in entries]
  14. text, links = add_detail_links(body, entries, ['a', 'b'], mapping)
  15. self.assertEqual(text.count('https://example.invalid/a'), 1)
  16. self.assertIn('可通过登记平台提交申请。', text)
  17. self.assertEqual(len(links), 1)
  18. again, again_links = add_detail_links(text, entries, ['a', 'b'], mapping)
  19. self.assertEqual(again, text)
  20. self.assertEqual(again_links, links)
  21. def test_shared_portal_keeps_each_section_but_metadata_has_one_url(self):
  22. entries = [self.entry(), self.entry('b')]
  23. entries[1]['service_link'] = entries[0]['service_link']
  24. text, links = add_detail_links('### 甲事项\n\n内容。\n\n### 乙事项\n\n内容。', entries, ['a', 'b'], [
  25. {'source_id': 'a', 'heading': '甲事项'}, {'source_id': 'b', 'heading': '乙事项'}])
  26. self.assertEqual(text.count('https://example.invalid/a'), 2)
  27. self.assertEqual(len(links), 1)
  28. self.assertLess(text.index('https://example.invalid/a'), text.index('### 乙事项'))
  29. self.assertGreater(text.rindex('https://example.invalid/a'), text.index('### 乙事项'))
  30. def test_raw_url_is_rebound_and_different_destinations_are_kept(self):
  31. text, links = add_detail_links('### 同一事项\n\n合成办理说明。\n\nhttps://example.invalid/a\n[[service_detail:0]]\n[[service_detail:1]]',
  32. [self.entry(), self.entry('b')], ['a', 'b'], [
  33. {'source_id': 'a', 'heading': '同一事项'}, {'source_id': 'b', 'heading': '同一事项'}])
  34. self.assertEqual(text.count('https://example.invalid/a'), 1)
  35. self.assertEqual(text.count('https://example.invalid/b'), 1)
  36. self.assertEqual(len(links), 2)
  37. def test_explicit_section_mapping_places_different_source_names_and_numbers_sections(self):
  38. entries = [self.entry(), self.entry('b')]
  39. model = Mock(return_value={
  40. 'text': '### 登记手续\n\n**办理渠道**\n\n网上办理。\n\n### 其他许可(如涉及)\n\n**办理渠道**\n\n按情况办理。\n\n### 需要您确认的信息\n\n是否涉及。',
  41. 'source_ids': ['a', 'b'], 'link_sections': [
  42. {'source_id': 'a', 'heading': '登记手续'}, {'source_id': 'b', 'heading': '其他许可(如涉及)'}]})
  43. result = synthesize_answers(model, '如何办理', entries, [], 'specific_need')
  44. text = result['text']
  45. self.assertIn('### 一、登记手续', text)
  46. self.assertIn('### 二、其他许可(如涉及)', text)
  47. self.assertLess(text.index('https://example.invalid/a'), text.index('### 二、'))
  48. self.assertGreater(text.index('https://example.invalid/b'), text.index('### 二、'))
  49. self.assertLess(text.index('https://example.invalid/b'), text.index('### 需要您确认'))
  50. self.assertEqual(number_sections(text), text)
  51. def test_partial_section_mapping_keeps_other_explicitly_used_guides(self):
  52. entries = [self.entry(chr(ord('a') + i)) for i in range(5)]
  53. for entry in entries:
  54. entry['title'] = '合成许可'
  55. entry['service_link']['title'] = f'合成许可{entry["source_id"]}情形'
  56. body = '\n\n'.join(f'### 合成许可{entry["source_id"]}情形\n\n办理说明。'
  57. for entry in entries)
  58. mapped = [{'source_id': entry['source_id'], 'heading': f'合成许可{entry["source_id"]}情形'}
  59. for entry in entries[:3]]
  60. text, links = add_detail_links(body, entries, [entry['source_id'] for entry in entries], mapped)
  61. self.assertEqual(len(links), 5)
  62. for entry in entries:
  63. self.assertEqual(text.count(f'https://example.invalid/{entry["source_id"]}'), 1)
  64. # A fifth source that only appeared in the inputs cannot gain an entry.
  65. text, links = add_detail_links(body, entries, [entry['source_id'] for entry in entries[:3]], mapped)
  66. self.assertEqual(len(links), 3)
  67. def test_section_mapping_cannot_enable_uncited_source_or_missing_heading(self):
  68. body = '### 甲事项\n\n内容。\n\n### 乙事项\n\n内容。'
  69. text, links = add_detail_links(body, [self.entry(), self.entry('b')], ['a'], [
  70. {'source_id': 'b', 'heading': '甲事项'}, {'source_id': 'a', 'heading': '不存在'}])
  71. self.assertEqual(len(links), 0)
  72. self.assertNotIn('https://example.invalid/b', text)
  73. self.assertNotIn('不存在', text)
  74. def test_conditional_citation_without_section_does_not_add_footer_link(self):
  75. body = '### 登记手续\n\n内容。\n\n特殊活动另有要求。[[service_detail:1]]'
  76. text, links = add_detail_links(body, [self.entry(), self.entry('b')], ['a', 'b'], [
  77. {'source_id': 'a', 'heading': '登记手续'}])
  78. self.assertEqual([link['source_id'] for link in links], ['a'])
  79. self.assertIn('https://example.invalid/a', text)
  80. self.assertNotIn('https://example.invalid/b', text)
  81. self.assertIn('特殊活动另有要求', text)
  82. self.assertNotIn('[[service_detail:', text)
  83. def test_adjacent_service_keeps_citation_but_cannot_enable_entry(self):
  84. entry = {**self.entry(), 'relevance_level': 'adjacent'}
  85. model = Mock(return_value={'text': '### 主线事项\n\n另有条件要求。[[service_detail:0]]',
  86. 'source_ids': ['a'], 'link_sections': [{'source_id': 'a', 'heading': '主线事项'}]})
  87. result = synthesize_answers(model, '如何办理', [entry], [], 'specific_need')
  88. self.assertEqual(result['source_ids'], ['a'])
  89. self.assertTrue(result['citations'])
  90. self.assertEqual(result['detail_links'], [])
  91. self.assertNotIn('https://', result['text'])
  92. self.assertNotIn('service_marker', model.call_args.args[1]['evidence'][0])
  93. def test_online_entry_qa_requires_validated_answer_quote(self):
  94. record = {'kind': 'qa', 'data': {'question': '网上办理设立登记,入口在哪里?',
  95. 'answer': '请使用网上入口(https://example.invalid/register)。'}}
  96. citation = {'path': '/data/answer', 'quote': record['data']['answer']}
  97. self.assertIsNone(record_service_link(record))
  98. self.assertIsNone(record_service_link(record, [{'path': '/data/question', 'quote': citation['quote']}]))
  99. self.assertIsNone(record_service_link(record, [{'path': '/data/answer', 'quote': 'https://evil.invalid'}]))
  100. link = record_service_link(record, [citation])
  101. self.assertEqual(link['url'], 'https://example.invalid/register')
  102. entry = {**self.entry(), 'knowledge_type': 'qa', 'title': record['data']['question'], 'service_link': link}
  103. model = Mock(return_value={'text': '### 办理渠道\n\n支持网办。[[service_detail:0]]', 'source_ids': ['a']})
  104. result = synthesize_answers(model, '怎样登记', [entry], [], 'specific_need')
  105. self.assertIn('https://example.invalid/register', result['text'])
  106. self.assertEqual(len(result['detail_links']), 1)
  107. def entry(self, source='a'):
  108. return {'source_id': source, 'title': '合成备案' + source, 'knowledge_type': 'service_guide',
  109. 'answer': '支持网上办理。', 'citations': [{'path': '/data/body', 'quote': '支持网上办理。'}],
  110. 'service_link': record_service_link({'data': {'structured_content': {'指南链接': 'https://example.invalid/' + source}}})}
  111. def test_explicit_online_link_precedes_guide_and_pdf_or_homepage_is_not_entry(self):
  112. record = {'data': {'structured_content': {'指南链接': 'https://example.invalid/guide',
  113. '网上办理链接': 'https://example.invalid/apply'}}}
  114. link = record_service_link(record)
  115. self.assertEqual(link['label'], '网上办理入口')
  116. self.assertEqual(link['path'], '/data/structured_content/网上办理链接')
  117. self.assertEqual(link['quote'], 'https://example.invalid/apply')
  118. self.assertIsNone(record_service_link({'source': {'url': 'https://example.invalid'},
  119. 'data': {'structured_content': {'指南PDF链接': 'https://example.invalid/a.pdf'}}}))
  120. for value in ('javascript:alert(1)', '//example.invalid', 'https://u:p@example.invalid', ''):
  121. self.assertIsNone(record_service_link({'data': {'指南链接': value}}))
  122. def test_synthesis_places_source_bound_link_in_channel_and_enables_renderer(self):
  123. model = Mock(return_value={'text': '### 办理渠道\n\n- 网上办理:支持。[[service_detail:0]]\n\n### 申请材料\n\n- 合成材料。', 'source_ids': ['a']})
  124. result = synthesize_answers(model, '怎么办理', [self.entry()], [], 'specific_need')
  125. self.assertLess(result['text'].index('[合成备案a(办事指南)]'), result['text'].index('### 二、申请材料'))
  126. evidence = model.call_args.args[1]['evidence'][0]
  127. self.assertEqual(evidence['service_link_label'], '查看办事指南')
  128. self.assertNotIn('https://', str(evidence))
  129. self.assertIn('href="https://example.invalid/a"', markdown(result['text'], result['detail_links']))
  130. def test_uncited_missing_and_unknown_markers_never_produce_links(self):
  131. entries = [self.entry(), self.entry('b')]
  132. entries[1]['service_link'] = None
  133. text, links = add_detail_links('说明[[service_detail:0]][[service_detail:1]][[service_detail:99]]', entries, ['b'])
  134. self.assertEqual(text, '说明')
  135. self.assertEqual(links, [])
  136. def test_unlocated_sources_do_not_create_unrelated_footer_links(self):
  137. text, links = add_detail_links('办理说明', [self.entry(), self.entry('b')], ['a', 'b'])
  138. self.assertEqual(text, '办理说明')
  139. self.assertEqual(links, [])
  140. def test_missing_or_footer_markers_move_into_abbreviated_chapters(self):
  141. entries = [self.entry(), self.entry('b')]
  142. entries[0]['title'] = '上海市合成登记营业执照办理(测试区)'
  143. entries[1]['title'] = '合成回收经营者备案(测试区)'
  144. body = ('### 一、营业执照办理\n\n**办理渠道**\n\n- 支持网办。\n\n**申请材料**\n\n- 材料甲。\n\n'
  145. '### 二、合成回收经营者备案\n\n**办理渠道**\n\n- 支持网办。\n\n**申请材料**\n\n- 材料乙。')
  146. for suffix in ('', '\n\n[[service_detail:0]]\n[[service_detail:1]]'):
  147. text, links = add_detail_links(body + suffix, entries, ['a', 'b'])
  148. self.assertEqual(len(links), 2)
  149. self.assertGreater(text.index('https://example.invalid/a'), text.index('支持网办。'))
  150. self.assertLess(text.index('https://example.invalid/a'), text.index('材料甲'))
  151. self.assertGreater(text.index('https://example.invalid/b'), text.index('### 二、'))
  152. self.assertLess(text.index('https://example.invalid/b'), text.index('材料乙'))
  153. self.assertNotIn('[[service_detail:', text)
  154. def test_ambiguous_titles_do_not_guess_a_chapter(self):
  155. body = '### 合成备案a(甲区)\n\n内容甲。\n\n### 合成备案a(乙区)\n\n内容乙。'
  156. text, _ = add_detail_links(body, [self.entry()], ['a'])
  157. self.assertNotIn('https://example.invalid/a', text)
  158. def test_single_link_does_not_borrow_another_matters_channel(self):
  159. body = '### 另一事项\n\n#### 办理渠道\n\n窗口甲。\n\n### 未知事项\n\n内容乙。'
  160. text, _ = add_detail_links(body, [self.entry()], ['a'])
  161. self.assertNotIn('https://example.invalid/a', text)