test_main_fan_batch.py 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566
  1. """月度本地批次:40天双系统取数、身份边界、PDF接口与跨会话档案复用。"""
  2. from contextlib import asynccontextmanager, contextmanager
  3. import asyncio
  4. from datetime import timedelta
  5. import json
  6. from types import SimpleNamespace
  7. import pytest
  8. import config
  9. from agents.workspace import session_root
  10. from db import business_store, chat_store, main_fan_store
  11. from extensions.main_fan import exploration, gateway_queries, history, report_archive
  12. from extensions.main_fan.batch import BatchRunner
  13. from extensions.main_fan.batch_context import BatchScope, current_scope
  14. from extensions.main_fan.batch_models import BatchRequest, HistoryPoint, MinePlan, ShaftPlan
  15. from extensions.main_fan.batch_tools import checked_plan
  16. from extensions.main_fan.report_api import ReportClient, mock_report_client
  17. from extensions.registry import load_extensions
  18. from extensions.shaanxi.client import GatewayError
  19. from runtime.python_sandbox import capability, digest, run as run_python, workspace_file
  20. from test_main_fan_documents import native_pdf
  21. @pytest.fixture
  22. def batch_workspace(monkeypatch, tmp_path):
  23. for directory in ('skills', 'db'):
  24. (tmp_path / directory).symlink_to(config.ROOT / directory, target_is_directory=True)
  25. monkeypatch.setattr(config, 'ROOT', tmp_path)
  26. for key, value in {'MAIN_FAN_TARGETS_FILE': '', 'MAIN_FAN_RULES_FILE': '',
  27. 'MAIN_FAN_SERIES_MAX_HOURS': '24', 'MAIN_FAN_HISTORY_CONCURRENCY': '2',
  28. 'RESULT_PLATFORM_ENABLED': '1', 'MAIN_FAN_AUTO_PUBLISH': '1'}.items():
  29. monkeypatch.setenv(key, value)
  30. load_extensions()
  31. source = tmp_path / 'original.pdf'
  32. native_pdf(source)
  33. return SimpleNamespace(root=tmp_path, pdf=source,
  34. mine={'dept_id': '1219', 'mine_id': '00001219', 'mine_name': '测试甲矿'})
  35. @contextmanager
  36. def bound(workspace, *, client=None, shaft=None, days=40, mine=None):
  37. session = chat_store.create_session('batch-test', 'full')
  38. mine = mine or workspace.mine
  39. shaft = shaft or ShaftPlan(shaft_id='north', shaft_name='北风井', identity_status='confirmed', identity_reason='测试明确井号',
  40. points=[HistoryPoint(system='safety', node_id='state1', role='fan_state'),
  41. HistoryPoint(system='safety', node_id='state2', role='fan_state'),
  42. HistoryPoint(system='equipment', node_id='flow1', role='flow'),
  43. HistoryPoint(system='equipment', node_id='flow2', role='flow')])
  44. scope = BatchScope('batch-1', mine, 'batch-test', session['id'], workspace.root / 'out',
  45. workspace.root / 'archives', '2026-10-20T15:00:00+08:00', days, client, shaft)
  46. token = current_scope.set(scope)
  47. prepared = exploration.prepare({'mine_code': mine['mine_id'], 'mine_name': mine['mine_name'],
  48. 'dept_id': mine['dept_id'], 'use_gateway': True, 'measurements': {'batch': {
  49. 'batch_id': scope.batch_id, 'cutoff': scope.cutoff, 'lookback_days': scope.lookback_days,
  50. 'shaft_plan': shaft.model_dump(mode='json')}}}, scope.user, scope.session, job_id='local-test-no-push')
  51. scope.active_workflow_id = prepared['workflow_id']
  52. try:
  53. yield scope, prepared['workflow_id']
  54. finally:
  55. current_scope.reset(token)
  56. class HistoryGateway:
  57. def __init__(self):
  58. self.calls = []
  59. self.fail = set()
  60. async def proxy(self, agent, path, *, params):
  61. self.calls.append((path, params))
  62. node = params.get('nodeIds') or params['nodeId']
  63. if (node, params['startTime']) in self.fail:
  64. raise GatewayError('test', 503, retryable=False)
  65. if 'nodeIds' in params:
  66. return {'code': 200, 'data': [[{'NodeID': node, 'DataTime': params['startTime'],
  67. 'RealData': 1, 'KgDesc': '开'}], [{'NodeID': node}]]}
  68. return {'code': 200, 'data': {'nodeId': node, 'unit': 'm3/min',
  69. 'samples': [{'time': params['startTime'], 'value': 3800}]}}
  70. def test_daily_windows_are_contiguous_and_rolling():
  71. windows = history.daily_windows('2026-10-20 15:00:00', 40)
  72. assert len(windows) == 40
  73. assert windows[0][1].isoformat() == '2026-10-20T15:00:00+08:00'
  74. assert windows[-1][0].isoformat() == '2026-09-10T15:00:00+08:00'
  75. assert all(end - start == timedelta(days=1) for start, end in windows)
  76. assert all(windows[index][0] == windows[index + 1][1] for index in range(39))
  77. @pytest.mark.asyncio
  78. async def test_four_points_160_daily_queries_one_operation_and_resumption(batch_workspace, monkeypatch):
  79. gateway = HistoryGateway()
  80. monkeypatch.setattr(history, 'get_service', lambda _: gateway)
  81. with bound(batch_workspace) as (scope, identifier):
  82. receipt = await history.query(identifier, scope.user, scope.session)
  83. assert receipt['coverage_complete'] and receipt['completed_segments'] == 160
  84. assert receipt['source_policy']['fan_state'] == 'safety'
  85. assert receipt['source_policy']['flow'] == 'equipment'
  86. assert len(gateway.calls) == 160
  87. assert sum('nodeIds' in params for _, params in gateway.calls) == 80
  88. assert sum('nodeId' in params for _, params in gateway.calls) == 80
  89. assert all(params['deptId'] == '1219' for _, params in gateway.calls)
  90. workflow = main_fan_store.workflow(identifier, scope.user, scope.session)
  91. assert len(workflow['stages']) == 2, '按日传输不消耗160次Agent操作'
  92. assert len(workflow['context']['history_manifest']['segments']) == 160
  93. archive = workspace_file(scope.user, scope.session, receipt['evidence_archive_path'])
  94. assert archive.is_file() and len(list(archive.parent.glob('history/**/*.json'))) == 0
  95. compact = json.loads(workspace_file(scope.user, scope.session, receipt['agent_data_path']).read_text())
  96. assert len(compact['points']) == 4 and len(compact['points'][0]['state_periods']) == 1
  97. assert compact['source_policy'] == receipt['source_policy']
  98. await history.query(identifier, scope.user, scope.session)
  99. assert len(gateway.calls) == 160, '恢复成功段不能重复发请求'
  100. exploration.validate_inputs(main_fan_store.workflow(identifier, scope.user, scope.session), scope.user, scope.session)
  101. @pytest.mark.asyncio
  102. async def test_history_failure_keeps_partial_and_only_failed_interval_retries(batch_workspace, monkeypatch):
  103. gateway = HistoryGateway()
  104. gateway.fail.add(('flow1', '2026-10-19 15:00:00'))
  105. monkeypatch.setattr(history, 'get_service', lambda _: gateway)
  106. with bound(batch_workspace, days=2) as (scope, identifier):
  107. first = await history.query(identifier, scope.user, scope.session)
  108. assert first['failed_segments'] == 1 and not first['coverage_complete']
  109. assert len(gateway.calls) == 8
  110. gateway.fail.clear()
  111. second = await history.query(identifier, scope.user, scope.session)
  112. assert second['completed_segments'] == 8 and second['coverage_complete']
  113. assert len(gateway.calls) == 9
  114. @pytest.mark.asyncio
  115. async def test_single_query_rejects_another_mine_before_gateway_access(batch_workspace, monkeypatch):
  116. gateway = HistoryGateway()
  117. monkeypatch.setattr(gateway_queries, 'get_service', lambda _: gateway)
  118. with bound(batch_workspace) as (scope, identifier):
  119. with pytest.raises(ValueError, match='不属于'):
  120. await gateway_queries.fetch(identifier, scope.user, scope.session, '/ignored', {'deptId': '1338'})
  121. assert not gateway.calls
  122. @pytest.mark.asyncio
  123. async def test_mock_report_interface_returns_original_bytes_and_fixture_provenance(batch_workspace):
  124. async with mock_report_client(batch_workspace.pdf) as http:
  125. client = ReportClient(http)
  126. files, _, response = await client.list('00001219', 'north')
  127. assert response['code'] == 200 and files[0]['is_mock']
  128. assert files[0]['binding'] == 'test_fixture_not_mine_report'
  129. target = batch_workspace.root / 'download.pdf'
  130. assert await client.download(files[0], target) == digest(batch_workspace.pdf)
  131. assert target.read_bytes() == batch_workspace.pdf.read_bytes()
  132. assert (await http.get('/api/main-fan/report-files/not-found.pdf')).status_code == 404
  133. @pytest.mark.asyncio
  134. async def test_report_archive_reuses_across_sessions_and_corruption_reparses(batch_workspace):
  135. if not capability()['available']:
  136. pytest.skip('需要真实Python沙箱')
  137. async with mock_report_client(batch_workspace.pdf) as http:
  138. client = ReportClient(http)
  139. with bound(batch_workspace, client=client) as (scope, identifier):
  140. first = await report_archive.fetch(identifier, scope.user, scope.session)
  141. assert first['reports'][0]['mode'] == 'analyze'
  142. facts = {'source_sha256': digest(batch_workspace.pdf), 'page_count': 1, 'read_pages': [1],
  143. 'page_sources': {'1': '/workspace/page_1.txt'},
  144. 'reports': [{'mine_name': 'Synthetic Native Test Mine', 'report_id': 'NATIVE-TEST-001', 'page': 1}],
  145. 'analysis': {'is_test': True}, 'limitations': ['不同矿的模拟原件']}
  146. script = workspace_file(scope.user, scope.session, '/workspace/extract.py')
  147. script.write_text('from pathlib import Path\nimport json\nPath("page_1.txt").write_text("Actual test page text")\nPath("facts.json").write_text(' + repr(json.dumps(facts)) + ')\n')
  148. execution = await run_python(scope.user, scope.session, '/workspace/extract.py',
  149. output_paths=['/workspace/facts.json', '/workspace/page_1.txt'])
  150. assert execution['exit_code'] == 0, execution['stderr']
  151. exploration.record_python(identifier, scope.user, scope.session, execution)
  152. with pytest.raises(ValueError, match='没有可核验'):
  153. report_archive.save(identifier, scope.user, scope.session, '/workspace/facts.json', [execution['run_id']])
  154. assert not list(scope.cache_root.glob('*.json'))
  155. from extensions.main_fan.middleware import record_page_read
  156. from langchain_core.messages import ToolMessage
  157. record_page_read(SimpleNamespace(tool_call={'name': 'read_file', 'args': {'file_path': '/workspace/page_1.txt'}}),
  158. ToolMessage(content='Actual test page text', tool_call_id='page-read'))
  159. receipt = report_archive.save(identifier, scope.user, scope.session, '/workspace/facts.json', [execution['run_id']])
  160. assert receipt['page_count'] == 1
  161. with bound(batch_workspace, client=client) as (scope, identifier):
  162. reused = await report_archive.fetch(identifier, scope.user, scope.session)
  163. assert reused['reused_count'] == 1 and reused['reports'][0]['mode'] == 'reuse'
  164. assert 'archive_python_path' in reused['reports'][0]
  165. archive = next((batch_workspace.root / 'archives').glob('*.json'))
  166. archive.write_text('{broken')
  167. with bound(batch_workspace, client=client) as (scope, identifier):
  168. assert (await report_archive.fetch(identifier, scope.user, scope.session))['reports'][0]['mode'] == 'analyze'
  169. @pytest.mark.asyncio
  170. async def test_report_content_change_invalidates_archive(batch_workspace):
  171. async with mock_report_client(batch_workspace.pdf) as http:
  172. with bound(batch_workspace, client=ReportClient(http)) as (scope, identifier):
  173. first = await report_archive.fetch(identifier, scope.user, scope.session)
  174. sha = first['reports'][0]['sha256']
  175. assert not report_archive.load_cached(scope, sha)
  176. original = batch_workspace.pdf.read_bytes()
  177. batch_workspace.pdf.write_bytes(original + b'\n% updated fixture\n')
  178. async with mock_report_client(batch_workspace.pdf) as http:
  179. with bound(batch_workspace, client=ReportClient(http)) as (scope, identifier):
  180. second = await report_archive.fetch(identifier, scope.user, scope.session)
  181. assert second['reports'][0]['sha256'] != sha and second['reused_count'] == 0
  182. @pytest.mark.asyncio
  183. async def test_claiming_26_pages_after_reading_only_10_cannot_create_cache(batch_workspace):
  184. if not capability()['available']:
  185. pytest.skip('需要真实沙箱')
  186. import pypdfium2
  187. from extensions.main_fan.middleware import record_page_read
  188. from langchain_core.messages import ToolMessage
  189. original = batch_workspace.pdf.read_bytes()
  190. with pypdfium2.PdfDocument(original) as source, pypdfium2.PdfDocument.new() as combined:
  191. for _ in range(26):
  192. combined.import_pages(source)
  193. combined.save(batch_workspace.pdf)
  194. async with mock_report_client(batch_workspace.pdf) as http:
  195. with bound(batch_workspace, client=ReportClient(http)) as (scope, identifier):
  196. await report_archive.fetch(identifier, scope.user, scope.session)
  197. facts = {'source_sha256': digest(batch_workspace.pdf), 'page_count': 26,
  198. 'read_pages': list(range(1, 27)), 'page_sources': {str(page): f'/workspace/page_{page}.txt' for page in range(1, 27)},
  199. 'reports': [{'report_id': 'synthetic'}]}
  200. script = workspace_file(scope.user, scope.session, '/workspace/incomplete.py')
  201. script.write_text('from pathlib import Path\nfor page in range(1,27): Path(f"page_{page}.txt").write_text(f"actual page {page}")\nPath("incomplete.json").write_text(' + repr(json.dumps(facts)) + ')\n')
  202. receipt = await run_python(scope.user, scope.session, '/workspace/incomplete.py', output_paths=['/workspace/incomplete.json'])
  203. assert receipt['exit_code'] == 0
  204. exploration.record_python(identifier, scope.user, scope.session, receipt)
  205. for page in range(1, 11):
  206. record_page_read(SimpleNamespace(tool_call={'name': 'read_file', 'args': {'file_path': f'/workspace/page_{page}.txt'}}),
  207. ToolMessage(content=f'actual page {page}', tool_call_id=str(page)))
  208. with pytest.raises(ValueError, match='第11页'):
  209. report_archive.save(identifier, scope.user, scope.session, '/workspace/incomplete.json', [receipt['run_id']])
  210. assert not list(scope.cache_root.glob('*.json'))
  211. def test_plan_requires_real_points_and_complete_pagination(batch_workspace):
  212. with bound(batch_workspace) as (scope, identifier):
  213. workflow = main_fan_store.workflow(identifier, scope.user, scope.session)
  214. plan = MinePlan(shafts=[scope.shaft.model_copy(update={'shaft_id': 'unresolved-main',
  215. 'shaft_name': '主备系统(待确认)', 'identity_status': 'candidate'})])
  216. with pytest.raises(ValueError, match='两个实时列表'):
  217. checked_plan(workflow, scope.user, scope.session, plan)
  218. context = workflow['context']
  219. for system, collection in [('safety', 'sensor_queries'), ('equipment', 'equipment_queries')]:
  220. virtual = '/workspace/' + system + '.json'
  221. local = workspace_file(scope.user, scope.session, virtual)
  222. exploration._write(local, {'payload': {'rows': []}})
  223. context[collection] = [{'data_path': virtual, 'page_num': 1, 'next_page': None}]
  224. with pytest.raises(ValueError, match='未从本矿'):
  225. checked_plan(workflow, scope.user, scope.session, plan)
  226. context['sensor_queries'][0]['next_page'] = 2
  227. with pytest.raises(ValueError, match='未获取分页'):
  228. checked_plan(workflow, scope.user, scope.session, plan)
  229. @pytest.mark.asyncio
  230. async def test_runner_selected_mines_local_files_no_publish_even_global_auto_enabled(batch_workspace):
  231. calls = []
  232. mine = batch_workspace.mine
  233. snapshot = {'snapshot_id': 'a' * 32, 'mines': [mine,
  234. {'dept_id': '1338', 'mine_id': '00001338', 'mine_name': '不应执行的矿'}]}
  235. plan = MinePlan(shafts=[ShaftPlan(shaft_id=value, shaft_name=value, identity_reason='测试数据明确井号')
  236. for value in ('north', 'south')])
  237. async def executor(**kwargs):
  238. data = kwargs['graph_config']['configurable']['business_task_input']
  239. phase = data['measurements']['batch']['phase']
  240. calls.append((data['dept_id'], kwargs['session_id'], phase))
  241. workspace = session_root(kwargs['user_name'], kwargs['session_id']) / 'workspace'
  242. workspace.mkdir(exist_ok=True)
  243. outside = batch_workspace.root / 'not-an-evidence.txt'
  244. outside.write_text('do not export')
  245. link = workspace / 'external-link.txt'
  246. if not link.is_symlink():
  247. link.symlink_to(outside)
  248. if phase == 'discovery':
  249. result = {'assessment': {'plan': plan.model_dump(mode='json')}}
  250. else:
  251. result = {'result': '# 本地测试报告', 'analysis_id': 'analysis-test', 'workflow_id': 'workflow-test',
  252. 'platform_payload': {'judgment': {'resultCode': 'UNDETERMINED'}},
  253. 'assessment': {'history_coverage': {'coverage_complete': True}, 'report_archives': []}}
  254. return SimpleNamespace(status='success', run_id='run-test', error='', output='', usage={}, structured_output=result)
  255. output = batch_workspace.root / 'output'
  256. runner = BatchRunner(output, batch_workspace.pdf, executor=executor)
  257. request = BatchRequest(dept_ids=['1219'], cutoff='2026-10-20T15:00:00+08:00')
  258. result = await runner.run(request, snapshot=snapshot)
  259. assert result['status'] == 'completed' and result['publish'] is False
  260. assert len(calls) == 3 and {row[0] for row in calls} == {'1219'}
  261. assert len({row[1] for row in calls}) == 1, '同矿多井复用矿级Agent会话'
  262. assert (output / '1219/north/报告.md').is_file() and (output / '1219/south/结果.json').is_file()
  263. assert not (output / '1338').exists()
  264. assert not (output / '1219/workspace/external-link.txt').exists()
  265. assert not business_store.list_jobs('main_fan', 'batch-test', 100)
  266. await runner.run(request)
  267. assert len(calls) == 3, '恢复已完成批次不能重新调用Agent'
  268. await runner.run(request.model_copy(update={'reassess': True}))
  269. assert len(calls) == 5, '重新分析只执行原矿的两井,不重复发现和遍历其他矿'
  270. assert (output / '1219/north/revisions/analysis-test/报告.md').is_file()
  271. def test_plan_rejects_vibration_as_fan_state_and_fan_number_as_shaft(batch_workspace):
  272. with bound(batch_workspace) as (scope, identifier):
  273. workflow = main_fan_store.workflow(identifier, scope.user, scope.session)
  274. for collection in ('sensor_queries', 'equipment_queries'):
  275. virtual = '/workspace/' + collection + '.json'
  276. exploration._write(workspace_file(scope.user, scope.session, virtual), {'payload': {'rows': [
  277. {'nodeId': 'vibration', 'nodeTypeName': '水平振动', 'sensorUnit': 'mm/s'}]}})
  278. workflow['context'][collection] = [{'data_path': virtual, 'page_num': 1, 'next_page': None}]
  279. shaft = ShaftPlan(shaft_id='unresolved-main', shaft_name='主备系统(待确认)', identity_reason='没有井号',
  280. points=[HistoryPoint(system='equipment', node_id='vibration', role='fan_state')])
  281. with pytest.raises(ValueError, match='实际类型'):
  282. checked_plan(workflow, scope.user, scope.session, MinePlan(shafts=[shaft]))
  283. shaft.points = []
  284. shaft.shaft_id, shaft.shaft_name = 'shaft-1', '1号通风井'
  285. with pytest.raises(ValueError, match='风机号不能'):
  286. checked_plan(workflow, scope.user, scope.session, MinePlan(shafts=[shaft]))
  287. @pytest.mark.parametrize('system,role,row', [
  288. ('equipment', 'fan_state', {'nodeId': 'wrong-source', 'nodeTypeName': '风机运行状态'}),
  289. ('safety', 'flow', {'nodeId': 'wrong-source', 'sensorName': '风量'}),
  290. ])
  291. def test_plan_rejects_valid_measurement_from_wrong_authoritative_system(batch_workspace, system, role, row):
  292. with bound(batch_workspace) as (scope, identifier):
  293. workflow = main_fan_store.workflow(identifier, scope.user, scope.session)
  294. for name, collection in [('safety', 'sensor_queries'), ('equipment', 'equipment_queries')]:
  295. virtual = '/workspace/' + collection + '.json'
  296. exploration._write(workspace_file(scope.user, scope.session, virtual),
  297. {'payload': {'rows': [row] if name == system else []}})
  298. workflow['context'][collection] = [{'data_path': virtual, 'page_num': 1, 'next_page': None}]
  299. shaft = ShaftPlan(shaft_id='unresolved-main', shaft_name='主备系统(待确认)', identity_reason='没有井号',
  300. points=[HistoryPoint(system=system, node_id='wrong-source', role=role)])
  301. with pytest.raises(ValueError, match='开停以安全监控为准'):
  302. checked_plan(workflow, scope.user, scope.session, MinePlan(shafts=[shaft]))
  303. @pytest.mark.asyncio
  304. async def test_history_rejects_old_equipment_state_plan_before_gateway_access(batch_workspace, monkeypatch):
  305. gateway = HistoryGateway()
  306. monkeypatch.setattr(history, 'get_service', lambda _: gateway)
  307. shaft = ShaftPlan(shaft_id='unresolved-main', shaft_name='主备系统(待确认)', identity_reason='旧计划',
  308. points=[HistoryPoint(system='equipment', node_id='state1', role='fan_state')])
  309. with bound(batch_workspace, shaft=shaft) as (scope, identifier):
  310. with pytest.raises(ValueError, match='开停以安全监控为准'):
  311. await history.query(identifier, scope.user, scope.session)
  312. assert gateway.calls == []
  313. @pytest.mark.asyncio
  314. async def test_history_reuses_old_archive_but_excludes_equipment_states_from_analysis(batch_workspace, monkeypatch):
  315. gateway = HistoryGateway()
  316. monkeypatch.setattr(history, 'get_service', lambda _: gateway)
  317. with bound(batch_workspace, days=2) as (scope, identifier):
  318. scope.shaft.points.append(HistoryPoint(system='equipment', node_id='old-state', role='fan_state'))
  319. # 先按旧版本行为生成真实格式的合成归档,再应用当前来源选择,检验历史兼容。
  320. with monkeypatch.context() as legacy:
  321. legacy.setattr(history, 'select_history_points', lambda points: list(points))
  322. old = await history.query(identifier, scope.user, scope.session)
  323. assert old['completed_segments'] == 10 and len(gateway.calls) == 10
  324. archive = workspace_file(scope.user, scope.session, old['evidence_archive_path'])
  325. archived_bytes = archive.read_bytes()
  326. current = await history.query(identifier, scope.user, scope.session)
  327. assert current['coverage_complete'] and current['completed_segments'] == 8
  328. assert current['point_count'] == 4 and len(gateway.calls) == 10
  329. assert archive.read_bytes() == archived_bytes, '来源调整不应重拉或删除旧原件'
  330. workflow = main_fan_store.workflow(identifier, scope.user, scope.session)
  331. assert len(workflow['context']['history_index']) == 10
  332. manifest = workflow['context']['history_manifest']
  333. assert len(manifest['segments']) == 8
  334. assert manifest['ignored_plan_points'][0]['node_id'] == 'old-state'
  335. compact = json.loads(workspace_file(scope.user, scope.session, current['agent_data_path']).read_text())
  336. assert all(p['node_id'] != 'old-state' for p in compact['points'])
  337. exploration.validate_inputs(workflow, scope.user, scope.session)
  338. @pytest.mark.asyncio
  339. async def test_actual_agent_graph_two_phases_pdf_history_sandbox_and_local_delivery(batch_workspace, monkeypatch, pricing_ready):
  340. if not capability()['available']:
  341. pytest.skip('需要实际沙箱')
  342. from agents import agent_factory, agent_registry
  343. from runtime.environment import RuntimeEnvironment
  344. from sandbox_agent_helpers import tool_call
  345. from test_main_fan_timeseries import ToolModel
  346. monkeypatch.setenv('RESULT_PLATFORM_ENABLED', '0')
  347. points = [HistoryPoint(system='safety', node_id='state1', role='fan_state'),
  348. HistoryPoint(system='equipment', node_id='flow1', role='flow', unit='m3/min')]
  349. plan = MinePlan(shafts=[ShaftPlan(shaft_id='unresolved-main', shaft_name='主备系统(待确认)',
  350. identity_reason='实际列表未给井号', points=points)])
  351. gateway = HistoryGateway()
  352. original_proxy = gateway.proxy
  353. async def proxy(agent, path, *, params):
  354. if path.endswith('getNodeRealtimeDetails'):
  355. return {'code': 200, 'total': 1, 'rows': [{'nodeId': 'state1', 'sensorTypeId': '1010', 'nodePlace': '主通1一级'}]}
  356. if path.endswith('selectSbNodeList'):
  357. return {'code': 200, 'total': 1, 'rows': [{'nodeId': 'flow1', 'nodeTypeName': '风量', 'sensorUnit': 'm3/min'}]}
  358. return await original_proxy(agent, path, params=params)
  359. gateway.proxy = proxy
  360. monkeypatch.setenv('SHANXI_GATEWAY_ENABLED', '1')
  361. monkeypatch.setenv('SHANXI_GATEWAY_URL', 'https://gateway.invalid')
  362. monkeypatch.setenv('SHANXI_GATEWAY_TOKEN', 'batch-test-not-a-real-token')
  363. @asynccontextmanager
  364. async def gateway_service(dependencies):
  365. yield gateway
  366. monkeypatch.setattr('extensions.shaanxi.extension.gateway_service', gateway_service)
  367. monkeypatch.setattr(gateway_queries, 'get_service', lambda _: gateway)
  368. monkeypatch.setattr(history, 'get_service', lambda _: gateway)
  369. def latest():
  370. from db import db_conn
  371. row = db_conn.query_one("SELECT id,user_name,session_id FROM main_fan_workflows WHERE status='running' ORDER BY created_at DESC LIMIT 1")
  372. return main_fan_store.workflow(row['id'], row['user_name'], row['session_id'])
  373. def messages():
  374. yield tool_call('prepare_main_fan_task', {'target': {}})
  375. identifier = latest()['id']
  376. yield tool_call('query_fan_sensor_realtime', {'workflow_id': identifier, 'dept_id': '1219', 'sensor_type_list': ['1010']})
  377. yield tool_call('query_fan_equipment_realtime', {'workflow_id': identifier, 'dept_id': '1219'})
  378. yield tool_call('submit_main_fan_mine_plan', {'workflow_id': identifier, 'plan': plan.model_dump(mode='json')})
  379. if current_scope.get().shaft is None:
  380. snapshot = agent_registry.get_agent('main_fan').get_state({'configurable': {'thread_id': current_scope.get().session}})
  381. raise AssertionError(str([(getattr(message, 'name', ''), message.content) for message in snapshot.values['messages'][-4:]]))
  382. yield tool_call('prepare_main_fan_task', {'target': {}})
  383. workflow = latest(); identifier = workflow['id']
  384. yield tool_call('fetch_main_fan_reports', {'workflow_id': identifier})
  385. yield tool_call('query_main_fan_history', {'workflow_id': identifier})
  386. workflow = latest(); pdf = workflow['context']['report_archives'][0]
  387. prefix = 'exploration/' + identifier
  388. code = f'''from pathlib import Path
  389. import json, hashlib, pypdfium2 as pdfium
  390. source=Path({pdf['python_path']!r})
  391. with pdfium.PdfDocument(source) as document:
  392. texts=[document[index].get_textpage().get_text_range() for index in range(len(document))]
  393. facts={{'source_sha256':hashlib.sha256(source.read_bytes()).hexdigest(),'page_count':len(texts),
  394. 'read_pages':list(range(1,len(texts)+1)),'page_sources':{{'1':'/workspace/page_1.txt'}},'reports':[{{'raw_text':texts[0],'page':1}}],
  395. 'analysis':{{}},'limitations':['模拟报告,不移植矿井身份']}}
  396. manifest=json.loads(Path({(prefix + '/history-manifest.json')!r}).read_text())
  397. compact=json.loads(Path({(prefix + '/history-agent.json')!r}).read_text())
  398. counts={{row['node_id']:row['quality']['raw_sample_count'] for row in compact['points']}}
  399. Path('facts.json').write_text(json.dumps(facts,ensure_ascii=False))
  400. Path('page_1.txt').write_text(texts[0])
  401. Path('report.md').write_text('# 本地判识\\n\\n完整查询并保留归属限制。')
  402. assessment={{'result_code':'UNDETERMINED','summary':'已查询原件与时序,通风井及整机归属待确认。',
  403. 'details':{{'segments':manifest['expected_segments'],'total_records':sum(counts.values()),'switch_analysis':{{'status':'insufficient_data','method':'单级状态不能代替整机','events':[],'limitations':['测试仅有单级状态']}},'flow_analysis':{{'status':'unrecognized_structure','raw_sample_count':None,'limitations':['保留未知格式,不当空数据']}}}},
  404. 'evidence':[{{'source':'/workspace/{prefix}/history-manifest.json'}}]}}
  405. Path('assessment.json').write_text(json.dumps(assessment,ensure_ascii=False))
  406. '''
  407. yield tool_call('write_file', {'file_path': '/workspace/analyze.py', 'content': code})
  408. yield tool_call('run_python', {'workflow_id': identifier, 'script_path': '/workspace/analyze.py',
  409. 'output_paths': ['/workspace/facts.json', '/workspace/report.md', '/workspace/assessment.json', '/workspace/page_1.txt']})
  410. receipt = latest()['context']['python_runs'][-1]
  411. assert receipt['exit_code'] == 0
  412. yield tool_call('read_file', {'file_path': '/workspace/page_1.txt'})
  413. yield tool_call('archive_main_fan_report', {'workflow_id': identifier, 'facts_path': '/workspace/facts.json',
  414. 'python_run_ids': [receipt['run_id']]})
  415. # 即使宿主全局自动推送开关为真,本地批次的task_id也必须阻止入队。
  416. monkeypatch.setenv('RESULT_PLATFORM_ENABLED', '1')
  417. yield tool_call('submit_main_fan_report', {'workflow_id': identifier, 'report_path': '/workspace/report.md',
  418. 'calculations_path': '/workspace/assessment.json', 'python_run_ids': [receipt['run_id']]})
  419. model = ToolModel(messages=messages())
  420. monkeypatch.setattr(agent_factory.model_factory, 'get_model', lambda: model)
  421. agent_registry.invalidate()
  422. output = batch_workspace.root / 'graph-output'
  423. snapshot = {'snapshot_id': 'b' * 32, 'mines': [batch_workspace.mine]}
  424. async with RuntimeEnvironment(prewarm=False, billing_retry=False, recover_interrupted=False):
  425. result = await BatchRunner(output, batch_workspace.pdf).run(
  426. {'dept_ids': ['1219'], 'cutoff': '2026-10-20 15:00:00', 'lookback_days': 2}, snapshot=snapshot)
  427. assert result['status'] == 'completed', result
  428. from db import db_conn
  429. assert db_conn.query_one('SELECT COUNT(*) AS n FROM business_jobs')['n'] == 0
  430. delivered = json.loads((output / '1219/unresolved-main/结果.json').read_text())
  431. assert delivered['assessment']['agent_result']['details']['segments'] == 4
  432. assert delivered['assessment']['history_coverage']['coverage_complete']
  433. assert delivered['assessment']['report_archives'][0]['archived']
  434. assert (output / 'index.html').is_file()
  435. @pytest.mark.asyncio
  436. async def test_reassessment_reuses_same_batch_history_but_other_batch_refetches(batch_workspace, monkeypatch):
  437. gateway = HistoryGateway()
  438. monkeypatch.setattr(history, 'get_service', lambda _: gateway)
  439. with bound(batch_workspace, days=2) as (scope, identifier):
  440. await history.query(identifier, scope.user, scope.session)
  441. assert len(gateway.calls) == 8
  442. old = main_fan_store.workflow(identifier, scope.user, scope.session)
  443. main_fan_store.update_workflow(identifier, status='completed')
  444. data = old['context']['task_input']
  445. second = exploration.prepare(data, scope.user, scope.session, job_id='local-review')
  446. scope.active_workflow_id = second['workflow_id']
  447. receipt = await history.query(second['workflow_id'], scope.user, scope.session)
  448. assert receipt['coverage_complete'] and len(gateway.calls) == 8
  449. scope.batch_id = 'different-batch'
  450. data['measurements']['batch']['batch_id'] = scope.batch_id
  451. third = exploration.prepare(data, scope.user, scope.session, job_id='other-month')
  452. scope.active_workflow_id = third['workflow_id']
  453. await history.query(third['workflow_id'], scope.user, scope.session)
  454. assert len(gateway.calls) == 16, '不同月度批次仍重新取得当期原件'
  455. def test_equipment_sbnodecurve_zero_samples_are_data_not_empty():
  456. from extensions.main_fan.equipment import _curve_data
  457. body = {'code': 200, 'data': [{'nodeId': 'flow1', 'nodePlace': '1号风机风量', 'sbNodeCurve': [
  458. {'dataTime': '2026-10-19 16:23:20', 'realData': '0'},
  459. {'dataTime': '2026-10-19 17:13:21', 'realData': '0'}]}]}
  460. data, summary = _curve_data(body, 'flow1')
  461. assert summary['sample_count'] == 2 and summary['normalization'] == 'sbNodeCurve'
  462. assert data['payload'] is body and data['series'][0]['samples'][0]['value'] == '0'
  463. assert data['series'][0]['samples'][0]['time'] == '2026-10-19T16:23:20+08:00'
  464. unknown, metadata = _curve_data({'code': 200, 'data': [{'otherCurve': [1, 2]}]})
  465. assert unknown['series'] is None and metadata['sample_count'] is None
  466. with pytest.raises(ValueError, match='测点与本次请求'):
  467. _curve_data(body, 'another-point')
  468. def test_independent_verification_excludes_conflicting_states_and_keeps_zero_flow(tmp_path):
  469. from scripts.verify_main_fan_batch import point_statistics
  470. path = tmp_path / 'workspace' / 'samples.json'
  471. path.parent.mkdir()
  472. segment = {'status': 'completed', 'node_id': 'flow', 'data_path': '/workspace/samples.json',
  473. 'start_time': '2026-10-01T00:00:00+08:00', 'end_time': '2026-10-02T00:00:00+08:00'}
  474. path.write_text(json.dumps({'samples': [
  475. {'time': '2026-10-01T01:00:00+08:00', 'value_label': '开', 'status_label': '正常'},
  476. {'time': '2026-10-01T02:00:00+08:00', 'value_label': '开', 'status_label': '正常'},
  477. {'time': '2026-10-01T02:00:00+08:00', 'value_label': '停', 'status_label': '正常'},
  478. {'time': '2026-10-01T03:00:00+08:00', 'value_label': '停', 'status_label': '故障'},
  479. {'time': '2026-09-30T03:00:00+08:00', 'value_label': '停', 'status_label': '正常'},
  480. ]}))
  481. safety = point_statistics([segment], tmp_path, 'safety')
  482. assert safety['raw_sample_count'] == 5 and safety['unique_usable_sample_count'] == 1
  483. assert safety['conflicting_timestamp_count'] == 1 and safety['invalid_status_count'] == 1
  484. assert safety['out_of_window_count'] == 1 and safety['transitions'] == []
  485. path.write_text(json.dumps({'payload': {'data': [{'nodeId': 'flow', 'sbNodeCurve': [
  486. {'dataTime': '2026-10-01 01:00:00', 'realData': '0'},
  487. {'dataTime': '2026-10-01 01:00:00', 'realData': '0'},
  488. {'dataTime': '2026-10-01 02:00:00', 'realData': 'NaN'},
  489. ]}]}}))
  490. flow = point_statistics([segment], tmp_path, 'equipment')
  491. assert flow['raw_sample_count'] == 3 and flow['zero_count'] == 1
  492. assert flow['repeated_timestamp_count'] == 1 and flow['unrecognized_value_count'] == 1
  493. @pytest.mark.asyncio
  494. async def test_monthly_local_queue_idempotence_and_shutdown_before_start(batch_workspace, monkeypatch):
  495. from extensions.main_fan import batch_service
  496. monkeypatch.setenv('MAIN_FAN_MOCK_REPORT_PDF', str(batch_workspace.pdf))
  497. monkeypatch.setenv('MAIN_FAN_BATCH_OUTPUT_DIR', str(batch_workspace.root / 'service-output'))
  498. monkeypatch.setenv('MAIN_FAN_BATCH_DEPT_IDS', '1219')
  499. calls = []
  500. class Runner:
  501. def __init__(self, folder, pdf, **kwargs):
  502. self.folder = folder
  503. async def run(self, request):
  504. calls.append(request.dept_ids)
  505. exploration._write(self.folder / 'batch.json', {'status': 'completed'})
  506. return {'status': 'completed'}
  507. monkeypatch.setattr(batch_service, 'BatchRunner', Runner)
  508. controller = batch_service.LocalBatchController()
  509. monkeypatch.setattr('extensions.get_service', lambda _: controller)
  510. first = await batch_service.monthly_callback()
  511. second = await batch_service.monthly_callback()
  512. assert first['batch_id'] == second['batch_id'] and len(controller.tasks) == 1
  513. await asyncio.gather(*list(controller.tasks.values()))
  514. assert (await batch_service.monthly_callback())['status'] == 'completed'
  515. assert calls == [['1219']]
  516. queued = controller.submit({'dept_ids': ['1219']})
  517. await controller.close()
  518. assert controller.get(queued['batch_id'])['status'] == 'interrupted'
  519. assert not controller.tasks