service.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  1. """取数、文档解析、确定性核验和结果落库;模型只负责业务编排。"""
  2. import asyncio
  3. import hashlib
  4. import json
  5. from pathlib import Path
  6. import re
  7. from subprocess import SubprocessError
  8. import config
  9. from agents.workspace import session_root
  10. from algorithms.main_fan.analysis import screen, same_mine
  11. from algorithms.main_fan.models import ScreeningInput, Screening, DocumentFacts, Finding, Device, MonitorPoint, OperatingSeries, SwitchRecord, FanStateRecord
  12. from db import main_fan_store, business_store, db_conn
  13. from extensions import get_service
  14. from extensions.registry import load_extensions
  15. from extensions.main_fan import data_source
  16. from extensions.main_fan.documents import read_document, fingerprint
  17. from extensions.main_fan.reporting import render_result
  18. from extensions.main_fan.schemas import MainFanInput, MainFanResult
  19. from extensions.main_fan.pipeline import load_rules, new_stages, run_pipeline
  20. from algorithms.main_fan.equivalence import normalize_and_validate_fan_data
  21. from runtime.business_models import Clue, RemoteFile
  22. from extensions.main_fan.result_output import build_payload
  23. def _save_result(value, data, analysis, user_name, session_id, job_id=None):
  24. binding = load_extensions().business("main_fan").result_binding
  25. value["platform_payload"] = build_payload(value, data, analysis, binding,
  26. task_id=job_id or db_conn.new_id())
  27. with db_conn.transaction():
  28. saved = main_fan_store.save_analysis(user_name, session_id, value)
  29. result = MainFanResult.model_validate(saved).model_dump(mode="json")
  30. if (not job_id and result["status"] == "completed"
  31. and config.get_bool("RESULT_PLATFORM_ENABLED", False)
  32. and config.get_bool("MAIN_FAN_AUTO_PUBLISH", True)):
  33. business_store.record_completed_result("main_fan", user_name, session_id,
  34. data.model_dump(mode="json"), result, config.get_str("RESULT_PLATFORM_URL"))
  35. return result
  36. def workspace_file(root, virtual):
  37. if not isinstance(virtual, str) or not virtual.startswith("/workspace/"):
  38. raise ValueError("附件必须位于本会话 /workspace/ 中")
  39. local = (root / virtual.lstrip("/")).resolve()
  40. if not local.is_relative_to((root / "workspace").resolve()) or not local.is_file():
  41. raise ValueError("附件不存在或越出当前会话")
  42. return local
  43. async def parse_fan_evidence_documents(request, user_name, session_id, *, gateway=None, document_reader=None, job_id=None,
  44. document_audit_dir=None, document_progress=None):
  45. root = session_root(user_name, session_id)
  46. warnings, file_paths, sources, fingerprints = [], [], [], []
  47. if request.use_gateway:
  48. gateway = gateway or get_service("shaanxi_gateway")
  49. spec = load_extensions().business("main_fan")
  50. incoming = await data_source.fetch(gateway, spec.gateway.agent_code, request.mine_code)
  51. # 不静默覆盖显式输入,同编码冲突会在算法中被识别。
  52. request.devices.extend(Device.model_validate(r) for r in incoming["devices"])
  53. request.points.extend(MonitorPoint.model_validate(r) for r in incoming["points"])
  54. for kind in ('operating_series','switch_records','fan_state_records'):
  55. schema={'operating_series':OperatingSeries,'switch_records':SwitchRecord,'fan_state_records':FanStateRecord}[kind]
  56. getattr(request,kind).extend(schema.model_validate(r) for r in incoming.get(kind,[]))
  57. existing_ids = {m.get("file_id") for m in request.materials}
  58. for raw in incoming["files"]:
  59. source = RemoteFile.model_validate(raw)
  60. if source.file_id in existing_ids:
  61. continue
  62. filename = re.sub(r"[^\w.\-\u4e00-\u9fff]", "_", Path(source.filename).name)
  63. key = hashlib.sha256(f"{source.file_id}\0{source.version}".encode()).hexdigest()[:12]
  64. target = root / "workspace" / "uploads" / f"{key}_{filename}"
  65. metadata = await gateway.download(spec.gateway.agent_code, source, target)
  66. if job_id:
  67. await asyncio.to_thread(business_store.record_file, job_id, metadata)
  68. request.materials.append({"file_id": source.file_id, "path": f"/workspace/uploads/{target.name}",
  69. "filename": source.filename, "sha256": metadata["sha256"]})
  70. if request.measurements:
  71. warnings.append("旧 measurements 字段未明确设备、单位及来源,未猜测其含义;请使用 points。")
  72. # 先重验字典输入;上方扩展来自已验证网关 schema。
  73. core = request.model_dump(mode="json")
  74. facts = DocumentFacts()
  75. document_audits = []
  76. for material in request.materials:
  77. virtual = material.get("path", "")
  78. local = workspace_file(root, virtual)
  79. fingerprints.append({"path":virtual,"sha256":await asyncio.to_thread(fingerprint,local)})
  80. file_paths.append(virtual)
  81. try:
  82. parsed = (await document_reader(local, virtual) if document_reader else
  83. await read_document(local, virtual, user_name=user_name, session_id=session_id,
  84. cache_policy=request.document_cache_policy,
  85. audit_dir=Path(document_audit_dir)/hashlib.sha256(virtual.encode()).hexdigest()[:12] if document_audit_dir else None,
  86. progress=document_progress))
  87. facts.reports.extend(parsed.reports)
  88. facts.devices.extend(parsed.devices)
  89. facts.points.extend(parsed.points)
  90. warnings.extend(parsed.warnings)
  91. if parsed.extraction_audit:
  92. document_audits.append(parsed.extraction_audit)
  93. if any(same_mine(item,request) for item in [*parsed.reports,*parsed.devices,*parsed.points]):
  94. sources.append({"title":material.get("filename") or Path(virtual).name,"url":virtual,"type":"file","type_cn":"文件"})
  95. except (ValueError, SubprocessError) as exc:
  96. detail = str(exc)[:160] if isinstance(exc, ValueError) else type(exc).__name__
  97. warnings.append(f"附件 {Path(virtual).name} 未完成提取:{detail}")
  98. core["reports"].extend(r.model_dump(mode="json") for r in facts.reports)
  99. core["devices"].extend(r.model_dump(mode="json") for r in facts.devices)
  100. core["points"].extend(r.model_dump(mode="json") for r in facts.points)
  101. # 时序证据也进入来源栏;合成数据的标签由模拟网关显式提供。
  102. for item in [*request.operating_series,*request.fan_state_records,*request.switch_records]:
  103. evidence = item.evidence
  104. if same_mine(item,request) and evidence.source.startswith(('http://','https://')) and not any(s['url'] == evidence.source for s in sources):
  105. sources.append({'title':evidence.label or ('运行时序模拟数据' if evidence.method == 'synthetic' else '运行监测数据'),
  106. 'url':evidence.source,'type':'api','type_cn':'模拟监测数据' if evidence.method == 'synthetic' else '监测数据'})
  107. allowed = {key: value for key, value in core.items() if key in ScreeningInput.model_fields}
  108. screening_input = ScreeningInput.model_validate(allowed)
  109. return screening_input,warnings,file_paths,sources,fingerprints,document_audits
  110. def _screen(data):
  111. return screen(data,
  112. freshness_seconds=config.get_int("MAIN_FAN_FRESHNESS_SECONDS", 600),
  113. sync_seconds=config.get_int("MAIN_FAN_SYNC_SECONDS", 120))
  114. async def analyze(data, user_name, session_id, *, gateway=None, document_reader=None, job_id=None,
  115. document_audit_dir=None, document_progress=None):
  116. from extensions.main_fan.targets import load_catalog
  117. request=MainFanInput.model_validate(data)
  118. stages=new_stages()
  119. workflow_id=await asyncio.to_thread(main_fan_store.create_workflow,user_name,session_id,
  120. {"request":request.model_dump(mode="json")},stages) if request.paired_assessment else ""
  121. try:
  122. if workflow_id:
  123. stages[0]['status']='running'
  124. await asyncio.to_thread(main_fan_store.update_workflow,workflow_id,stages=stages)
  125. core,warnings,file_paths,sources,fingerprints,document_audits=await parse_fan_evidence_documents(request,user_name,session_id,
  126. gateway=gateway,document_reader=document_reader,job_id=job_id,document_audit_dir=document_audit_dir,document_progress=document_progress)
  127. except Exception as exc:
  128. if workflow_id:
  129. stages[0].update(status='failed',error=str(exc)[:200])
  130. await asyncio.to_thread(main_fan_store.update_workflow,workflow_id,stages=stages,status='failed')
  131. raise
  132. if request.paired_assessment:
  133. policy=load_rules()
  134. names={t.fan_id:t.fan_name for t in load_catalog().targets if t.mine_code==core.mine_code and t.mine_name==core.mine_name}
  135. dataset=normalize_and_validate_fan_data(core,policy,names)
  136. screens=[]
  137. for fan in dataset.fans:
  138. selected=core.model_copy(deep=True,update={"fan_id":fan['fan_id'],"fan_name":fan['fan_name'],
  139. "reports":[fan['report']] if fan['report'] else []})
  140. screens.append(_screen(selected))
  141. if not screens:
  142. screens=[_screen(core)]
  143. analysis=Screening(engineering_status='review_required' if any(s.has_in_scope_data for s in screens) else 'insufficient_data',
  144. has_in_scope_data=any(s.has_in_scope_data for s in screens),findings=[f for s in screens for f in s.findings],
  145. associations=[a for s in screens for a in s.associations],calculations=[c for s in screens for c in s.calculations],
  146. missing_materials=list(dict.fromkeys(m for s in screens for m in s.missing_materials)))
  147. else:
  148. analysis=_screen(core)
  149. analysis.document_audits = document_audits
  150. analysis.findings.extend(Finding(code="document_extraction", severity="review", message=value) for value in warnings)
  151. if warnings:
  152. analysis.missing_materials.append("核验文档识别结果及原件")
  153. assessment=None
  154. if request.paired_assessment:
  155. context={"data":core.model_dump(mode="json"),"analysis":analysis.model_dump(mode="json"),"fan_names":names,
  156. "policy":policy.model_dump(mode="json"),"sources":sources,"fingerprints":fingerprints,"document_audits":document_audits}
  157. await asyncio.to_thread(main_fan_store.update_workflow,workflow_id,context=context)
  158. def save_progress(state):
  159. main_fan_store.update_workflow(workflow_id,stages=state,status='failed' if any(s['status']=='failed' for s in state) else 'running')
  160. assessment,body,stages=await asyncio.to_thread(run_pipeline,context,stages=stages,save=save_progress)
  161. else:
  162. body=render_result(core,analysis)
  163. if not analysis.has_in_scope_data and any('未完成提取' in warning for warning in warnings):
  164. body='原件识别未完成,请恢复文档识别服务后重新执行本次研判。'
  165. # 先持久化原件再用结果 schema 校验;模型无法通过自报 completed 绕过这一步。
  166. value = dict(status="completed" if analysis.has_in_scope_data else "needs_materials",
  167. mine_code=request.mine_code, mine_name=request.mine_name,
  168. check_object="、".join(f['fan_name'] for f in assessment['fan_pair']) if assessment and assessment['fan_pair'] else f"矿井主通风机 {request.fan_name or request.fan_id}".strip(),
  169. result_title=f"{request.mine_name} 主通风机与备用风机一致性研判报告" if request.paired_assessment else f"{request.mine_name} {request.fan_name or request.fan_id}研判报告",
  170. result=body, result_format="report", sources=sources,assessment=assessment,workflow_id=workflow_id,
  171. check_process="文档与运行数据接入 → 双机身份及单位核验 → 报告参数比较 → 性能数据比较 → 切换稳定工况分析 → 规则汇总 → 报告归档" if request.paired_assessment else "单机资料核验与公式计算",
  172. alarm_level=None, missing_materials=[] if request.paired_assessment else analysis.missing_materials,
  173. clues=[Clue(clueType="text", clueName=f.code, clueContent=f.message).model_dump(by_alias=True) for f in analysis.findings]
  174. + [Clue(clueType="table", clueName="测点关联与公式复核",
  175. clueContent=json.dumps({"associations": [a.model_dump() for a in analysis.associations],
  176. "calculations": [c.model_dump() for c in analysis.calculations]}, ensure_ascii=False)).model_dump(by_alias=True)]
  177. + [Clue(clueType="file", clueName=Path(p).name, clueContent=p).model_dump(by_alias=True)
  178. for p in dict.fromkeys([s['url'] for s in sources] if request.paired_assessment else file_paths)],
  179. judgment_scope="main_fan_consistency" if request.paired_assessment else "preliminary_screening", analysis=analysis.model_dump(mode="json"))
  180. result = await asyncio.to_thread(_save_result, value, core, analysis, user_name, session_id, job_id)
  181. if workflow_id:
  182. await asyncio.to_thread(main_fan_store.update_workflow,workflow_id,stages=stages,result=result,status='completed')
  183. return result
  184. async def resume_assessment(workflow_id, start_stage, user_name, session_id, *, job_id=None):
  185. workflow=await asyncio.to_thread(main_fan_store.workflow,workflow_id,user_name,session_id)
  186. context=workflow['context']
  187. if 'data' not in context:
  188. raise ValueError("资料准备未完成,请重新执行原始研判请求")
  189. root=session_root(user_name,session_id)
  190. for record in context.get('fingerprints',[]):
  191. if await asyncio.to_thread(fingerprint,workspace_file(root,record['path'])) != record['sha256']:
  192. raise ValueError("原文件已变化,请重新获取和识别资料,不能沿用旧阶段")
  193. def save_progress(stages):
  194. main_fan_store.update_workflow(workflow_id,stages=stages,status='failed' if any(s['status']=='failed' for s in stages) else 'running')
  195. assessment,body,stages=await asyncio.to_thread(run_pipeline,context,stages=workflow['stages'],start_stage=start_stage,save=save_progress)
  196. previous=workflow['result'] or {}
  197. value={**previous,'status':'completed' if context['analysis']['has_in_scope_data'] else 'needs_materials',
  198. 'mine_code':context['data']['mine_code'],'mine_name':context['data']['mine_name'],
  199. 'check_object':'、'.join(f['fan_name'] for f in assessment['fan_pair']) or '矿井主通风机',
  200. 'result_title':context['data']['mine_name']+' 主通风机与备用风机一致性研判报告',
  201. 'result_format':'report','result':body,'assessment':assessment,'workflow_id':workflow_id,
  202. 'analysis':context['analysis'],'sources':context['sources'],'missing_materials':[]}
  203. value.pop('analysis_id',None)
  204. data=ScreeningInput.model_validate(context['data'])
  205. analysis=Screening.model_validate(context['analysis'])
  206. result=await asyncio.to_thread(_save_result,value,data,analysis,user_name,session_id,job_id)
  207. await asyncio.to_thread(main_fan_store.update_workflow,workflow_id,stages=stages,result=result,status='completed')
  208. return result
  209. def validate_result(value, user_name, session_id):
  210. canonical = main_fan_store.canonical_result(value, user_name, session_id)
  211. return MainFanResult.model_validate(canonical).model_dump(mode="json")