test_main_fan_realtime.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  1. """三个新入口的请求契约与原始数据归档;返回字段使用合成样例,不代表真实平台schema。"""
  2. from contextlib import asynccontextmanager
  3. from copy import deepcopy
  4. import json
  5. from types import SimpleNamespace
  6. import httpx
  7. import pytest
  8. from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
  9. from langchain_core.messages import AIMessageChunk
  10. from langchain_core.outputs import ChatGenerationChunk
  11. import config
  12. from agents import agent_factory, agent_registry
  13. from agents.workspace import session_root
  14. from db import chat_store, main_fan_store
  15. from extensions.main_fan import exploration, gateway_queries
  16. from extensions.main_fan.exploration_tools import (query_fan_sensor_realtime,
  17. query_fan_equipment_realtime, query_fan_equipment_timeseries)
  18. from extensions.shaanxi.client import GatewayClient
  19. from extensions.shaanxi.settings import GatewaySettings
  20. from runtime.environment import RuntimeEnvironment
  21. from runtime.execution import execute
  22. from runtime.python_sandbox import capability, digest, workspace_file
  23. from sandbox_agent_helpers import tool_call
  24. TYPES = ['0005', '0013', '1001', '1010', '0001,0011,0043', '0002']
  25. NODE = '6108310185903000MN30102100000000000064'
  26. CURVE_ARGS = {'dept_id': '1237', 'node_id': NODE, 'start_time': '2026-10-20 06:20:49',
  27. 'end_time': '2026-10-20 09:20:49'}
  28. SENSORS = {'code': 200, 'total': 1, 'rows': [{'nodeId': 'synthetic-switch', 'currentData': '开',
  29. 'sensorTypeId': '1010', 'nodePlace': '合成主通1一级'}]}
  30. EQUIPMENT = {'code': 200, 'total': '3', 'rows': [
  31. {'nodeId': 'synthetic-flow', 'label': '合成风量', 'currentValue': '3800', 'unit': 'm³/min',
  32. 'device': {'id': 'synthetic-fan', 'name': '合成测试风机'}},
  33. {'nodeId': 'synthetic-bearing', 'label': '合成电机轴承温度', 'currentValue': '46.2', 'unit': '℃'}]}
  34. CURVE = {'code': 200, 'data': {'metadata': {'nodeId': NODE, 'unit': '℃'},
  35. 'axis': ['2026-10-20 06:20:49', '2026-10-20 09:20:49'], 'values': [45.2, 46.2]}}
  36. @pytest.fixture
  37. def workbench(monkeypatch, tmp_path):
  38. for directory in ('skills', 'db'):
  39. (tmp_path / directory).symlink_to(config.ROOT / directory, target_is_directory=True)
  40. monkeypatch.setattr(config, 'ROOT', tmp_path)
  41. for key, value in {'MAIN_FAN_TARGETS_FILE': '', 'MAIN_FAN_RULES_FILE': '',
  42. 'SHANXI_GATEWAY_ENABLED': '1', 'MAIN_FAN_AGENT_CODE': 'main_fan_equivalence_agent',
  43. 'MAIN_FAN_REALTIME_MAX_PAGE_SIZE': '1000', 'MAIN_FAN_SERIES_MAX_HOURS': '24'}.items():
  44. monkeypatch.setenv(key, value)
  45. user = 'realtime-test'
  46. session = chat_store.create_session(user)['id']
  47. request = {'mine_code': 'synthetic-mine', 'mine_name': '合成接口测试矿', 'use_gateway': True}
  48. prepared = exploration.prepare(request, user, session)
  49. runtime = SimpleNamespace(config={'configurable': {'workspace_user': user, 'workspace_session': session}})
  50. return SimpleNamespace(user=user, session=session, request=request, identifier=prepared['workflow_id'],
  51. runtime=runtime, workspace=session_root(user, session) / 'workspace')
  52. @asynccontextmanager
  53. async def gateway(monkeypatch, response=SENSORS, *, proxy_status=200):
  54. calls = []
  55. def handler(request):
  56. assert request.url.path == '/internal/monitor/proxy'
  57. assert request.headers['X-Agent-Code'] == 'main_fan_equivalence_agent'
  58. assert request.headers['Authorization'] == 'Bearer realtime-test-secret'
  59. payload = json.loads(request.content)
  60. calls.append(payload)
  61. body = response(payload) if callable(response) else response
  62. return httpx.Response(200, json={'code': 200, 'success': True,
  63. 'data': {'status_code': proxy_status, 'body': deepcopy(body)}})
  64. client = GatewayClient(GatewaySettings('https://gateway.invalid', 'realtime-test-secret'),
  65. transport=httpx.MockTransport(handler))
  66. monkeypatch.setattr(gateway_queries, 'get_service', lambda name: client)
  67. try:
  68. yield calls
  69. finally:
  70. await client.close()
  71. async def sensor_call(workbench, **overrides):
  72. args = {'workflow_id': workbench.identifier, 'dept_id': '1117', 'runtime': workbench.runtime, **overrides}
  73. return await query_fan_sensor_realtime(**args)
  74. async def equipment_call(workbench, **overrides):
  75. args = {'workflow_id': workbench.identifier, 'dept_id': '1219', 'runtime': workbench.runtime, **overrides}
  76. return await query_fan_equipment_realtime(**args)
  77. def read_data(workbench, result):
  78. return json.loads(workspace_file(workbench.user, workbench.session, result['data_path']).read_text())
  79. @pytest.mark.asyncio
  80. async def test_sensor_indexed_filter_matches_curl_and_preserves_complete_payload(workbench, monkeypatch):
  81. async with gateway(monkeypatch) as calls:
  82. result = await sensor_call(workbench, sensor_type_list=TYPES)
  83. assert result['ok'], result
  84. assert calls[0]['path'] == '/aqjk/node/getNodeRealtimeDetails'
  85. assert calls[0]['params'] == [['pageSize', 100], ['pageNum', 1], ['deptId', '1117'],
  86. *[[f'sensorTypeList[{index}]', value] for index, value in enumerate(TYPES)]]
  87. assert result['row_count'] == 1 and result['total'] == 1 and result['has_more'] is False
  88. assert read_data(workbench, result)['payload'] == SENSORS
  89. assert result['rows_pointer'] == '/payload/rows'
  90. assert result['kind'] == 'sensor_realtime'
  91. assert 'realtime-test-secret' not in json.dumps(read_data(workbench, result))
  92. @pytest.mark.asyncio
  93. async def test_unfiltered_sensor_query_and_explicit_last_page(workbench, monkeypatch):
  94. body = {'code': 200, 'data': {'rows': [{'id': 'three'}, {'id': 'four'}], 'total': '4'}}
  95. async with gateway(monkeypatch, body) as calls:
  96. result = await sensor_call(workbench, page_num=2, page_size=2)
  97. assert calls[0]['params'] == [['pageSize', 2], ['pageNum', 2], ['deptId', '1117']]
  98. assert result['row_count'] == 2 and result['has_more'] is False and result['next_page'] is None
  99. assert result['rows_pointer'] == '/payload/data/rows'
  100. @pytest.mark.asyncio
  101. async def test_equipment_system_pagination_and_original_units(workbench, monkeypatch):
  102. def response(payload):
  103. body = deepcopy(EQUIPMENT)
  104. if payload['params']['pageNum'] == '2':
  105. body['rows'] = [{'nodeId': 'synthetic-flow-2', 'currentValue': '3610', 'unit': 'm³/min'}]
  106. return body
  107. async with gateway(monkeypatch, response) as calls:
  108. first = await equipment_call(workbench, page_size=2)
  109. second = await equipment_call(workbench, page_num=2, page_size=2)
  110. assert calls[0]['path'] == '/zdsb/sbnode/selectSbNodeList'
  111. assert calls[0]['params'] == {'pageNum': '1', 'pageSize': '2', 'deptId': '1219', 'devOfSystem': '30'}
  112. assert calls[1]['params']['pageNum'] == '2'
  113. assert first['row_count'] == 2 and first['has_more'] is True and first['next_page'] == 2
  114. assert second['row_count'] == 1 and second['has_more'] is False and second['next_page'] is None
  115. assert first['query_id'] != second['query_id']
  116. data = read_data(workbench, first)
  117. assert data['payload'] == EQUIPMENT
  118. assert data['payload']['rows'][0]['currentValue'] == '3800'
  119. assert data['payload']['rows'][0]['device']['id'] == 'synthetic-fan'
  120. assert data['payload']['rows'][1]['unit'] == '℃'
  121. @pytest.mark.asyncio
  122. async def test_equipment_curve_uses_own_namespace_singular_node_and_raw_shape(workbench, monkeypatch):
  123. async with gateway(monkeypatch, CURVE) as calls:
  124. result = await query_fan_equipment_timeseries(workbench.identifier, runtime=workbench.runtime, **CURVE_ARGS)
  125. assert result['ok'], result
  126. assert calls[0]['path'] == '/zdsb/sbnode/selectSbNodeCurve'
  127. assert calls[0]['params'] == {'deptId': '1237', 'nodeId': NODE,
  128. 'startTime': CURVE_ARGS['start_time'], 'endTime': CURVE_ARGS['end_time']}
  129. assert result['node_id'] == NODE and result['kind'] == 'equipment_timeseries'
  130. assert result['data_type'] == 'dict' and result['data_item_count'] is None
  131. assert result['sample_count'] is None and result['normalization'] == 'unrecognized'
  132. assert read_data(workbench, result)['payload'] == CURVE
  133. assert result['sha256'] == digest(workspace_file(workbench.user, workbench.session, result['data_path']))
  134. @pytest.mark.asyncio
  135. async def test_unknown_list_is_saved_without_claiming_zero_or_complete(workbench, monkeypatch):
  136. body = {'code': 200, 'data': {'unknownEquipmentContainer': [{'strangeField': 'value'}]}}
  137. async with gateway(monkeypatch, body):
  138. result = await equipment_call(workbench)
  139. assert result['ok'] and result['list_recognized'] is False
  140. assert result['row_count'] is None and result['has_more'] is None and result['total'] is None
  141. assert read_data(workbench, result)['payload'] == body
  142. @pytest.mark.asyncio
  143. async def test_missing_total_does_not_claim_last_page(workbench, monkeypatch):
  144. async with gateway(monkeypatch, {'code': 200, 'data': [{'id': 'one'}]}):
  145. result = await sensor_call(workbench, page_size=1)
  146. assert result['row_count'] == 1 and result['total'] is None and result['has_more'] is None
  147. assert result['next_page'] == 2
  148. @pytest.mark.asyncio
  149. async def test_empty_inconsistent_and_failure_are_distinct(workbench, monkeypatch):
  150. async with gateway(monkeypatch, {'code': 200, 'rows': [], 'total': 0}):
  151. empty = await equipment_call(workbench)
  152. assert empty['ok'] and empty['row_count'] == 0 and empty['has_more'] is False
  153. async with gateway(monkeypatch, {'code': 200, 'rows': [], 'total': 3}):
  154. inconsistent = await equipment_call(workbench)
  155. assert inconsistent['has_more'] is True and inconsistent['next_page'] is None
  156. assert '本页为空' in inconsistent['next_action']
  157. async with gateway(monkeypatch, proxy_status=503):
  158. failed = await sensor_call(workbench)
  159. assert failed['ok'] is False and failed['retryable'] is True and 'row_count' not in failed
  160. @pytest.mark.asyncio
  161. @pytest.mark.parametrize('overrides', [{'dept_id': ''}, {'page_num': 0}, {'page_size': 0},
  162. {'page_size': 1001}, {'sensor_type_list': ['']}, {'sensor_type_list': ['../bad']}])
  163. async def test_invalid_list_arguments_do_not_send_a_request(workbench, monkeypatch, overrides):
  164. async with gateway(monkeypatch) as calls:
  165. result = await sensor_call(workbench, **overrides)
  166. assert result['ok'] is False and calls == []
  167. @pytest.mark.asyncio
  168. async def test_invalid_rows_do_not_become_an_empty_result(workbench, monkeypatch):
  169. async with gateway(monkeypatch, {'code': 200, 'rows': None, 'total': 0}):
  170. result = await equipment_call(workbench)
  171. assert result['ok'] is False and 'row_count' not in result
  172. @pytest.mark.asyncio
  173. async def test_large_preview_is_omitted_but_complete_data_is_kept(workbench, monkeypatch):
  174. body = {'code': 200, 'rows': [{'id': 'one', 'nested': 'x' * 20000}], 'total': 1}
  175. async with gateway(monkeypatch, body):
  176. result = await equipment_call(workbench)
  177. assert result['preview_omitted'] and result['preview'] == []
  178. assert read_data(workbench, result)['payload'] == body
  179. @pytest.mark.asyncio
  180. async def test_query_files_are_protected_and_workflow_ownership_precedes_network(workbench, monkeypatch):
  181. async with gateway(monkeypatch) as calls:
  182. wrong = SimpleNamespace(config={'configurable': {'workspace_user': 'another-user',
  183. 'workspace_session': workbench.session}})
  184. rejected = await sensor_call(workbench, runtime=wrong)
  185. assert rejected['ok'] is False and calls == []
  186. result = await sensor_call(workbench)
  187. workflow = exploration._load(workbench.identifier, workbench.user, workbench.session)
  188. assert exploration.manifest(workflow, workbench.user, workbench.session)['sensor_queries'] == [result]
  189. workspace_file(workbench.user, workbench.session, result['data_path']).write_text('{}')
  190. failed = await equipment_call(workbench)
  191. assert failed['ok'] is False and '已被修改' in failed['message'] and len(calls) == 1
  192. class ToolModel(GenericFakeChatModel):
  193. def bind_tools(self, tools, *, tool_choice=None, **kwargs):
  194. return self
  195. async def _astream(self, *args, **kwargs):
  196. message = next(self.messages)
  197. yield ChatGenerationChunk(message=AIMessageChunk(content=message.content,
  198. tool_calls=message.tool_calls, usage_metadata=message.usage_metadata))
  199. def query_and_deliver(workbench):
  200. identifier = workbench.identifier
  201. yield tool_call('prepare_main_fan_task', {'target': workbench.request})
  202. yield tool_call('query_fan_sensor_realtime', {'workflow_id': identifier, 'dept_id': '1117',
  203. 'sensor_type_list': TYPES})
  204. yield tool_call('query_fan_equipment_realtime', {'workflow_id': identifier, 'dept_id': '1219', 'page_size': 2})
  205. yield tool_call('query_fan_equipment_timeseries', {'workflow_id': identifier, **CURVE_ARGS})
  206. workflow = main_fan_store.workflow(identifier, workbench.user, workbench.session)
  207. queries = workflow['context']['sensor_queries'] + workflow['context']['equipment_queries']
  208. paths = [item['python_paths']['data'] for item in queries]
  209. sources = [{'source': item['data_path'], 'pointer': '/payload'} for item in queries]
  210. code = f'''
  211. from pathlib import Path
  212. import json
  213. documents = [json.loads(Path(path).read_text())['payload'] for path in {paths!r}]
  214. counts = [len(documents[0]['rows']), len(documents[1]['rows']), len(documents[2]['data']['values'])]
  215. Path('realtime-report.md').write_text('# 合成接口测试\\n\\n已读取3份真实工具输出文件,未作跨矿风机比较。')
  216. assessment = {{'status': 'needs_materials', 'result_code': 'UNDETERMINED',
  217. 'summary': '合成接口取数与读取测试,尚未形成主备对照。',
  218. 'evidence': {sources!r}, 'details': {{'counts': counts}}}}
  219. Path('realtime-assessment.json').write_text(json.dumps(assessment, ensure_ascii=False))
  220. '''
  221. yield tool_call('write_file', {'file_path': '/workspace/realtime-analysis.py', 'content': code})
  222. yield tool_call('run_python', {'workflow_id': identifier, 'script_path': '/workspace/realtime-analysis.py',
  223. 'output_paths': ['/workspace/realtime-report.md', '/workspace/realtime-assessment.json']})
  224. workflow = main_fan_store.workflow(identifier, workbench.user, workbench.session)
  225. receipt = workflow['context']['python_runs'][-1]
  226. assert receipt['exit_code'] == 0
  227. yield tool_call('submit_main_fan_report', {'workflow_id': identifier, 'report_path': '/workspace/realtime-report.md',
  228. 'calculations_path': '/workspace/realtime-assessment.json', 'python_run_ids': [receipt['run_id']]})
  229. @pytest.mark.asyncio
  230. async def test_real_agent_queries_all_three_tools_and_archives_citations(workbench, monkeypatch, pricing_ready):
  231. if not capability()['available']:
  232. pytest.skip('当前平台缺少执行沙箱')
  233. responses = {'/aqjk/node/getNodeRealtimeDetails': SENSORS, '/zdsb/sbnode/selectSbNodeList': EQUIPMENT,
  234. '/zdsb/sbnode/selectSbNodeCurve': CURVE}
  235. model = ToolModel(messages=query_and_deliver(workbench))
  236. monkeypatch.setattr(agent_factory.model_factory, 'get_model', lambda: model)
  237. agent_registry.invalidate()
  238. async with gateway(monkeypatch, lambda payload: responses[payload['path']]) as calls:
  239. async with RuntimeEnvironment(prewarm=False, billing_retry=False, recover_interrupted=False):
  240. result = await execute(agent_name='main_fan', user_name=workbench.user, session_id=workbench.session,
  241. message='验证三个查询入口的合成取数和沙箱读取,不跨部门作业务比较。')
  242. assert result.status == 'success', result.error
  243. assert len(calls) == 3
  244. value = result.structured_output
  245. assert value['assessment']['agent_result']['details']['counts'] == [1, 2, 2]
  246. evidence = value['assessment']['evidence']
  247. assert len(evidence) == 3 and all(item['sha256'] for item in evidence)
  248. assert {item['source'] for item in evidence} <= {item['url'] for item in value['sources']}
  249. assert len(value['assessment']['exploration']['sensor_queries']) == 1
  250. assert len(value['assessment']['exploration']['equipment_queries']) == 2
  251. assert value['platform_payload']['judgment']['resultCode'] == 'UNDETERMINED'