test_main_fan_timeseries.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. """真实曲线协议适配、可信取数文件及 Agent 的查询/沙箱/归档链路。"""
  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_timeseries
  17. from extensions.shaanxi.client import GatewayClient
  18. from extensions.shaanxi.settings import GatewaySettings
  19. from runtime.environment import RuntimeEnvironment
  20. from runtime.execution import execute
  21. from runtime.python_sandbox import capability, digest, workspace_file
  22. from sandbox_agent_helpers import tool_call
  23. NODE = '61082101917301KG1010016D09'
  24. ARGS = {'node_id': NODE, 'dept_id': '1117', 'start_time': '2026-10-19 11:00:00',
  25. 'end_time': '2026-10-19 11:11:00'}
  26. TIMES = ['11:00:13', '11:00:43', '11:01:15', '11:01:45', '11:02:17', '11:02:48',
  27. '11:03:19', '11:03:50', '11:04:20', '11:04:49', '11:05:21', '11:05:52',
  28. '11:06:22', '11:06:53', '11:07:24', '11:07:54', '11:08:25', '11:08:57',
  29. '11:09:28', '11:09:58', '11:10:30', '11:11:00']
  30. def curve_body():
  31. return {'code': 200, 'msg': '操作成功', 'data': [[
  32. {'SensorTypeID': '1010', 'stateName': '正常', 'RealData': 1, 'NodeID': NODE,
  33. 'StateID': '0', 'nodePlace': '主通1一级', 'DataTime': '2026-10-19 ' + value, 'KgDesc': '开'}
  34. for value in TIMES], [{'SensorTypeID': '1010', 'SensorName': '主通风机',
  35. 'NodeID': NODE, 'NodePlace': '主通1一级'}]]}
  36. @pytest.fixture
  37. def workbench(monkeypatch, tmp_path):
  38. (tmp_path / 'skills').symlink_to(config.ROOT / 'skills', target_is_directory=True)
  39. (tmp_path / 'db').symlink_to(config.ROOT / 'db', 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_SERIES_MAX_HOURS': '24', 'MAIN_FAN_SERIES_MAX_SAMPLES': '200000'}.items():
  44. monkeypatch.setenv(key, value)
  45. user = 'curve-user'
  46. session = chat_store.create_session(user)['id']
  47. request = {'mine_code': '610821019173', '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, manifest=prepared, runtime=runtime,
  51. workspace=session_root(user, session) / 'workspace')
  52. @asynccontextmanager
  53. async def gateway(monkeypatch, body=None, *, status=200):
  54. calls = []
  55. body = curve_body() if body is None else body
  56. def handler(request):
  57. assert request.url.path == '/internal/monitor/proxy'
  58. assert request.headers['X-Agent-Code'] == 'main_fan_equivalence_agent'
  59. assert request.headers['Authorization'] == 'Bearer curve-test-secret'
  60. calls.append(json.loads(request.content))
  61. return httpx.Response(200, json={'code': 200, 'success': True,
  62. 'data': {'status_code': status, 'body': deepcopy(body)}})
  63. client = GatewayClient(GatewaySettings('https://gateway.invalid', 'curve-test-secret'),
  64. transport=httpx.MockTransport(handler))
  65. monkeypatch.setattr(gateway_queries, 'get_service', lambda name: client)
  66. try:
  67. yield calls
  68. finally:
  69. await client.close()
  70. async def call(workbench, **overrides):
  71. return await query_fan_timeseries(workflow_id=workbench.manifest['workflow_id'], runtime=workbench.runtime,
  72. **{**ARGS, **overrides})
  73. @pytest.mark.asyncio
  74. async def test_curve_protocol_timezone_full_data_and_protected_sources(workbench, monkeypatch):
  75. async with gateway(monkeypatch) as calls:
  76. result = await call(workbench, start_time='2026-10-19T03:00:00Z', end_time='2026-10-19T03:11:00Z')
  77. assert result['ok'], result
  78. assert calls == [{'method': 'GET', 'path': '/aqjk/alarmcurve/getCurveByNodeId', 'params': {
  79. 'nodeIds': NODE, 'deptId': '1117', 'startTime': ARGS['start_time'], 'endTime': ARGS['end_time']},
  80. 'json_body': None}]
  81. assert result['sample_count'] == 22 and len(result['preview']) == 4
  82. assert result['first_time'] == '2026-10-19T11:00:13+08:00'
  83. assert result['last_time'] == '2026-10-19T11:11:00+08:00'
  84. raw = workspace_file(workbench.user, workbench.session, result['raw_path'])
  85. data = workspace_file(workbench.user, workbench.session, result['data_path'])
  86. assert json.loads(raw.read_text()) == curve_body()
  87. normalized = json.loads(data.read_text())
  88. assert len(normalized['samples']) == 22
  89. assert normalized['samples'][0] == {'time': result['first_time'], 'value': 1,
  90. 'value_label': '开', 'status_code': '0', 'status_label': '正常'}
  91. assert normalized['metadata'][0]['NodePlace'] == '主通1一级'
  92. assert result['python_paths']['data'] == str(data.relative_to(workbench.workspace))
  93. assert result['sha256'] == digest(data)
  94. assert 'curve-test-secret' not in raw.read_text() + data.read_text()
  95. workflow = exploration._load(workbench.manifest['workflow_id'], workbench.user, workbench.session)
  96. assert len(workflow['context']['query_exports']) == 3
  97. assert exploration.manifest(workflow, workbench.user, workbench.session)['timeseries_queries'] == [result]
  98. @pytest.mark.asyncio
  99. async def test_repeated_queries_keep_sources_after_input_export_and_detect_tampering(workbench, monkeypatch):
  100. async with gateway(monkeypatch) as calls:
  101. first, second = await call(workbench), await call(workbench)
  102. assert first['query_id'] != second['query_id']
  103. workflow = exploration._load(workbench.manifest['workflow_id'], workbench.user, workbench.session)
  104. exploration._persist(workflow, workbench.user, workbench.session, '重新导出输入', {}, export=True)
  105. exploration.validate_inputs(workflow, workbench.user, workbench.session)
  106. assert len(workflow['context']['query_exports']) == 6
  107. workspace_file(workbench.user, workbench.session, first['data_path']).write_text('{}')
  108. rejected = await call(workbench)
  109. assert rejected['ok'] is False and '已被修改' in rejected['message']
  110. assert len(calls) == 2
  111. @pytest.mark.asyncio
  112. @pytest.mark.parametrize('overrides', [
  113. {'node_id': '../escape'}, {'dept_id': ''}, {'start_time': '2026-10-19'},
  114. {'start_time': 'not-a-time'}, {'end_time': ARGS['start_time']},
  115. {'end_time': '2026-10-21 11:11:00'}, {'start_time': '2026-10-19 11:00:00.5'},
  116. ])
  117. async def test_invalid_query_does_not_call_gateway(workbench, monkeypatch, overrides):
  118. async with gateway(monkeypatch) as calls:
  119. result = await call(workbench, **overrides)
  120. assert result['ok'] is False and calls == []
  121. @pytest.mark.asyncio
  122. @pytest.mark.parametrize('damage', ['shape', 'foreign_sample', 'foreign_metadata', 'missing_time', 'bad_time'])
  123. async def test_unrecognized_or_foreign_data_is_rejected(workbench, monkeypatch, damage):
  124. body = curve_body()
  125. if damage == 'shape':
  126. body['data'] = {'items': []}
  127. elif damage == 'foreign_sample':
  128. body['data'][0][0]['NodeID'] = 'foreign-node'
  129. elif damage == 'foreign_metadata':
  130. body['data'][1][0]['NodeID'] = 'foreign-node'
  131. elif damage == 'missing_time':
  132. del body['data'][0][0]['DataTime']
  133. else:
  134. body['data'][0][0]['DataTime'] = None
  135. async with gateway(monkeypatch, body):
  136. result = await call(workbench)
  137. assert result['ok'] is False and 'sample_count' not in result
  138. workflow = exploration._load(workbench.manifest['workflow_id'], workbench.user, workbench.session)
  139. assert not workflow['context'].get('timeseries_queries')
  140. @pytest.mark.asyncio
  141. async def test_empty_data_and_upstream_failure_are_distinct(workbench, monkeypatch):
  142. async with gateway(monkeypatch, {'code': 200, 'data': [[], []]}):
  143. empty = await call(workbench)
  144. assert empty['ok'] and empty['sample_count'] == 0 and empty['first_time'] is None
  145. async with gateway(monkeypatch, status=503):
  146. failed = await call(workbench)
  147. assert failed['ok'] is False and failed['retryable'] is True
  148. assert 'sample_count' not in failed
  149. @pytest.mark.asyncio
  150. async def test_quality_issues_are_reported_without_dropping_records(workbench, monkeypatch):
  151. body = curve_body()
  152. rows = body['data'][0] = [deepcopy(body['data'][0][0]) for _ in range(3)]
  153. rows[1]['RealData'], rows[1]['stateName'] = None, '未知'
  154. rows[2]['DataTime'] = '2026-10-19 10:59:00'
  155. async with gateway(monkeypatch, body):
  156. result = await call(workbench)
  157. assert result['sample_count'] == 3
  158. assert result['quality'] == {'duplicate_time_count': 1, 'out_of_order_count': 1,
  159. 'out_of_range_count': 1, 'null_value_count': 1}
  160. assert result['preview'][1]['value'] is None and result['preview'][1]['status_label'] == '未知'
  161. @pytest.mark.asyncio
  162. async def test_disabled_gateway_and_sample_limit_return_actionable_feedback(workbench, monkeypatch):
  163. monkeypatch.setenv('SHANXI_GATEWAY_ENABLED', '0')
  164. async with gateway(monkeypatch) as calls:
  165. disabled = await call(workbench)
  166. assert disabled['ok'] is False and 'SHANXI_GATEWAY_ENABLED' in disabled['message'] and not calls
  167. monkeypatch.setenv('SHANXI_GATEWAY_ENABLED', '1')
  168. monkeypatch.setenv('MAIN_FAN_SERIES_MAX_SAMPLES', '2')
  169. async with gateway(monkeypatch):
  170. oversized = await call(workbench)
  171. assert oversized['ok'] is False and '缩小查询时间范围' in oversized['message']
  172. class ToolModel(GenericFakeChatModel):
  173. def bind_tools(self, tools, *, tool_choice=None, **kwargs):
  174. return self
  175. async def _astream(self, *args, **kwargs):
  176. message = next(self.messages)
  177. yield ChatGenerationChunk(message=AIMessageChunk(content=message.content,
  178. tool_calls=message.tool_calls, usage_metadata=message.usage_metadata))
  179. def query_and_deliver(workbench):
  180. yield tool_call('prepare_main_fan_task', {'target': workbench.request})
  181. identifier = workbench.manifest['workflow_id']
  182. yield tool_call('query_fan_timeseries', {'workflow_id': identifier, **ARGS})
  183. workflow = main_fan_store.workflow(identifier, workbench.user, workbench.session)
  184. query = workflow['context']['timeseries_queries'][0]
  185. code = f'''
  186. from pathlib import Path
  187. import json
  188. data = json.loads(Path({query['python_paths']['data']!r}).read_text())
  189. count = len(data['samples'])
  190. Path('curve-report.md').write_text('# 时序读取测试\\n\\n实际读取 ' + str(count) + ' 条采样。')
  191. assessment = {{'status': 'needs_materials', 'result_code': 'UNDETERMINED',
  192. 'summary': '仅有单个开停点,尚未形成主备风机对照。',
  193. 'evidence': [{{'source': {query['data_path']!r}, 'pointer': '/samples'}}],
  194. 'details': {{'sample_count': count}}}}
  195. Path('curve-assessment.json').write_text(json.dumps(assessment, ensure_ascii=False))
  196. '''
  197. yield tool_call('write_file', {'file_path': '/workspace/curve-analysis.py', 'content': code})
  198. yield tool_call('run_python', {'workflow_id': identifier, 'script_path': '/workspace/curve-analysis.py',
  199. 'output_paths': ['/workspace/curve-report.md', '/workspace/curve-assessment.json']})
  200. workflow = main_fan_store.workflow(identifier, workbench.user, workbench.session)
  201. receipt = workflow['context']['python_runs'][-1]
  202. assert receipt['exit_code'] == 0
  203. yield tool_call('submit_main_fan_report', {'workflow_id': identifier, 'report_path': '/workspace/curve-report.md',
  204. 'calculations_path': '/workspace/curve-assessment.json', 'python_run_ids': [receipt['run_id']]})
  205. @pytest.mark.asyncio
  206. async def test_real_agent_queries_executes_and_cites_gateway_file(workbench, monkeypatch, pricing_ready):
  207. if not capability()['available']:
  208. pytest.skip('当前平台缺少执行沙箱')
  209. model = ToolModel(messages=query_and_deliver(workbench))
  210. monkeypatch.setattr(agent_factory.model_factory, 'get_model', lambda: model)
  211. agent_registry.invalidate()
  212. async with gateway(monkeypatch) as calls:
  213. async with RuntimeEnvironment(prewarm=False, billing_retry=False, recover_interrupted=False):
  214. result = await execute(agent_name='main_fan', user_name=workbench.user, session_id=workbench.session,
  215. message='查询已明确的测试测点和时间范围并如实说明数据限制。')
  216. assert len(calls) == 1 and result.status == 'success', result.error
  217. value = result.structured_output
  218. assert value['assessment']['agent_result']['details']['sample_count'] == 22
  219. queries = value['assessment']['exploration']['timeseries_queries']
  220. assert value['assessment']['evidence'][0]['sha256'] == queries[0]['sha256']
  221. assert any(source['url'] == queries[0]['data_path'] for source in value['sources'])
  222. assert value['platform_payload']['judgment']['resultCode'] == 'UNDETERMINED'