fleet_query.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302
  1. # -*- coding: utf-8 -*-
  2. """fleet_query.py — 跨场健康查询层 (运维 agent 柱3「智能助手」grounded 底座).
  3. 把已 onboard 各场 outputs/<farm>/sop/findings.json 汇成 fleet 健康视图 + 结构化查询。
  4. NL 前端(LLM)挂本层上 → 助手回答"哪些台是齿箱候选/某场状态/某台所有 finding", 但**事实全来自本层**,
  5. 本层只报 findings 里有的, 未覆盖场明说"未分析"(不幻觉=柱3 相对通用数字员工的分界)。纯函数, 无副作用。
  6. verdict 六枚举 (docs/SOP.md §0.2), rank 高→低: 定论>准定论·预警>候选>参考>INSUFFICIENT>撤回。
  7. system 分类复用 cases.system_of (同一映射, 单源)。
  8. """
  9. try:
  10. from app_common.app_common_guanlan.api import install_root as _install_root
  11. except ImportError: # 理论不可达;包结构异常时回退到按位置上跳
  12. from pathlib import Path as _P
  13. def _install_root(_f): return _P(_f).resolve().parents[4]
  14. import json
  15. from pathlib import Path
  16. from src.sop.cases import system_of
  17. from src.sop.schemas import norm_verdict, VERDICTS
  18. # 2026-07-09 合并: verdict rank + system 匹配 抽共享核 (与 assistant 单源, 见 agent_query_common)
  19. from src.sop.agent_query_common import VERDICT_RANK, rank as _rank, system_family_match # noqa: F401
  20. def load_fleet(outputs_root):
  21. """glob outputs/<farm>/sop/findings.json → {farm: doc}. 坏/缺文件跳过并记 _errors。"""
  22. root = Path(outputs_root)
  23. fleet, errors = {}, []
  24. for fj in sorted(root.glob('*/sop/findings.json')):
  25. farm = fj.parent.parent.name
  26. try:
  27. doc = json.loads(fj.read_text(encoding='utf-8'))
  28. if isinstance(doc, dict) and isinstance(doc.get('findings'), list):
  29. fleet[farm] = doc
  30. else:
  31. errors.append(f'{farm}: findings.json 结构异常')
  32. except (json.JSONDecodeError, OSError) as e:
  33. errors.append(f'{farm}: {type(e).__name__}')
  34. fleet['_errors'] = errors
  35. return fleet
  36. def load_cases(outputs_root):
  37. """glob outputs/<farm>/sop/cases.json → {farm: [cases]}. 供板 surface 深挖层(case.analysis)。"""
  38. root = Path(outputs_root)
  39. out = {}
  40. for cj in sorted(root.glob('*/sop/cases.json')):
  41. farm = cj.parent.parent.name
  42. try:
  43. d = json.loads(cj.read_text(encoding='utf-8'))
  44. if isinstance(d, list):
  45. out[farm] = d
  46. except (json.JSONDecodeError, OSError):
  47. pass
  48. return out
  49. def analysis_priority(cases_fleet, min_verdict='准定论·预警'):
  50. """从 case.analysis(Phase-2 深挖+独立审) 抽 verdict≥min 的优先项 (grounded)。
  51. 深挖层比自动 fleet-z 更权威 → 板优先关注须含, 否则同 assistant 修前盲点(D29 被埋没)。"""
  52. floor = _rank(min_verdict)
  53. out = []
  54. for farm, cases in (cases_fleet or {}).items():
  55. for c in cases or []:
  56. an = c.get('analysis')
  57. if isinstance(an, dict) and _rank(an.get('verdict')) >= floor:
  58. out.append({'farm': farm, 'turbine': c.get('turbine'), 'system': c.get('system'),
  59. 'verdict': norm_verdict(an.get('verdict')), 'mechanism': an.get('mechanism', ''),
  60. 'ref': an.get('ref')})
  61. out.sort(key=lambda r: (-_rank(r['verdict']), r['farm'], str(r['turbine'])))
  62. return out
  63. def flagged(fleet, system=None, min_verdict='候选', farm=None):
  64. """跨场 flagged 台清单 (grounded): 返回 [{farm,turbine,title,system,verdict,problem_nature}].
  65. 只收 affected_machines 有台 且 verdict rank≥min_verdict 的 finding。
  66. system: 规范 system key 过滤 (如 'gearbox'); farm: 限定场; 均可 None。
  67. """
  68. floor = _rank(min_verdict)
  69. out = []
  70. for fm, doc in fleet.items():
  71. if fm == '_errors' or (farm and fm != farm):
  72. continue
  73. for it in doc.get('findings', []):
  74. if not isinstance(it, dict):
  75. continue
  76. if _rank(it.get('verdict')) < floor:
  77. continue
  78. am = it.get('affected_machines')
  79. if isinstance(am, str):
  80. am = [am]
  81. if not am:
  82. continue
  83. sysk = system_of(it)
  84. if system and not system_family_match(sysk, system): # 家族前缀匹配 (共享核, 单源)
  85. continue
  86. for t in am:
  87. out.append({'farm': fm, 'turbine': str(t), 'title': it.get('title'),
  88. 'system': sysk, 'verdict': norm_verdict(it.get('verdict')),
  89. 'problem_nature': it.get('problem_nature')})
  90. out.sort(key=lambda r: (-_rank(r['verdict']), r['farm'], r['turbine']))
  91. return out
  92. def fleet_health(fleet):
  93. """fleet 健康板: 每场 finding/候选计数 + 跨场 system tally + verdict 分布。grounded。"""
  94. farms = {k: v for k, v in fleet.items() if k != '_errors'}
  95. per_farm, sys_tally, verdict_tally = {}, {}, {}
  96. for fm, doc in farms.items():
  97. fs = doc.get('findings', [])
  98. cand = [it for it in fs if isinstance(it, dict) and _rank(it.get('verdict')) >= _rank('候选')
  99. and it.get('affected_machines')]
  100. systems = sorted({system_of(it) for it in cand})
  101. per_farm[fm] = {'n_findings': len(fs), 'n_candidate_findings': len(cand),
  102. 'n_machines': doc.get('_n_machines'), 'systems_flagged': systems}
  103. for it in fs:
  104. if not isinstance(it, dict):
  105. continue
  106. v = norm_verdict(it.get('verdict'))
  107. verdict_tally[v] = verdict_tally.get(v, 0) + 1
  108. if v in VERDICTS and _rank(v) >= _rank('候选') and it.get('affected_machines'):
  109. sysk = system_of(it)
  110. sys_tally[sysk] = sys_tally.get(sysk, 0) + 1
  111. return {'n_farms': len(farms), 'per_farm': per_farm,
  112. 'system_tally': dict(sorted(sys_tally.items(), key=lambda x: -x[1])),
  113. 'verdict_tally': verdict_tally, 'errors': fleet.get('_errors', [])}
  114. def farm_status(fleet, farm):
  115. """单场状态 (honest): 未覆盖场明说, 不返回空当健康。"""
  116. if farm not in fleet or farm == '_errors':
  117. return {'farm': farm, 'covered': False,
  118. 'note': f'{farm} 无 findings.json (未 onboard/分析); 不等于健康, 是无数据'}
  119. doc = fleet[farm]
  120. fs = doc.get('findings', [])
  121. rows = []
  122. for it in fs:
  123. if not isinstance(it, dict):
  124. continue
  125. rows.append({'title': it.get('title'), 'verdict': norm_verdict(it.get('verdict')),
  126. 'system': system_of(it), 'flagged': it.get('affected_machines')})
  127. rows.sort(key=lambda r: -_rank(r['verdict']))
  128. return {'farm': farm, 'covered': True, 'n_machines': doc.get('_n_machines'),
  129. 'n_findings': len(fs), 'findings': rows}
  130. def turbine_status(fleet, farm, turbine):
  131. """单台所有 finding (honest)。"""
  132. if farm not in fleet or farm == '_errors':
  133. return {'farm': farm, 'turbine': turbine, 'covered': False, 'note': f'{farm} 未覆盖'}
  134. hits = []
  135. for it in fleet[farm].get('findings', []):
  136. if not isinstance(it, dict):
  137. continue
  138. am = it.get('affected_machines')
  139. am = [am] if isinstance(am, str) else (am or [])
  140. if str(turbine) in [str(x) for x in am]:
  141. hits.append({'title': it.get('title'), 'verdict': norm_verdict(it.get('verdict')),
  142. 'system': system_of(it), 'recommendation': it.get('recommendation')})
  143. hits.sort(key=lambda r: -_rank(r['verdict']))
  144. return {'farm': farm, 'turbine': turbine, 'covered': True, 'n_flagged': len(hits),
  145. 'findings': hits or [{'note': '该台无 flagged finding (相对健康 or 未被判别器命中)'}]}
  146. # 规范部件 system 前缀 (区分"部件级" vs 场特有描述性 slug)
  147. _CANON_SYS_PREFIX = ('gearbox', 'gen', 'main_brg', 'main_shaft', 'yaw', 'pitch',
  148. 'driveline', 'coupling', 'safety_chain', 'underperf', 'converter')
  149. def _canon_head(v):
  150. """verdict → 规范六枚举头 (复合 '候选·预警(...)'→'候选'; 供 fleet 汇总归一, 保留 honest)。"""
  151. nv = norm_verdict(v)
  152. if nv in VERDICT_RANK:
  153. return nv
  154. head = nv.split('·', 1)[0].split(' ', 1)[0].strip()
  155. return head if head in VERDICT_RANK else nv
  156. def render_board(fleet, cases_fleet=None):
  157. """机群健康总览 markdown (grounded, 可复现; 非手写)。优先关注=准定论·预警及以上。"""
  158. h = fleet_health(fleet)
  159. strong = flagged(fleet, min_verdict='准定论·预警')
  160. # verdict 归一汇总
  161. vt = {}
  162. for v, n in h['verdict_tally'].items():
  163. vt[_canon_head(v)] = vt.get(_canon_head(v), 0) + n
  164. canon_sys = {k: v for k, v in h['system_tally'].items() if k.startswith(_CANON_SYS_PREFIX)}
  165. farm_sys = {k: v for k, v in h['system_tally'].items() if not k.startswith(_CANON_SYS_PREFIX)}
  166. L = ['# 机群健康总览 v0.1 (grounded · 自动生成)',
  167. '> 由 `src/sop/fleet_query.py::render_board` 渲染, 勿手改。数据源 = 各场 '
  168. f'`findings.json` ({h["n_farms"]} 场) + `cases.json` 深挖层(case.analysis)。',
  169. '> ⚠ verdict 纪律: **候选**=fleet-z 相对排名, 待现场; **准定论·预警**=多证但仍 SCADA; '
  170. '**定论/确诊**才可对外交付(RV-1)。**未列场 = 未分析 ≠ 健康**。\n']
  171. # 最强证据拆两类: 部件级(该推现场) vs 场级特征(已确诊分析, 非部件抢修)。verdict≠tier。
  172. comp = [r for r in strong if str(r['system']).startswith(_CANON_SYS_PREFIX)]
  173. charac = [r for r in strong if not str(r['system']).startswith(_CANON_SYS_PREFIX)]
  174. L.append('## 最强证据 finding (定论 / 准定论·预警)')
  175. L.append('> ⚠ **verdict=证据强度 ≠ tier=行动紧迫度**(正交两轴)。定论≠抢修: 场级特征类(限电/KPI/通讯)'
  176. '是**已确诊的分析结论**非部件抢修; 部件级才对应现场动作, 且候选级仍须现场坐实。\n')
  177. # 深挖层(case.analysis Phase-2+独立审) 并入部件级优先: 比自动fleet-z权威, 去重(同键已在comp则跳)
  178. comp_keys = {(r['farm'], str(r['turbine']), r['system']) for r in comp}
  179. ana = [a for a in analysis_priority(cases_fleet)
  180. if str(a['system']).startswith(_CANON_SYS_PREFIX)
  181. and (a['farm'], str(a['turbine']), a['system']) not in comp_keys]
  182. L.append(f'### A. 部件级 ({len(comp) + len(ana)} 项 · 对应现场动作)')
  183. if comp or ana:
  184. L.append('| 场 | 台 | 系统 | verdict | 来源 | 依据 |')
  185. L.append('|---|---|---|---|---|---|')
  186. for r in comp:
  187. L.append(f"| {r['farm']} | {r['turbine']} | {r['system']} | {r['verdict']} | finding | {r['title']} |")
  188. for a in ana:
  189. ref = f" (据 {str(a['ref']).split('/')[-1]})" if a.get('ref') else ''
  190. L.append(f"| {a['farm']} | {a['turbine']} | {a['system']} | {a['verdict']} | **深挖** | {a['mechanism'][:48]}{ref} |")
  191. else:
  192. L.append('_(无部件级达 准定论·预警 · 全fleet 部件类最高仅候选, 见花名册)_')
  193. L.append(f'\n### B. 场级特征分析 ({len(charac)} 项 · 已确诊, 非部件抢修)')
  194. if charac:
  195. seen = set()
  196. for r in charac:
  197. key = (r['farm'], r['title'])
  198. if key in seen:
  199. continue
  200. seen.add(key)
  201. L.append(f"- **{r['farm']}** 【{r['verdict']}】{r['title']}")
  202. L.append(f'\n## 各场候选花名册 ({h["n_farms"]} 场)')
  203. L.append('| 场 | 机数 | 候选finding | 总finding | flagged系统 |')
  204. L.append('|---|--:|--:|--:|---|')
  205. for fm, s in sorted(h['per_farm'].items(), key=lambda x: -x[1]['n_candidate_findings']):
  206. if s['n_candidate_findings']:
  207. L.append(f"| {fm} | {s['n_machines'] or '?'} | {s['n_candidate_findings']} | "
  208. f"{s['n_findings']} | {', '.join(s['systems_flagged'])} |")
  209. L.append('\n## 跨场 system 分布 (候选级 finding 数)')
  210. L.append('**部件级**: ' + (', '.join(f'{k} ×{v}' for k, v in canon_sys.items()) or '无'))
  211. if farm_sys:
  212. L.append('**场特有分析** (描述性, 非标准部件键): '
  213. + ', '.join(f'{k[:24]} ×{v}' for k, v in list(farm_sys.items())[:8]))
  214. L.append('\n## verdict 分布 (全fleet, 归一六枚举)')
  215. L.append(' · '.join(f'{k} {v}' for k, v in sorted(vt.items(), key=lambda x: -_rank(x[0]))))
  216. L.append('\n## 诚实边界')
  217. L.append(f'- {h["n_farms"]} 场已分析入板; 数据盘 45 场 → 余未 onboard (见 `docs/数据盘索引_WINDDATA_DATA2.md`), '
  218. '未列 ≠ 健康。')
  219. L.append(f'- **候选级需现场坐实** (fleet-z 相对排名, 禁当确诊); INSUFFICIENT {vt.get("INSUFFICIENT", 0)} 条 = 判不了+缺什么(诚实出口)。')
  220. if h['errors']:
  221. L.append(f'- ⚠ 载入异常: {h["errors"]}')
  222. return '\n'.join(L)
  223. # ---------------------------------------------------------------------------
  224. if __name__ == '__main__':
  225. # 控制台可能是 GBK(中文 Windows 代码页 936): 正文里的 ✔ ✗ ✅ ⚠ 这类字符编不出来会抛
  226. # UnicodeEncodeError, 脚本干成了事却以退出码 1 结束(同类坑见 src/console.py)。降级为 '?' 而不是崩;
  227. # 不用 import 是为了兼顾 python -m 与直接当脚本跑两种启动方式。
  228. import sys as _sys
  229. for _s in (_sys.stdout, _sys.stderr):
  230. try: _s.reconfigure(errors='replace')
  231. except Exception: pass
  232. import argparse
  233. ROOT = _install_root(__file__)
  234. ap = argparse.ArgumentParser(description='跨场健康查询 (柱3 grounded 底座)')
  235. ap.add_argument('--summary', action='store_true', help='fleet 健康板 (终端)')
  236. ap.add_argument('--board', action='store_true', help='渲染机群健康总览 md → outputs/_fleet/健康总览_v0.1.md')
  237. ap.add_argument('--system', help='按 system 过滤 flagged 台 (如 gearbox/gen_de_brg/yaw)')
  238. ap.add_argument('--farm', help='单场状态')
  239. ap.add_argument('--turbine', help='单台 (配 --farm)')
  240. ap.add_argument('--min-verdict', default='候选')
  241. a = ap.parse_args()
  242. fleet = load_fleet(ROOT / 'outputs')
  243. if a.board:
  244. out = ROOT / 'outputs' / '_fleet' / '健康总览_v0.1.md'
  245. out.parent.mkdir(parents=True, exist_ok=True)
  246. out.write_text(render_board(fleet, load_cases(ROOT / 'outputs')), encoding='utf-8')
  247. print(f'✅ 机群健康总览 → {out.relative_to(ROOT)}')
  248. elif a.turbine and a.farm:
  249. r = turbine_status(fleet, a.farm, a.turbine)
  250. print(json.dumps(r, ensure_ascii=False, indent=2))
  251. elif a.farm:
  252. print(json.dumps(farm_status(fleet, a.farm), ensure_ascii=False, indent=2))
  253. elif a.system or a.min_verdict != '候选':
  254. rows = flagged(fleet, system=a.system, min_verdict=a.min_verdict)
  255. print(f'flagged 台 {len(rows)} (system={a.system}, ≥{a.min_verdict}):')
  256. for r in rows:
  257. print(f" [{r['verdict']}] {r['farm']}/{r['turbine']} · {r['system']} · {r['title']}")
  258. else:
  259. h = fleet_health(fleet)
  260. print(f"=== Fleet 健康板 ({h['n_farms']} 场) ===")
  261. print(f"跨场 system 候选 tally: {h['system_tally']}")
  262. print(f"verdict 分布: {h['verdict_tally']}")
  263. print("每场候选 finding 数 (有候选的场):")
  264. for fm, s in sorted(h['per_farm'].items(), key=lambda x: -x[1]['n_candidate_findings']):
  265. if s['n_candidate_findings']:
  266. print(f" {fm:20s} {s['n_candidate_findings']:2d}候选/{s['n_findings']}总 · {s['systems_flagged']}")
  267. if h['errors']:
  268. print(f"⚠ 坏/缺: {h['errors']}")