test_exact_qa_fast_path.py 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114
  1. import copy
  2. import threading
  3. import unittest
  4. from unittest.mock import Mock
  5. from src.step3_question_answer.graph import build_graph
  6. from src.step3_question_answer.recommendation import Recommender
  7. from src.step3_question_answer.timing import Timings, bind
  8. from src.step3_question_answer.tests.test_knowledge_answers import RECORD, QUESTION, ANSWER, fixture, context
  9. from src.step3_question_answer.tests.test_recommendation import candidate
  10. from src.step4_api.runtime import GraphRuntime
  11. class ExactQaFastPathTests(unittest.TestCase):
  12. def mixed(self, records=None, complete=True):
  13. data = fixture(records, complete)
  14. data['candidates'].insert(0, candidate())
  15. return data
  16. def test_exact_answer_skips_all_other_candidates_and_rerank(self):
  17. model, reranker = Mock(), Mock()
  18. engine = Recommender(model, reranker=reranker)
  19. engine._analyze_candidates = Mock(side_effect=AssertionError('must not start analysis'))
  20. original = self.mixed()
  21. before = copy.deepcopy(original)
  22. timing = Timings()
  23. with bind(timing):
  24. result = engine.answer(context(), original)
  25. model.assert_not_called()
  26. reranker.select.assert_not_called()
  27. engine._analyze_candidates.assert_not_called()
  28. self.assertEqual(original, before)
  29. self.assertEqual(result['summary']['text'], '> ' + ANSWER)
  30. self.assertEqual(result['summary']['source_ids'], [RECORD['id']])
  31. self.assertEqual(result['sources'][0]['full_record'], RECORD)
  32. self.assertEqual(len(result['sources']), 1)
  33. self.assertEqual(len(result['candidate_diagnostics']), 1)
  34. self.assertEqual(result['items'], [])
  35. self.assertEqual(result['next_actions'], [])
  36. self.assertEqual(result['follow_up_questions'], [])
  37. self.assertEqual(result['retrieval']['fast_path'], {
  38. 'kind': 'exact_qa', 'matched_count': 1, 'skipped_candidate_count': 1})
  39. self.assertTrue(result['complete'])
  40. self.assertEqual(set(timing.snapshot()['spans']), {'exact_qa'})
  41. def test_all_conflicting_answers_preserved_without_model(self):
  42. second = copy.deepcopy(RECORD)
  43. second['id'] = 'k_' + 'c' * 24
  44. second['data']['answer'] = '需要提交复印件。'
  45. model = Mock()
  46. result = Recommender(model).answer(context(), self.mixed([RECORD, second]))
  47. model.assert_not_called()
  48. self.assertEqual(result['summary']['status'], 'qa_conflict')
  49. self.assertIn('不同答案', result['summary']['text'])
  50. self.assertIn(ANSWER, result['summary']['text'])
  51. self.assertIn(second['data']['answer'], result['summary']['text'])
  52. self.assertEqual(len(result['summary']['source_ids']), 2)
  53. self.assertEqual(len(result['sources']), 2)
  54. def test_partial_retrieval_stays_partial(self):
  55. model = Mock()
  56. result = Recommender(model).answer(context(), self.mixed(complete=False))
  57. model.assert_not_called()
  58. self.assertFalse(result['complete'])
  59. self.assertFalse(result['retrieval_analysis_complete'])
  60. self.assertTrue(result['summary']['notices'])
  61. self.assertEqual(result['retrieval']['failed_databases'], ['qa'])
  62. self.assertIn(ANSWER, result['summary']['text'])
  63. def test_nonexact_blank_and_expired_answers_keep_normal_flow(self):
  64. variants = []
  65. for field, value in [('question', QUESTION + '和原件'), ('answer', ''),
  66. ('answer', '本实施意见自2020年1月1日起执行,至2020年12月31日止。')]:
  67. record = copy.deepcopy(RECORD)
  68. record['data'][field] = value
  69. variants.append(record)
  70. for record in variants:
  71. with self.subTest(record=record['data']):
  72. model = Mock(return_value={'relevant': False})
  73. reranker = Mock()
  74. reranker.select.side_effect = lambda handoff, data: data
  75. result = Recommender(model, reranker=reranker).answer(context(), self.mixed([record]))
  76. reranker.select.assert_called_once()
  77. self.assertTrue(model.called)
  78. self.assertNotIn('fast_path', result['retrieval'])
  79. self.assertNotEqual(result['summary']['status'], 'exact_qa')
  80. def test_attachment_failure_is_not_hidden_by_shortcut(self):
  81. model = Mock(return_value={'relevant': False})
  82. reranker = Mock()
  83. reranker.select.side_effect = lambda handoff, data: data
  84. result = Recommender(model, reranker=reranker).answer({**context(), 'attachments': [
  85. {'id': 'attachment_test', 'title': '合成附件', 'status': 'failed', 'error': 'download_failed'}]}, self.mixed())
  86. reranker.select.assert_called_once()
  87. self.assertNotIn('fast_path', result['retrieval'])
  88. self.assertFalse(result['complete'])
  89. self.assertIn('未能读取', result['summary']['text'])
  90. def test_api_graph_delivers_literal_answer_and_sources_without_generation(self):
  91. retriever, model, reranker = Mock(), Mock(), Mock()
  92. retriever.search.return_value = self.mixed()
  93. engine = Recommender(model, reranker=reranker)
  94. graph = build_graph(None, lambda _: {'conversation_kind': 'policy_overview', 'needs_company': False},
  95. retriever=retriever, recommender=engine)
  96. events = []
  97. GraphRuntime(graph).run('synthetic-qa', QUESTION, lambda e, d: events.append((e, d)), threading.Event())
  98. model.assert_not_called()
  99. reranker.select.assert_not_called()
  100. self.assertEqual([d['text'] for e, d in events if e == 'summary'], ['> ' + ANSWER])
  101. self.assertEqual([d['id'] for e, d in events if e == 'source'], [RECORD['id']])
  102. self.assertFalse(any(e in ('item', 'answer_delta') for e, _ in events))
  103. self.assertEqual(events[-1], ('done', {'status': 'completed'}))
  104. result = next(d for e, d in events if e == 'result')
  105. self.assertEqual(result['recommendation']['summary']['citations'][0]['quote'], ANSWER)