test_simple_plan.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. """Offline correctness and model-call regression for the simple planning path."""
  2. from copy import deepcopy
  3. from types import SimpleNamespace
  4. import os
  5. import unittest
  6. from unittest.mock import patch
  7. from langgraph.checkpoint.memory import InMemorySaver
  8. from langgraph.types import Command
  9. from step3_qa_agent.agent import nodes, production, llm
  10. from step3_qa_agent.agent.graph import build_agent_graph
  11. from step3_qa_agent.agent.simple_plan import build_simple_plan
  12. from step3_qa_agent.agent.timing import measure_llm_calls, llm_calls
  13. from step3_qa_agent.retrieval.production_query import compile_query
  14. SCHEMA = {'meta': {'schema_version': 2}, 'nodes': [
  15. {'id': '人员信息', 'active': True, 'attributes': ['工号', '姓名', '岗位名称', '部门'],
  16. 'identity_fields': ['工号'], 'multivalue_fields': {'岗位名称': 1}},
  17. {'id': '项目信息', 'attributes': ['项目编号', '项目名称']}],
  18. 'relations': [{'id': 'works', 'source': '人员信息', 'target': '项目信息', 'type': '服务于'}]}
  19. CONTEXT = {'schema': SCHEMA, 'build_id': 'v1', 'catalog': [], 'assessment_text': '',
  20. 'selected_datasets': ['人员信息'], 'loaded_datasets': [], 'selection_source': 'understanding',
  21. 'issues': []}
  22. def state(question='共有多少名员工?'):
  23. return {'question': question, 'category': '图谱检索', 'qa_context': deepcopy(CONTEXT),
  24. 'slots': {'anchors': [], 'filters': {}, 'aggregation': '汇总'},
  25. 'simple_query_needs': {'datasets': ['人员信息'], 'relations': []},
  26. 'simple_query': {'kind': 'count', 'dataset': '人员信息', 'filters': [],
  27. 'fields': [], 'limit': 50, 'complete': True}}
  28. class SimplePlanTests(unittest.TestCase):
  29. def setUp(self):
  30. self.env = patch.dict(os.environ, {'QA_FAST_PLAN_ENABLED': 'true'})
  31. self.env.start()
  32. self.addCleanup(self.env.stop)
  33. def test_count_is_distinct_node_query_not_schema_count(self):
  34. s = state()
  35. plan, _ = build_simple_plan(s)
  36. spec = plan['steps'][0]['params']
  37. compiled = compile_query(spec, SCHEMA, 'v1')
  38. self.assertIn('count(DISTINCT n._kg_id)', compiled.query)
  39. self.assertEqual(compiled.params['build'], 'v1')
  40. with patch.object(production, 'chat_json') as model:
  41. result = production.plan(s)
  42. model.assert_not_called()
  43. self.assertEqual(result['trace'][-1]['mode'], 'simple')
  44. self.assertEqual(result['trace'][-1]['llm_calls'], [])
  45. def test_property_query_keeps_anchor_and_provenance(self):
  46. s = state('张三的岗位名称是什么?')
  47. s['slots'] = {'anchors': [{'raw': '张三'}]}
  48. s['simple_query'].update(kind='list', fields=['姓名', '岗位名称'],
  49. filters=[{'field': '姓名', 'op': 'eq', 'value': '张三'}])
  50. plan, _ = build_simple_plan(s)
  51. compiled = compile_query(plan['steps'][0]['params'], SCHEMA, 'v1')
  52. self.assertEqual(compiled.params['v0'], '张三')
  53. self.assertIn('_sources', compiled.query)
  54. self.assertIn('any(x IN CASE', compiled.query)
  55. def test_list_preserves_all_and_filters_and_limit(self):
  56. s = state('列出运营部岗位名称包含保安的员工姓名')
  57. s['slots'] = {'filters': {'部门': '运营部', '岗位': '保安'}}
  58. s['simple_query'].update(kind='list', fields=['工号', '姓名'], limit=20,
  59. filters=[{'field': '部门', 'op': 'eq', 'value': '运营部'},
  60. {'field': '岗位名称', 'op': 'contains', 'value': '保安'}])
  61. plan, _ = build_simple_plan(s)
  62. compiled = compile_query(plan['steps'][0]['params'], SCHEMA, 'v1')
  63. self.assertEqual((compiled.params['v0'], compiled.params['v1']), ('运营部', '保安'))
  64. self.assertEqual(compiled.params['result_limit'], 21)
  65. self.assertIn(' AND ', compiled.query)
  66. def test_complex_questions_reject_even_when_model_claims_complete(self):
  67. for question in ['他们有多少人', '按部门统计人数', '每个项目多少人', '保安或电工有多少',
  68. '没有证书的员工', '去年入职人数', '工资大于5000人数',
  69. '前10名员工', '删除所有员工', '比较项目人数']:
  70. with self.subTest(question=question):
  71. self.assertIsNone(build_simple_plan(state(question))[0])
  72. def test_relation_and_invalid_selection_never_disappear(self):
  73. for needs in [{'datasets': ['人员信息'], 'relations': ['unknown']},
  74. {'datasets': ['人员信息', '项目信息'], 'relations': ['works']},
  75. {'datasets': ['人员信息', 'unknown'], 'relations': []}]:
  76. s = state()
  77. s['simple_query_needs'] = needs
  78. self.assertIsNone(build_simple_plan(s)[0])
  79. def test_dropped_anchor_or_filter_is_rejected(self):
  80. for slots in [{'anchors': [{'raw': '张三'}]}, {'filters': {'岗位': '保安'}},
  81. {'time_range': {'start': '2026-01'}}, {'filters': {'岗位': ['保安', '电工']}}]:
  82. s = state('张三是保安吗')
  83. s['slots'] = slots
  84. self.assertIsNone(build_simple_plan(s)[0])
  85. def test_unknown_field_and_nonliteral_value_fall_back(self):
  86. for condition in [{'field': '不存在', 'op': 'eq', 'value': '员工'},
  87. {'field': '岗位名称', 'op': 'eq', 'value': '保安'},
  88. {'field': '岗位名称', 'op': 'ne', 'value': '员工'}]:
  89. s = state()
  90. s['simple_query']['filters'] = [condition]
  91. self.assertIsNone(build_simple_plan(s)[0])
  92. def test_malformed_candidates_fail_closed(self):
  93. for candidate in [None, [], {}, {'complete': True},
  94. {**state()['simple_query'], 'fields': None},
  95. {**state()['simple_query'], 'fields': [['姓名']]},
  96. {**state()['simple_query'], 'limit': True},
  97. {**state()['simple_query'], 'limit': 201},
  98. {**state()['simple_query'], 'filters': [None]},
  99. {**state()['simple_query'], 'extra': 'ignored?'},
  100. {**state()['simple_query'], 'kind': 'sum'},
  101. {**state()['simple_query'], 'complete': 'true'}]:
  102. s = state()
  103. s['simple_query'] = candidate
  104. self.assertIsNone(build_simple_plan(s)[0])
  105. def test_feedback_always_uses_full_planner(self):
  106. original, _ = build_simple_plan(state())
  107. for key in ['plan_feedback', 'run_feedback', 'slots_feedback', 'feedback']:
  108. s = state()
  109. s[key] = '改成只查保安'
  110. with patch.object(production, 'chat_json', return_value=original) as model:
  111. result = production.plan(s)
  112. model.assert_called_once()
  113. self.assertEqual(result['trace'][-1]['mode'], 'llm')
  114. self.assertEqual(result['trace'][-1]['reason'], 'feedback_requires_replanning')
  115. def test_disabled_and_legacy_fall_back(self):
  116. original, _ = build_simple_plan(state())
  117. with patch.dict(os.environ, {'QA_FAST_PLAN_ENABLED': 'false'}):
  118. with patch.object(production, 'chat_json', return_value=original) as model:
  119. self.assertEqual(production.plan(state())['trace'][-1]['reason'], 'disabled')
  120. model.assert_called_once()
  121. s = state()
  122. s['qa_context']['schema']['meta']['schema_version'] = 1
  123. self.assertIsNone(build_simple_plan(s)[0])
  124. def model_output(self):
  125. s = state()
  126. return {'category': '图谱检索', 'slots': s['slots'], 'data_needs': s['simple_query_needs'],
  127. 'simple_query': s['simple_query']}
  128. def test_real_graph_skips_second_model_and_still_confirms(self):
  129. with patch.object(nodes, 'load_question_context', return_value=deepcopy(CONTEXT)), \
  130. patch.object(nodes, 'select_assessments', side_effect=lambda ctx, *a, **kw: ctx), \
  131. patch.object(nodes, 'chat_json', return_value=self.model_output()) as understanding, \
  132. patch.object(production, 'chat_json') as planner:
  133. graph = build_agent_graph(InMemorySaver())
  134. result = graph.invoke({'question': '共有多少名员工?'},
  135. {'configurable': {'thread_id': 'simple'}})
  136. understanding.assert_called_once()
  137. planner.assert_not_called()
  138. self.assertEqual(result['__interrupt__'][0].value['type'], 'confirm_plan')
  139. self.assertEqual(result['trace'][-1]['mode'], 'simple')
  140. def test_confirm_edit_replans_and_then_executes(self):
  141. original, _ = build_simple_plan(state())
  142. driver = SimpleNamespace(execute_query=lambda *a, **kw: SimpleNamespace(records=[{'记录数': 2}]))
  143. with patch.object(nodes, 'load_question_context', return_value=deepcopy(CONTEXT)), \
  144. patch.object(nodes, 'select_assessments', side_effect=lambda ctx, *a, **kw: ctx), \
  145. patch.object(nodes, 'chat_json', return_value=self.model_output()), \
  146. patch.object(production, 'chat_json', return_value=original) as planner, \
  147. patch.object(production, 'get_driver', return_value=driver), \
  148. patch.object(production, 'release_snapshot', return_value={'data_version': 'v1'}), \
  149. patch.object(production, 'chat_text', return_value='共2条记录'):
  150. graph = build_agent_graph(InMemorySaver())
  151. config = {'configurable': {'thread_id': 'edit'}}
  152. graph.invoke({'question': '共有多少名员工?'}, config)
  153. result = graph.invoke(Command(resume='请重新核对计数口径'), config)
  154. self.assertIn('__interrupt__', result)
  155. planner.assert_called_once()
  156. result = graph.invoke(Command(resume='确认'), config)
  157. self.assertEqual(result['answer'], '共2条记录')
  158. self.assertEqual(result['subgraph']['results']['s1']['rows'], [{'记录数': 2}])
  159. def test_understanding_resets_old_candidate_and_switch_restores_prompt(self):
  160. for enabled in ['true', 'false']:
  161. s = state()
  162. with patch.dict(os.environ, {'QA_FAST_PLAN_ENABLED': enabled}), \
  163. patch.object(nodes, 'load_question_context', return_value=deepcopy(CONTEXT)), \
  164. patch.object(nodes, 'select_assessments', side_effect=lambda ctx, *a, **kw: ctx), \
  165. patch.object(nodes, 'chat_json', return_value={'category': '闲聊'}) as model:
  166. result = nodes.understand(s)
  167. self.assertIsNone(result['simple_query'])
  168. self.assertIsNone(result['simple_query_needs'])
  169. self.assertEqual('simple_query' in model.call_args.args[0], enabled == 'true')
  170. def test_full_planner_still_retries_invalid_plan(self):
  171. s = state()
  172. s['simple_query'] = None
  173. original, _ = build_simple_plan(state())
  174. with patch.object(production, 'chat_json', side_effect=[{'steps': []}, original]) as model:
  175. result = production.plan(s)
  176. self.assertEqual(model.call_count, 2)
  177. self.assertEqual(result['trace'][-1]['validation_attempts'], 2)
  178. def test_minimal_candidate_matches_explicit_defaults(self):
  179. full = state()
  180. minimal = state()
  181. minimal['simple_query'] = {'kind': 'count', 'dataset': '人员信息', 'complete': True}
  182. self.assertEqual(build_simple_plan(full), build_simple_plan(minimal))
  183. plan, _ = build_simple_plan(minimal)
  184. step = plan['steps'][0]
  185. self.assertNotIn('fields', step)
  186. self.assertNotIn('depends', step)
  187. self.assertNotIn('select', step['params'])
  188. self.assertNotIn('filters', step['params'])
  189. self.assertNotIn('limit', step['params'])
  190. def test_compact_full_plan_preserves_filters_and_does_not_add_empty_fields(self):
  191. s = state('按部门分别统计运营部员工')
  192. s['simple_query'] = None
  193. plan = {'steps': [{'step_id': 's1', 'tool': '图谱查询', 'params': {
  194. 'nodes': [{'alias': 'n', 'type': '人员信息'}],
  195. 'filters': [{'alias': 'n', 'field': '部门', 'op': 'eq', 'value': '运营部'}],
  196. 'select': [{'alias': 'n', 'field': '部门', 'as': '部门'}],
  197. 'aggregates': [{'alias': 'n', 'op': 'count', 'as': '人数'}]}}]}
  198. with patch.dict(os.environ, {'QA_COMPACT_INTERMEDIATE_ENABLED': 'true'}), \
  199. patch.object(production, 'chat_json', return_value=plan) as model:
  200. result = production.plan(s)
  201. self.assertEqual(result['plan'], plan)
  202. self.assertIn('省略所有空数组', model.call_args.args[0])
  203. self.assertNotIn('"history"', model.call_args.args[1])
  204. self.assertNotIn('"feedback"', model.call_args.args[1])
  205. compiled = compile_query(plan['steps'][0]['params'], SCHEMA, 'v1')
  206. self.assertEqual(compiled.params['v0'], '运营部')
  207. self.assertIn('n.`部门` AS `部门`', compiled.query)
  208. def test_both_switches_off_restore_original_prompt(self):
  209. s = state()
  210. original, _ = build_simple_plan(s)
  211. with patch.dict(os.environ, {'QA_FAST_PLAN_ENABLED': 'false',
  212. 'QA_COMPACT_INTERMEDIATE_ENABLED': 'false'}), \
  213. patch.object(nodes, 'load_question_context', return_value=deepcopy(CONTEXT)), \
  214. patch.object(nodes, 'select_assessments', side_effect=lambda ctx, *a, **kw: ctx), \
  215. patch.object(nodes, 'chat_json', return_value=self.model_output()) as understand_model, \
  216. patch.object(production, 'chat_json', return_value=original) as plan_model:
  217. s.update(nodes.understand(s))
  218. production.plan(s)
  219. self.assertNotIn('simple_query', understand_model.call_args.args[0])
  220. self.assertNotIn('仅输出紧凑JSON', understand_model.call_args.args[0])
  221. self.assertIn('不需要的数组填[]', plan_model.call_args.args[0])
  222. def test_invalid_switch_is_not_silently_accepted(self):
  223. from step2_graph_building.config import get_qa_fast_plan_enabled
  224. with patch.dict(os.environ, {'QA_FAST_PLAN_ENABLED': 'maybe'}):
  225. with self.assertRaises(ValueError):
  226. get_qa_fast_plan_enabled()
  227. def test_graph_records_one_vs_two_real_wrapper_streams(self):
  228. import json
  229. original, _ = build_simple_plan(state())
  230. output = self.model_output()
  231. class Model:
  232. def stream(self, messages):
  233. body = output if '问题理解器' in messages[0]['content'] else original
  234. yield SimpleNamespace(content=json.dumps(body, ensure_ascii=False), usage_metadata=None)
  235. for enabled, expected in [('true', 1), ('false', 2)]:
  236. with patch.dict(os.environ, {'QA_FAST_PLAN_ENABLED': enabled,
  237. 'QA_COMPACT_INTERMEDIATE_ENABLED': enabled}), \
  238. patch.object(nodes, 'load_question_context', return_value=deepcopy(CONTEXT)), \
  239. patch.object(nodes, 'select_assessments', side_effect=lambda ctx, *a, **kw: ctx), \
  240. patch.object(llm, 'get_chat', return_value=Model()) as client:
  241. result = build_agent_graph(InMemorySaver()).invoke({'question': '共有多少名员工?'},
  242. {'configurable': {'thread_id': enabled}})
  243. self.assertEqual(client.call_count, expected)
  244. self.assertEqual(sum(len(t['llm_calls']) for t in result['trace']), expected)
  245. self.assertEqual(result['__interrupt__'][0].value['type'], 'confirm_plan')
  246. class TimingTests(unittest.TestCase):
  247. def test_json_retries_and_usage_are_measured(self):
  248. class Model:
  249. def __init__(self):
  250. self.count = 0
  251. def stream(self, messages):
  252. self.count += 1
  253. yield SimpleNamespace(content='', usage_metadata=None)
  254. yield SimpleNamespace(content='invalid' if self.count == 1 else '{"ok":true}',
  255. usage_metadata={'input_tokens': 4, 'output_tokens': 2})
  256. with measure_llm_calls() as calls, patch.object(llm, 'get_chat', return_value=Model()):
  257. self.assertEqual(llm.chat_json('system', 'user'), {'ok': True})
  258. self.assertEqual(len(calls), 2)
  259. self.assertEqual(calls[-1]['usage']['input_tokens'], 4)
  260. self.assertGreaterEqual(calls[-1]['duration_ms'], calls[-1]['first_text_ms'])
  261. self.assertNotIn('system', str(calls))
  262. self.assertIsNone(llm_calls.get())
  263. def test_nested_collectors_do_not_leak(self):
  264. with measure_llm_calls() as outer:
  265. with measure_llm_calls() as inner:
  266. self.assertIs(llm_calls.get(), inner)
  267. self.assertIs(llm_calls.get(), outer)
  268. self.assertIsNone(llm_calls.get())
  269. def test_stream_failure_is_measured_and_propagated(self):
  270. def fail(messages):
  271. raise RuntimeError('transport failed')
  272. yield
  273. with measure_llm_calls() as calls, patch.object(llm, 'get_chat',
  274. return_value=SimpleNamespace(stream=fail)):
  275. with self.assertRaises(RuntimeError):
  276. llm.chat_json('system', 'user')
  277. self.assertEqual(calls[0]['status'], 'error')
  278. self.assertIsNone(calls[0]['first_text_ms'])
  279. if __name__ == '__main__':
  280. unittest.main(verbosity=2)