test_value_clarification.py 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164
  1. """Offline regression for empty-result suggestions and explicit selection."""
  2. from copy import deepcopy
  3. from types import SimpleNamespace
  4. import unittest
  5. from unittest.mock import patch
  6. from langgraph.checkpoint.memory import InMemorySaver
  7. from langgraph.graph import StateGraph, START, END
  8. from langgraph.types import Command
  9. from step3_qa_agent.agent import production as p
  10. from step3_qa_agent.agent.state import AgentState
  11. from step3_qa_agent.agent.value_clarification import empty_result, find_candidates, selected_option, selected_options
  12. SCHEMA = {'nodes': [{'id': '人员证书', 'attributes': ['证书名称', '级别']}], 'relations': []}
  13. SPEC = {'nodes': [{'alias': 'n', 'type': '人员证书'}], 'relations': [],
  14. 'filters': [{'alias': 'n', 'field': '证书名称', 'op': 'eq', 'value': '电工'},
  15. {'alias': 'n', 'field': '级别', 'op': 'eq', 'value': '中级'}],
  16. 'select': [], 'aggregates': [{'alias': 'n', 'op': 'count', 'as': '人数'}]}
  17. CONTEXT = {'schema': SCHEMA, 'build_id': 'v1'}
  18. PLAN = {'steps': [{'step_id': 's1', 'tool': '图谱查询', 'params': SPEC}]}
  19. class Driver:
  20. def __init__(self):
  21. self.calls = []
  22. def execute_query(self, query, **params):
  23. self.calls.append((query, params))
  24. if 'AS `候选值`' in query:
  25. rows = [{'候选值': ['水电工', '维修电工', '保安'], '记录数': 2}] if 'n.`证书名称` AS' in query else [{'候选值': '中级', '记录数': 1}]
  26. else:
  27. value = params.get('v0')
  28. matched = bool(set(value if isinstance(value, list) else [value]) & {'水电工', '维修电工'})
  29. rows = [{'人数': 2 if matched else 0}]
  30. return SimpleNamespace(records=rows)
  31. class ClarificationTests(unittest.TestCase):
  32. def state(self):
  33. return {'question': '有多少人是中级电工', 'plan': deepcopy(PLAN),
  34. 'qa_context': deepcopy(CONTEXT)}
  35. def test_zero_count_not_merely_empty_rows(self):
  36. self.assertTrue(empty_result(SPEC, [{'人数': 0}]))
  37. self.assertTrue(empty_result(SPEC, []))
  38. self.assertFalse(empty_result(SPEC, [{'人数': 2}]))
  39. self.assertTrue(empty_result({'aggregates': [{'op': 'count_distinct', 'as': '人数'}]}, [{'人数': 0}]))
  40. self.assertFalse(empty_result({'aggregates': [{'op': 'sum', 'as': '金额'}]}, [{'金额': 0}]))
  41. def test_real_multivalue_candidates_preserve_other_filters(self):
  42. driver = Driver()
  43. result = find_candidates(PLAN, {'s1': {'rows': [{'人数': 0}]}}, CONTEXT, driver)
  44. self.assertEqual({c['value'] for c in result['options']}, {'水电工', '维修电工'})
  45. query, args = driver.calls[0]
  46. self.assertIn('中级', args.values())
  47. self.assertNotIn('电工', args.values())
  48. self.assertEqual(args['build'], 'v1')
  49. self.assertEqual(SPEC['filters'][0]['value'], '电工')
  50. def test_positive_results_do_not_probe(self):
  51. driver = Driver()
  52. self.assertFalse(find_candidates(PLAN, {'s1': {'rows': [{'人数': 2}]}}, CONTEXT, driver)['options'])
  53. self.assertFalse(driver.calls)
  54. def test_confirmation_is_not_selection(self):
  55. options = [{'label': 'name', 'value': '水电工'}]
  56. self.assertIsNone(selected_option('确认', options))
  57. for reply in ('1', '选择1', '水电工', 'name'):
  58. self.assertEqual(selected_option(reply, options), options[0])
  59. self.assertIsNone(selected_option('2', options))
  60. def test_multiple_numbers_values_and_json_are_supported(self):
  61. options = [{'label': '1. 水电工', 'value': '水电工'},
  62. {'label': '2. 维修电工', 'value': '维修电工'}]
  63. for reply in ('1,2', '选择1,选2', '水电工、维修电工',
  64. '["1. 水电工", "2. 维修电工"]'):
  65. self.assertEqual([item['value'] for item in selected_options(reply, options)],
  66. ['水电工', '维修电工'])
  67. self.assertEqual([item['value'] for item in selected_options('1,1', options)], ['水电工'])
  68. self.assertEqual(selected_options('确认', options), [])
  69. def graph(self):
  70. graph = StateGraph(AgentState)
  71. graph.add_node('run', p.execute)
  72. graph.add_node('clarify', p.clarify)
  73. graph.add_edge(START, 'run')
  74. graph.add_edge('run', 'clarify')
  75. graph.add_conditional_edges('clarify', lambda s: 'run' if s['plan_confirm'] else END)
  76. return graph.compile(checkpointer=InMemorySaver())
  77. def test_interrupt_resume_requeries_without_changing_level(self):
  78. driver = Driver()
  79. with patch.object(p, 'get_driver', return_value=driver), patch.object(p, 'release_snapshot', return_value={'data_version': 'v1'}):
  80. graph = self.graph(); config = {'configurable': {'thread_id': 'choice'}}
  81. result = graph.invoke(self.state(), config)
  82. self.assertEqual(result['__interrupt__'][0].value['type'], 'clarify_value')
  83. self.assertEqual(result['subgraph']['results']['s1']['rows'], [{'人数': 0}])
  84. result = graph.invoke(Command(resume='确认'), config)
  85. self.assertIn('__interrupt__', result)
  86. result = graph.invoke(Command(resume='1'), config)
  87. self.assertNotIn('__interrupt__', result)
  88. self.assertEqual(result['subgraph']['results']['s1']['rows'], [{'人数': 2}])
  89. self.assertEqual(result['plan']['steps'][0]['params']['filters'][1]['value'], '中级')
  90. def test_multiple_choices_become_in_filter(self):
  91. driver = Driver()
  92. with patch.object(p, 'get_driver', return_value=driver), patch.object(p, 'release_snapshot', return_value={'data_version': 'v1'}):
  93. graph = self.graph(); config = {'configurable': {'thread_id': 'multi-choice'}}
  94. first = graph.invoke(self.state(), config)
  95. self.assertEqual(len(first['subgraph']['suggestions']['options']), 2)
  96. result = graph.invoke(Command(resume='1,2'), config)
  97. condition = result['plan']['steps'][0]['params']['filters'][0]
  98. self.assertEqual(condition['op'], 'in')
  99. self.assertEqual(set(condition['value']), {'水电工', '维修电工'})
  100. self.assertEqual(result['subgraph']['results']['s1']['rows'], [{'人数': 2}])
  101. def test_decline_keeps_original_query(self):
  102. with patch.object(p, 'get_driver', return_value=Driver()), patch.object(p, 'release_snapshot', return_value={'data_version': 'v1'}):
  103. graph = self.graph(); config = {'configurable': {'thread_id': 'decline'}}
  104. graph.invoke(self.state(), config)
  105. result = graph.invoke(Command(resume='都不是'), config)
  106. self.assertEqual(result['plan'], PLAN)
  107. self.assertFalse(result['plan_confirm'])
  108. def test_version_change_on_resume_rejects_stale_choice(self):
  109. with patch.object(p, 'get_driver', return_value=Driver()), patch.object(p, 'release_snapshot', return_value={'data_version': 'v1'}) as release:
  110. graph = self.graph(); config = {'configurable': {'thread_id': 'version'}}
  111. graph.invoke(self.state(), config)
  112. release.return_value = {'data_version': 'v2'}
  113. result = graph.invoke(Command(resume='1'), config)
  114. self.assertIn('数据已更新', result['subgraph']['error'])
  115. def test_probe_failure_is_not_zero_data_evidence(self):
  116. with patch.object(p, 'get_driver', return_value=Driver()), patch.object(p, 'release_snapshot', return_value={'data_version': 'v1'}), patch.object(p, 'find_candidates', side_effect=RuntimeError('secret')):
  117. result = p.execute(self.state())
  118. self.assertIn('暂不可用', result['subgraph']['suggestions']['error'])
  119. self.assertNotIn('secret', str(result))
  120. def test_no_candidates_and_bounded_results(self):
  121. driver = SimpleNamespace(execute_query=lambda *a, **k: SimpleNamespace(records=[{'候选值': 'xyz', '记录数': 1}] * 201))
  122. result = find_candidates(PLAN, {'s1': {'rows': []}}, CONTEXT, driver)
  123. self.assertFalse(result['options'])
  124. self.assertTrue(result['limited'])
  125. class ApiClarificationTests(unittest.IsolatedAsyncioTestCase):
  126. async def test_auto_confirm_stops_for_value_choice(self):
  127. from step4_web import api
  128. class FakeGraph:
  129. calls = 0
  130. async def astream(self, *args, **kwargs):
  131. self.calls += 1
  132. if self.calls > 1:
  133. raise AssertionError('自动确认不能替用户选择')
  134. yield {'__interrupt__': [SimpleNamespace(value={
  135. 'type': 'clarify_value', 'message': '请选择实际值',
  136. 'options': ['水电工', '都不是']})]}
  137. fake = FakeGraph()
  138. with patch.object(api, '_get_graph', return_value=fake):
  139. events = [event async for event in api._astream_run('test', '中级电工', True)]
  140. self.assertEqual(fake.calls, 1)
  141. self.assertEqual(events[-1]['type'], 'confirm')
  142. self.assertEqual(events[-1]['options'], ['水电工', '都不是'])
  143. self.assertEqual(events[-1]['confirm_type'], 'clarify_value')
  144. if __name__ == '__main__':
  145. unittest.main()