service.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  1. """工作台测试服务:选择输入、归档请求、复用业务队列、预览真实输出。"""
  2. from copy import deepcopy
  3. import hashlib
  4. import json
  5. from pathlib import Path
  6. import shutil
  7. import threading
  8. import uuid
  9. from agents.workspace import session_root
  10. from db import business_store, chat_store, db_conn, test_lab_store
  11. from extensions.registry import load_extensions
  12. from runtime.business_tasks import get_controller, submit
  13. from runtime.test_lab import files
  14. from runtime.test_lab.presets import PRESETS, main_fan_input, preset
  15. from runtime.test_lab.spreadsheets import result_tables, write_workbook
  16. from runtime.test_lab.main_fan_simulation import MainFanSimulation, build_input
  17. _submit_lock=threading.Lock()
  18. def catalog(user):
  19. agents=[]
  20. for spec in load_extensions().business_agents.values():
  21. agents.append({'name':spec.name,'display_name':spec.display_name,'pipeline_available':spec.test_runner is not None,
  22. 'presets':list(PRESETS) if spec.name=='main_fan' else []})
  23. return {'agents':agents,'assets':files.list_assets(user),
  24. 'main_fan_simulation':MainFanSimulation().model_dump(mode='json')}
  25. def get_preset(agent, identifier):
  26. load_extensions().business(agent)
  27. if agent!='main_fan':
  28. raise ValueError('该智能体还没有内置样例,可通过请求JSON输入测试数据')
  29. return {**preset(identifier),'input':main_fan_input(identifier)}
  30. def _merge_fragment(data, fragment):
  31. for kind, rows in fragment.items():
  32. if not rows:
  33. continue
  34. if kind=='reports':
  35. data[kind]=rows
  36. elif kind=='fan_state_records':
  37. ids={r['fan_id'] for r in rows}
  38. data[kind]=[r for r in data.get(kind,[]) if r['fan_id'] not in ids]+rows
  39. elif kind=='operating_series':
  40. keys={(r['fan_id'],r['metric']) for r in rows}
  41. data[kind]=[r for r in data.get(kind,[]) if (r['fan_id'],r['metric']) not in keys]+rows
  42. elif kind=='devices':
  43. ids={r['device_code'] for r in rows}
  44. data[kind]=[r for r in data.get(kind,[]) if r['device_code'] not in ids]+rows
  45. else:
  46. data[kind]=[*data.get(kind,[]),*rows]
  47. def start_run(request, user):
  48. with _submit_lock:
  49. return _start_run(request,user)
  50. def _start_run(request,user):
  51. get_controller()
  52. spec=load_extensions().business(request.agent_name)
  53. simulation=request.main_fan_simulation
  54. if simulation is not None:
  55. if spec.name!='main_fan' or request.execution_mode!='agent':
  56. raise ValueError('三项输入测试必须使用主通风机完整智能体')
  57. if request.input or request.input_asset_id:
  58. raise ValueError('三项输入测试仅使用所选PDF及本次运行时序,请勿混入其他请求数据')
  59. if request.execution_mode=='pipeline' and spec.test_runner is None:
  60. raise ValueError('该智能体暂不支持业务流程模式,请选择完整智能体模式')
  61. source='test_pipeline' if request.execution_mode=='pipeline' else 'test_agent'
  62. if request.request_id:
  63. existing=db_conn.query_one('SELECT * FROM business_jobs WHERE id=?',
  64. (hashlib.sha256(f'{spec.name}\0{user}\0{source}\0{request.request_id}'.encode()).hexdigest()[:32],))
  65. if existing:
  66. previous=test_lab_store.run(existing['id'],user)
  67. snapshot=json.loads(previous['request_json'])
  68. if snapshot['submitted']!=request.model_dump(mode='json'):
  69. raise ValueError('同一请求标识已用于不同的测试参数')
  70. return public_run(existing['id'],user)
  71. data=build_input(simulation) if simulation is not None else deepcopy(request.input)
  72. if request.input_asset_id:
  73. input_path,_=files.owned_file(request.input_asset_id,user)
  74. if input_path.suffix.lower()!='.json':
  75. raise ValueError('请求数据来源必须是JSON文件')
  76. if data.get('materials'):
  77. raise ValueError('测试附件请从资料库选择,不要在请求JSON中手写文件路径')
  78. selected=[]
  79. slot_ids={key:getattr(simulation,key) for key in ('switch_asset_id','flow_asset_id')} if simulation else {}
  80. if simulation and len(set([*request.asset_ids,*filter(None,slot_ids.values())])) != len(request.asset_ids)+sum(bool(v) for v in slot_ids.values()):
  81. raise ValueError('每份测试资料只能选择一次,请分别选择报告、开停和风量文件')
  82. for identifier in [*request.asset_ids,*filter(None,slot_ids.values())]:
  83. path,description=files.owned_file(identifier,user)
  84. if path.suffix.lower() not in files.DOCUMENTS|{'.json'}:
  85. raise ValueError('当前主通风机仅接收PDF、图片和JSON作为研判输入;Excel文件可在线预览')
  86. description['sha256']=hashlib.sha256(path.read_bytes()).hexdigest()
  87. if simulation:
  88. slot=next((key for key,value in slot_ids.items() if value==identifier),'report')
  89. if slot=='report' and path.suffix.lower()!='.pdf':
  90. raise ValueError('测定报告输入请选择PDF文件')
  91. if slot!='report' and path.suffix.lower()!='.json':
  92. raise ValueError('开停曲线和风量数据上传文件必须为JSON')
  93. description['input_slot']=slot
  94. selected.append((path,description))
  95. if len({description['sha256'] for _,description in selected})!=len(selected):
  96. raise ValueError('所选资料中存在内容相同的文件,请只保留一份')
  97. if data.get('use_gateway') and selected:
  98. raise ValueError('网关取数样例会自行取得文件,请取消手动选择的资料,或改用PDF资料研判')
  99. combined={}
  100. for path,description in selected:
  101. if path.suffix.lower()=='.json':
  102. raw=files.json_value(path)
  103. try:
  104. fragment=files.series_fragment(raw,'test-lab://assets/'+description['id'])
  105. except (KeyError,TypeError) as exc:
  106. raise ValueError('运行时序JSON缺少明确的设备、时间或单位字段') from exc
  107. if fragment is None:
  108. allowed={'reports','devices','points','operating_series','fan_state_records','switch_records','confirmed_bindings'}
  109. if not raw or set(raw)-allowed or not all(isinstance(v,list) for v in raw.values()):
  110. raise ValueError('完整请求JSON请先点击“用作请求”;也可直接选择开停、风量或业务事实片段JSON')
  111. fragment=raw
  112. if simulation:
  113. required='fan_state_records' if description['input_slot']=='switch_asset_id' else 'operating_series'
  114. allowed={required,'devices'} if required=='fan_state_records' else {required}
  115. if not fragment.get(required) or any(rows and key not in allowed for key,rows in fragment.items()):
  116. raise ValueError('开停与风量文件须分别提供对应的时序,不能混入其他资料')
  117. expected_ids=set(data['fan_pair'])
  118. rows=fragment[required]
  119. if {row.get('fan_id') for row in rows}!=expected_ids or any(
  120. row.get('mine_code')!=data['mine_code'] or row.get('mine_name')!=data['mine_name'] for row in rows):
  121. raise ValueError('上传时序必须明确对应本次矿井及1号、2号通风机')
  122. if required=='operating_series' and (len(rows)!=2 or any(row.get('metric')!='flow' for row in rows)):
  123. raise ValueError('风量文件须分别提供两台风机各一条风量时序')
  124. # 上传数据完整替换该输入槽,不能与参数生成的另一半序列拼接。
  125. data[required]=[]
  126. for kind,rows in fragment.items():
  127. combined.setdefault(kind,[]).extend(rows)
  128. _merge_fragment(data,combined)
  129. document_count=sum(path.suffix.lower() in files.DOCUMENTS for path,_ in selected)
  130. if simulation and not document_count:
  131. raise ValueError('请先选择或上传主备风机测定报告PDF')
  132. if request.scenario_id=='documents' and not document_count:
  133. raise ValueError('请至少选择一份PDF或图片资料')
  134. if document_count and any(r.get('evidence',{}).get('source','').startswith('test-lab://') for r in data.get('reports',[])):
  135. raise ValueError('使用PDF原件时请选择“PDF资料研判”,避免合成报告参数与原件混用')
  136. # 文件复制和提交在新会话下完成,任务只看到本次明确选择的资料。
  137. spec.input_schema.model_validate(data)
  138. session=chat_store.create_session(user,'full','智能体测试 · '+request.label[:100])
  139. business_store.bind_session(session['id'],spec.name)
  140. root=session_root(user,session['id'])
  141. attached=[]
  142. for path,description in selected:
  143. if path.suffix.lower() not in files.DOCUMENTS:
  144. continue
  145. destination=root/'workspace/uploads'/f"{description['id']}_{path.name}"
  146. destination.parent.mkdir(parents=True,exist_ok=True)
  147. shutil.copyfile(path,destination)
  148. digest=hashlib.sha256(destination.read_bytes()).hexdigest()
  149. attached.append({'path':'/workspace/uploads/'+destination.name,'filename':path.name,'sha256':digest})
  150. if attached:
  151. data['materials']=attached
  152. with db_conn.transaction():
  153. job=submit(spec.name,data,user_name=user,request_id=request.request_id,source=source,publish=False)
  154. business_store.update(job['id'],session_id=session['id'])
  155. snapshot={'submitted':request.model_dump(mode='json'),'input':data,
  156. 'assets':[description for _,description in selected],
  157. 'input_asset_id':request.input_asset_id,'publish':False}
  158. if simulation:
  159. # 每次是新的会话/文件根;仅归档本次原始输入,agent不会挂载历史workspace。
  160. input_dir=root/'workspace/test_inputs'
  161. input_dir.mkdir(parents=True,exist_ok=True)
  162. for name,key in (('主备风机开停曲线.json','fan_state_records'),('主备风机风量数据.json','operating_series')):
  163. (input_dir/name).write_text(json.dumps({key:data[key]},ensure_ascii=False,indent=2),encoding='utf-8')
  164. snapshot['isolation']={'fresh_session':True,'fresh_workspace':True,
  165. 'document_cache_policy':'off','history_reused':False}
  166. if request.input_asset_id:
  167. _,description=files.owned_file(request.input_asset_id,user)
  168. snapshot['input_asset']=description
  169. test_lab_store.save_run(job,request.label,request.scenario_id,request.execution_mode,snapshot)
  170. return public_run(job['id'],user)
  171. def _result(job):
  172. return json.loads(job['result_json']) if job.get('result_json') else None
  173. def artifact_files(job):
  174. result=_result(job)
  175. if not result:
  176. return []
  177. root=session_root(job['user_name'],job['session_id']).resolve()
  178. artifacts=[]
  179. for item in result.get('artifacts',[]):
  180. virtual=item.get('path','')
  181. if not virtual.startswith('/workspace/'):
  182. continue
  183. path=(root/virtual.lstrip('/')).resolve()
  184. if not path.is_relative_to(root/'workspace') or not path.is_file() or path.suffix.lower() not in {'.xlsx','.csv'}:
  185. continue
  186. key='returned-'+hashlib.sha256(virtual.encode()).hexdigest()[:24]
  187. artifacts.append({'id':key,'filename':item.get('filename') or path.name,'origin':'returned','kind':'excel','path':path})
  188. artifacts.append({'id':'result_excel','filename':'研判结果.xlsx','origin':'generated','kind':'excel'})
  189. return artifacts
  190. def artifact(identifier, key, user):
  191. test_lab_store.run(identifier,user)
  192. job=business_store.get(identifier)
  193. match=next((a for a in artifact_files(job) if a['id']==key),None)
  194. if match is None:
  195. raise ValueError('结果文件不存在')
  196. if key=='result_excel':
  197. path=session_root(user,job['session_id'])/'workspace/test_exports'/identifier/'研判结果.xlsx'
  198. if not path.is_file():
  199. path.parent.mkdir(parents=True,exist_ok=True)
  200. temporary=path.with_name(uuid.uuid4().hex+'.xlsx')
  201. try:
  202. write_workbook(temporary,result_tables(_result(job)))
  203. temporary.replace(path)
  204. finally:
  205. temporary.unlink(missing_ok=True)
  206. match['path']=path
  207. return match
  208. def public_run(identifier,user):
  209. record=test_lab_store.run(identifier,user)
  210. job=business_store.get(identifier)
  211. result=_result(job)
  212. workflow=None
  213. if job.get('session_id'):
  214. workflow=db_conn.query_one('SELECT id,stages_json FROM main_fan_workflows WHERE session_id=? AND user_name=? ORDER BY created_at DESC LIMIT 1', (job['session_id'],user))
  215. artifacts=[]
  216. for item in artifact_files(job):
  217. artifacts.append({**{k:v for k,v in item.items() if k!='path'},
  218. 'url':f"/api/test-lab/runs/{identifier}/artifacts/{item['id']}/content",
  219. 'preview_url':f"/api/test-lab/runs/{identifier}/artifacts/{item['id']}/preview"})
  220. return {'id':identifier,'label':record['label'],'agent_name':record['agent_name'],
  221. 'scenario_id':record['scenario_id'],'execution_mode':record['execution_mode'],
  222. 'status':job['status'],'error':job['error'],'session_id':job['session_id'],
  223. 'created_at':record['created_at'],'finished_at':job['finished_at'],
  224. 'request':json.loads(record['request_json']),'result':result,'artifacts':artifacts,
  225. 'stages':[{'name':s['name'],'status':s['status']} for s in json.loads(workflow['stages_json'])] if workflow else []}