mcp_server.py 57 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941
  1. # -*- coding: utf-8 -*-
  2. """ontology MCP 服务 (P2: 本体经 MCP 暴露给 dsh; stdio 协议壳照 windscada/mcp_server).
  3. 分工铁律 (用户裁): 本体查询/约束/动作 = 结构化工具 (本文件), 模型只做语义编排.
  4. 全部工具只读 (L1) — 模型零写权; 判级零生成 — 只转述对象里的 verdict 原文与审级;
  5. 最终回答必过 claim_check 接地闸 (台号/数字越界即拒).
  6. 用法: python -m src.ontology.mcp_server"""
  7. try:
  8. from app_common.app_common_guanlan.api import install_root as _install_root
  9. except ImportError: # 理论不可达;包结构异常时回退到按位置上跳
  10. from pathlib import Path as _P
  11. def _install_root(_f): return _P(_f).resolve().parents[4]
  12. from src import paths as P
  13. import sys, json, argparse, traceback, re
  14. from pathlib import Path
  15. sys.path.insert(0, str(_install_root(__file__)))
  16. from src.windscada.audit import grounding # noqa: E402
  17. PROTOCOL = '2025-06-18'
  18. OBJ_PATH = P.objects_json()
  19. FACTS = []
  20. FACTS_MAX = 200 # F11: 原无界 — 长会话下 FACTS 无限增长, 接地闸比对成本与内存同步涨
  21. _DB = None
  22. _DB_MTIME = None
  23. def db():
  24. """读本体库. F11 修 (Critic 逮): 原缓存只认 None, populate/kb_ingest 重写库后进程内仍读旧快照
  25. (判级已变而工具还在答旧值 = 静默 stale). 改按文件 mtime 失效重载."""
  26. global _DB, _DB_MTIME
  27. if not OBJ_PATH.exists():
  28. raise FileNotFoundError(f'本体库缺失: {OBJ_PATH} (先跑 populate) — 不可静默降级')
  29. mt = OBJ_PATH.stat().st_mtime
  30. if _DB is None or _DB_MTIME != mt:
  31. _DB = json.loads(OBJ_PATH.read_text(encoding='utf-8'))
  32. _DB_MTIME = mt
  33. return _DB
  34. def _fact(txt):
  35. """记事实并封顶 (F11): 超上限丢最旧, 保留最近的取证."""
  36. FACTS.append(txt)
  37. if len(FACTS) > FACTS_MAX:
  38. del FACTS[:len(FACTS) - FACTS_MAX]
  39. def _brief(o, with_props=False):
  40. b = dict(id=o['id'], type=o['type'])
  41. p = o.get('props', {})
  42. for k in ('name', 'level', 'system', 'component', 'review_tier', 'claim_window', 'status'):
  43. if k in p:
  44. b[k] = p[k]
  45. if with_props:
  46. b['props'] = p
  47. b['links'] = o.get('links', {})
  48. return b
  49. # ── 工具实现 (纯函数, 判断在本体, 本文件只查) ──
  50. def _tid(turbine):
  51. """台号解析容错 (2026-08-28 实测): 模型问"全场损失"时会传 turbine='全场',
  52. 原式 int('全场') 直接抛异常 → 工具返回"工具错误", 该轮取证白跑。
  53. 非数字一律当"不筛台", 而不是让工具崩掉。"""
  54. n = re.sub(r'\D', '', str(turbine or ''))
  55. return f'WTG{int(n):02d}' if n else ''
  56. def ont_stats():
  57. from collections import Counter
  58. d = db()
  59. cnt = dict(Counter(v['type'] for v in d.values()))
  60. return dict(text=(f"本体库 {len(d)} 对象: {cnt}。判读纪律(写入闸, 已在库保证): "
  61. "C1证据必带claim_window+观测面 / C2判级必带审级 / C3独立源数按观测面去重禁按条数 / "
  62. "C4判读三分(支持|排除|不可判, 不可判≠阴性) / C5状态措辞必带窗 / C6动作必带可逆级。"
  63. "回答纪律: 判级只转述对象原文并注明对象id与审级; 证据窗外该证据不背书; 未列≠正常。"), data=cnt)
  64. def _turbine_scope(d, tid):
  65. """台的语义邻域 (F5: 通过 links 真实游走, 不硬编码台号).
  66. turbine → Component(of) → Mechanism(affects 挂台或挂部件) → Verdict(about 挂台/部件/机制)
  67. → Decision/Action (basedOn/resolves/acceptedBy/informs 链入邻域, 层内传播至收敛).
  68. 修前只按 about∋turbine 收 Verdict, 挂在机制上的关键判级 (如 verdict/WTG29/TCM-16) 游走不到."""
  69. comps = {k for k, o in d.items() if o['type'] == 'Component'
  70. and tid in o.get('links', {}).get('of', [])}
  71. anchors = {tid} | comps
  72. mechs = {k for k, o in d.items() if o['type'] == 'FailureMechanism'
  73. and anchors & set(o.get('links', {}).get('affects', []))}
  74. verds = {k for k, o in d.items() if o['type'] == 'Verdict'
  75. and (anchors | mechs) & set(o.get('links', {}).get('about', []))}
  76. core = anchors | mechs | verds
  77. scen = set()
  78. for _ in range(4): # decision→scenario→decision 链, 层内传播至收敛
  79. grew = False
  80. for k, o in d.items():
  81. if k in scen or o['type'] not in ('Decision', 'Action'):
  82. continue
  83. flat = {x for ids in o.get('links', {}).values() for x in ids}
  84. if flat & (core | scen):
  85. scen.add(k); grew = True
  86. if not grew:
  87. break
  88. return anchors, mechs, verds, scen
  89. def ont_turbine_brief(turbine=''):
  90. t = _tid(turbine)
  91. d = db()
  92. tid = f'turbine/{t}'
  93. if tid not in d:
  94. return dict(text=f'机组不存在: {t}', error=True)
  95. anchors, mech_ids, verd_ids, scen_ids = _turbine_scope(d, tid)
  96. verd = [_brief(d[k]) for k in sorted(verd_ids)]
  97. mech = [_brief(d[k]) for k in sorted(mech_ids)]
  98. wo = [o for o in d.values() if o['type'] == 'WorkOrder' and o['props'].get('turbine') == t]
  99. oil = [o['props'].get('report_date', '') for o in d.values() if o['type'] == 'OilSample' and o['props'].get('turbine') == t]
  100. scen = [_brief(d[k]) for k in sorted(scen_ids)]
  101. alarm_lv = [v for v in verd if v.get('level') in ('报警', '定论') or '预警' in str(v.get('level', ''))]
  102. txt = (f"{t} 台级汇总: 判级 {len(verd)} 条 (报警/定论/预警级 {len(alarm_lv)} 条: "
  103. f"{[(v['id'], v.get('level'), v.get('review_tier')) for v in alarm_lv][:6]}); "
  104. f"机制 {len(mech)} 个: {[m['id'] for m in mech]}; 工单 {len(wo)} 条; "
  105. f"油样 {len(oil)} 份(末样 {max(oil) if oil else '无'}); 决策/动作 {[s['id'] for s in scen]}。"
  106. f"深入用 ont_get(id) / ont_links(id)。")
  107. return dict(text=txt, data=dict(verdicts=verd, mechanisms=mech, n_workorders=len(wo),
  108. oil_last=max(oil) if oil else None, decisions=scen))
  109. def ont_get(id=''):
  110. d = db()
  111. if id not in d:
  112. near = [k for k in d if str(id) in k][:8]
  113. return dict(text=f'对象不存在: {id}; 相近id: {near}', error=True)
  114. return dict(text=json.dumps(_brief(d[id], with_props=True), ensure_ascii=False, default=str)[:5000],
  115. data=_brief(d[id], with_props=True))
  116. def ont_query(type='', turbine='', contains='', exact='', limit=20):
  117. """F13: contains 数字串按词边界匹配 ('29' 命中 WTG29 不命中 2029/1729);
  118. exact= 全等匹配 (id / props 标量值 / links 目标id); 台号类查询走 turbine= (精确) 勿用 contains."""
  119. import re as _re
  120. d = db()
  121. t = _tid(turbine)
  122. out = []
  123. for o in d.values():
  124. if type and o['type'] != type:
  125. continue
  126. if t and t not in json.dumps(o.get('links', {}), ensure_ascii=False) and o['props'].get('turbine') != t and f'/{t}/' not in o['id']:
  127. continue
  128. if exact:
  129. vals = {o['id']} | {str(v) for v in o.get('props', {}).values()
  130. if not isinstance(v, (dict, list))}
  131. vals |= {x for ids in o.get('links', {}).values() for x in ids}
  132. if str(exact) not in vals:
  133. continue
  134. if contains:
  135. blob = json.dumps(o, ensure_ascii=False)
  136. c = str(contains)
  137. if c.isdigit(): # 纯数字: 词边界感知, 防 '29' 假阳命中 '2029'
  138. if not _re.search(rf'(?<!\d){_re.escape(c)}(?!\d)', blob):
  139. continue
  140. elif c not in blob:
  141. continue
  142. out.append(_brief(o))
  143. if len(out) >= int(limit):
  144. break
  145. return dict(text=f'{len(out)} 个对象: ' + json.dumps(out[:int(limit)], ensure_ascii=False, default=str)[:4500], data=out)
  146. def ont_links(id=''):
  147. d = db()
  148. if id not in d:
  149. return dict(text=f'对象不存在: {id}', error=True)
  150. fwd = d[id].get('links', {})
  151. back = [dict(id=o['id'], rel=rel) for o in d.values() for rel, ids in o.get('links', {}).items() if id in ids]
  152. return dict(text=f'正向: {fwd}; 反向被引: {back[:20]}', data=dict(forward=fwd, backlinks=back[:50]))
  153. def ont_mechanism_search(keyword=''):
  154. d = db()
  155. hits = [_brief(o, with_props=True) for o in d.values() if o['type'] == 'FailureMechanism'
  156. and (not keyword or str(keyword) in json.dumps(o, ensure_ascii=False))]
  157. return dict(text=f'机制库命中 {len(hits)}: ' + json.dumps(hits, ensure_ascii=False, default=str)[:4500], data=hits)
  158. def ont_scenario(turbine=''):
  159. t = _tid(turbine)
  160. d = db()
  161. hits = [o for o in d.values() if o['id'].startswith('scenario/') and (not t or f'/{t}/' in o['id'])]
  162. if not hits:
  163. return dict(text=f'无沙盘对象 (turbine={t or "全部"})', error=True)
  164. return dict(text=json.dumps([dict(id=o['id'], **o['props']) for o in hits], ensure_ascii=False, default=str)[:6000],
  165. data=[dict(id=o['id'], **o['props']) for o in hits])
  166. def ont_loss(turbine='', months=3):
  167. """损失分析: 近 N 月损失结构 (loss_monthly.parquet, L1 仓; 电量 kWh, 金额须另配电价区间口径)."""
  168. import pandas as pd
  169. lm = pd.read_parquet(P.store() / 'loss_monthly.parquet')
  170. ms = sorted(lm.month.unique())[-int(months):]
  171. d = lm[lm.month.isin(ms)]
  172. t = _tid(turbine)
  173. if t:
  174. d = d[d.turbine == t]
  175. by_state = d[d.state != '正常发电'].groupby('state').loss.sum().sort_values(ascending=False)
  176. by_turb = d[d.state != '正常发电'].groupby('turbine').loss.sum().sort_values(ascending=False).head(8)
  177. gen = d[d.state == '正常发电'].act.sum()
  178. txt = (f"损失结构 (窗={ms[0]}~{ms[-1]}{', ' + t if t else ', 全场'}): "
  179. f"分态损失 kWh = {by_state.round(0).to_dict()}; "
  180. f"台级损失 top = {by_turb.round(0).to_dict()}; 同窗正常发电量 {gen:,.0f} kWh。"
  181. f"口径: 损失=按状态归因的少发电量估计 (kWh); 折金额须带电价区间 0.391~0.85 元/kWh (合同价未知); "
  182. f"限电类损失≠设备责任 (口径纪律)。数据窗至 {sorted(lm.month.unique())[-1]}。 "
  183. # ★给出可引用对象 id (2026-08-28): 答案有效性闸要求每个结论句带 [对象id];
  184. # 本工具原来只回文字没有 id, 模型只好自造 [全场汇总]/[WTG19] 这类假 id ⇒ 闸判"有效=False"。
  185. f"来源对象: [loss/fleet/{ms[0]}~{ms[-1]}] (引用本结论时用此 id)。")
  186. return dict(text=txt, data=dict(months=ms, by_state=by_state.to_dict(), by_turbine=by_turb.to_dict()))
  187. def ont_risk():
  188. """风险跟踪面: 联动监视读数 + 机制状态 + 盲区纪律 (安全风险类问题的入口)."""
  189. from src.windscada.subsys import temp_nbm
  190. wl = temp_nbm.watchlist_check()
  191. d = db()
  192. mech = [(o['props'].get('name'), o['props'].get('status'), o.get('links', {}).get('affects', []))
  193. for o in d.values() if o['type'] == 'FailureMechanism']
  194. acc = d.get('acceptance/WTG29/state', {}).get('props', {})
  195. txt = ('联动监视 (温度watch): ' + ' | '.join(wl) +
  196. f" ;; 机制库状态: {[(n, s) for n, s, _ in mech]}"
  197. f" ;; 29# 验收状态机: {acc.get('state')} ({acc.get('note', '')[:80]})"
  198. ' ;; 盲区纪律: 振动未列≠正常 (盲区四条见 handoff detectability), 不可判≠阴性,'
  199. ' 风险升级判据=自身纵向机制链 (同期+先后+剂量), 单月横比不给判级。'
  200. # ★可引用 id (2026-08-28, 与 ont_loss 同一处修法): 答案有效性闸要求每个结论句带
  201. # [对象id]; 本工具原来只回文字没有 id, 模型只好不带引用 ⇒ 闸判"有效=False"。
  202. # 机制对象本身就是可引的, 直接把它们的 id 给出来。
  203. + ' ;; 可引用对象: ' + ', '.join(
  204. f"[{o['id']}]" for o in d.values() if o['type'] == 'FailureMechanism')[:600]
  205. + ' [risk/fleet/watchlist] (联动监视读数引用此 id)。')
  206. return dict(text=txt, data=dict(watchlist=wl, mechanisms=[(n, s) for n, s, _ in mech], acceptance=acc))
  207. def ont_brief_pack(turbine=''):
  208. """一站式取证包 (提速: 台级问题一轮取齐, 替代 stats→brief→get→scenario→loss 多轮).
  209. 合并: 台级汇总+机制详情+沙盘+验收状态+近3月损失+纪律."""
  210. b = ont_turbine_brief(turbine)
  211. if b.get('error'):
  212. return b
  213. t = turbine if str(turbine).startswith('WTG') else f'WTG{int(turbine):02d}'
  214. d = db()
  215. _, mech_ids, verd_ids, _ = _turbine_scope(d, f'turbine/{t}')
  216. mech_full = [dict(id=k, **d[k]['props']) for k in sorted(mech_ids)]
  217. # 关键判级全文 (F5: 挂机制的高审级判级如 TCM-16, 台级包里给全文非仅列名)
  218. key_verd = [dict(id=k, **{kk: d[k]['props'][kk] for kk in ('level', 'title', 'review_tier', 'claim_window')
  219. if kk in d[k]['props']})
  220. for k in sorted(verd_ids)
  221. if d[k]['props'].get('review_tier') in ('双模审', 'AdvCI-PR', '用户裁')]
  222. scen = ont_scenario(t)
  223. loss = ont_loss(t, 3)
  224. acc = d.get('acceptance/WTG29/state', {}).get('props', {}) if t == 'WTG29' else {}
  225. txt = (b['text'] + ' ;; 关键判级(高审级)全文: ' + json.dumps(key_verd, ensure_ascii=False, default=str)[:1400]
  226. + ' ;; 机制详情: ' + json.dumps(mech_full, ensure_ascii=False, default=str)[:2200]
  227. + ' ;; 沙盘: ' + (scen.get('text', '无')[:2600] if not scen.get('error') else '无')
  228. + ' ;; 验收状态机: ' + json.dumps(acc, ensure_ascii=False, default=str)[:600]
  229. + ' ;; ' + loss['text'][:800]
  230. + ' ;; 纪律: 判级只转述原文带审级与对象id; 观测面≠源数; 未列≠正常; 不可判≠阴性; 状态判断带窗。')
  231. return dict(text=txt[:9000], data=None)
  232. _PARAMS = None
  233. # 元件位号 (西门子 4.0 命名): 206-B5C / 403-B7 / 501-K1 / 453DI5 / 406F5 / 503-W2 / 400-M1
  234. _TAG_RE = re.compile(r'(\d{3}\s?[-–]\s?[A-Z]{1,3}\d{0,3}[A-Z]?|\d{3}[A-Z]{2}\d{1,2})')
  235. # 步骤里的物理量 → 参数表关键词 + 图纸类别 (用于给"检查规范"和"看哪张图")
  236. _QTY = [
  237. ('压力|油压|高压|低压|bar', 'pressure', '液压图', '压力'),
  238. ('温度|阻值.*温|℃', 'temperature', '电气图', '温度'),
  239. ('阻值|电阻|绝缘|Ω', 'resistance', '电气图', '阻值/绝缘'),
  240. ('电流|电压|供电|回路|接地|短路|断路', 'voltage|current', '电气图', '电气量'),
  241. ('转速|rpm|超速', 'speed', '原理图', '转速'),
  242. ('油位|液位|液面', 'level', '液压图', '油位'),
  243. ('角度|桨距|位置|编码器', 'angle|position', '原理图', '位置/角度'),
  244. ]
  245. def _params():
  246. global _PARAMS
  247. if _PARAMS is None:
  248. import pandas as _pd
  249. f = P.ont() / 'turbine_params.parquet'
  250. _PARAMS = _pd.read_parquet(f) if f.exists() else None
  251. return _PARAMS
  252. # 回路 → 应检节点清单 (2026-08-28 用户令: 给出检查的每个节点)。
  253. # 手册步骤常写"检查液压回路是否正常"这类笼统句, 没有位号 → 现场不知道查哪几处。
  254. # 下表按 SWT-4.0-130 的回路构成展开, 每个节点给"查什么"; 位号从同系统手册步骤里实际出现过的抽取补充。
  255. _CIRCUIT_NODES = {
  256. '液压回路': [('蓄能器', '氮气预充压力、皮囊有无破损'), ('比例阀/电磁阀', '得电动作、阀芯有无卡滞'),
  257. ('油缸', '行程到位、杆端密封渗漏'), ('压力传感器', '零点与量程、信号线通断'),
  258. ('油管与接头', '外泄、振动磨损、卡箍松动'), ('泵站与滤芯', '压力建立时间、滤芯压差'),
  259. ('回油与油位', '油位在标线内、回油是否通畅')],
  260. '润滑回路': [('润滑泵', '电机绕组与绝缘、出脂压力'), ('分配器', '柱塞往复计数、有无堵塞'),
  261. ('主管路与支路', '管路通畅、接头渗漏'), ('轴承注脂口', '出脂是否到位、旧脂颜色'),
  262. ('油位/脂位开关', '信号与实物是否一致')],
  263. '电气回路': [('断路器/熔断器', '是否跳闸、整定值'), ('接触器/继电器', '吸合动作、触点烧蚀'),
  264. ('端子排', '紧固力矩、氧化变色'), ('线缆与屏蔽', '通断、绝缘、屏蔽接地'),
  265. ('IO 模块/板卡', '指示灯、通道输入输出'), ('电源与UPS', '输出电压、后备时间')],
  266. '冷却系统': [('冷却风扇/水泵', '启停动作、转向、噪声'), ('散热片/换热器', '清洁度、堵塞'),
  267. ('温控开关', '动作温度与整定一致'), ('滤棉/滤网', '积尘、更换周期'),
  268. ('管路与液位', '渗漏、冷却液位与浓度')],
  269. '传感回路': [('传感器本体', '阻值/输出与温度或压力对应'), ('接线与插头', '插头到位、针脚氧化'),
  270. ('信号线缆', '屏蔽接地、与动力线分离'), ('采集通道', '同型号通道互换验证')],
  271. '制动/机械': [('制动器', '闸片厚度、间隙、动作压力'), ('联轴器', '对中、弹性件裂纹'),
  272. ('紧固螺栓', '力矩复核、防松标记错位')],
  273. }
  274. _CIRCUIT_HINT = [('液压|油压|蓄能|油缸|阀', '液压回路'), ('润滑|油脂|注脂|分配器|柱塞', '润滑回路'),
  275. ('电气|回路|供电|短路|断路|接地|模块|端子|线缆|电压|电流', '电气回路'),
  276. ('冷却|散热|风扇|滤棉|水冷|温控', '冷却系统'),
  277. ('传感器|编码器|信号|反馈|阻值', '传感回路'),
  278. ('制动|刹车|联轴器|螺栓|紧固', '制动/机械')]
  279. def _known_tags(system):
  280. """该系统手册步骤里实际出现过的元件位号 (给笼统步骤当"可能涉及的位号"参考)."""
  281. d = db()
  282. out = {}
  283. for o in d.values():
  284. if o['type'] != 'WorkInstruction' or o['props'].get('kind') != '抢修':
  285. continue
  286. if system and system not in str(o.get('links', {}).get('appliesTo', [])) and \
  287. system not in str(o['props'].get('name', '')):
  288. continue
  289. for st in o['props'].get('steps', []):
  290. for t in _TAG_RE.findall(str(st.get('步骤', ''))):
  291. out[t] = out.get(t, 0) + 1
  292. return sorted(out.items(), key=lambda kv: -kv[1])[:10]
  293. def _step_enrich(step_text, system, code=''):
  294. """把一条手册处置步骤补成可执行的检查项 (2026-08-28 用户令:
  295. "一定要检查系统的原理图纸, 给出具体的检查回路节点部件, 包括检查的规范, 检查内容都要清楚标明").
  296. 产出四段: ①节点部件(元件位号, 来自手册原文, 确定) ②检查内容(手册原文)
  297. ③判定规范(实机参数表 Min/Max/Current — 按物理量关键词匹配, 非厂商绑定, 须现场以铭牌/图纸复核)
  298. ④对应图纸(按物理量与系统匹配资料索引)。
  299. ★纪律: 参数按关键词匹配给的是**候选判据**不是绑定值; 码号与参数 ID 是不同命名空间
  300. (UP03225 参数 ≠ 告警码 3225, memory 已记), 故不按同号关联。"""
  301. import re as _re
  302. d = db()
  303. txt = str(step_text or '')
  304. tags = sorted(set(_TAG_RE.findall(txt)))
  305. qty, pkw, dkind = None, None, None
  306. for pat, kw, dk, label in _QTY:
  307. if _re.search(pat, txt):
  308. qty, pkw, dkind = label, kw, dk
  309. break
  310. # 判定规范: 参数表按物理量关键词 + 系统词过滤
  311. specs = []
  312. pr = _params()
  313. if pr is not None and pkw:
  314. sysen = {'变桨': 'pitch|blade', '偏航': 'yaw', '齿轮箱': 'gear', '发电机': 'generator|gen',
  315. '主轴承': 'main bearing|rotor', '变流器': 'converter|inverter|grid'}.get(system, '')
  316. m = pr.Text.astype(str).str.contains(pkw, case=False, na=False)
  317. if sysen:
  318. m &= pr.Text.astype(str).str.contains(sysen, case=False, na=False)
  319. for _, r in pr[m].head(4).iterrows():
  320. specs.append(dict(no=str(r['Number']), text=str(r['Text']).split(':', 1)[-1].strip(),
  321. lo=(None if r['Min'] != r['Min'] else float(r['Min'])),
  322. hi=(None if r['Max'] != r['Max'] else float(r['Max'])),
  323. val=(None if r['Value'] != r['Value'] else float(r['Value'])),
  324. unit=('' if r['Unit'] != r['Unit'] else str(r['Unit']))))
  325. # 图纸: 按类别 + 系统
  326. dwgs = []
  327. if dkind:
  328. for o in d.values():
  329. if o['type'] != 'Doc':
  330. continue
  331. pp = o['props']
  332. if pp.get('kind') == dkind and (not system or pp.get('system') in (None, system)):
  333. dwgs.append(dict(name=pp['name'], rel=pp.get('rel', '')))
  334. if len(dwgs) >= 3:
  335. break
  336. # 应检节点: 有位号用位号; 没位号则按回路展开清单 (笼统句"检查液压回路"现场不知道查哪几处)
  337. circuit = next((c for pat, c in _CIRCUIT_HINT if _re.search(pat, txt)), None)
  338. nodes = _CIRCUIT_NODES.get(circuit, []) if not tags else []
  339. known = _known_tags(system) if (not tags and circuit) else []
  340. return dict(tags=tags, qty=qty, specs=specs, drawings=dwgs,
  341. circuit=circuit, nodes=[dict(name=n, check=c) for n, c in nodes],
  342. known_tags=[dict(tag=t, n=n) for t, n in known])
  343. # 安全前提 (按系统; 通用检修规程, 非本场专用条款 — 现场以业主作业票与厂家手册为准)
  344. _SAFETY = {
  345. '变桨': ['三叶顺桨至 90° 并确认桨距锁定', '轮毂检修位固定, 转子锁投入', '蓄能器泄压至 0 bar 并验压',
  346. '轮毂内作业须双人 + 安全带 + 通风', '断开变桨柜动力电并验电挂牌'],
  347. '齿轮箱': ['机组停机, 转子锁投入', '齿轮油温降至可作业温度 (避免烫伤)', '油路作业前泄压并接油盘',
  348. '拆检前记录油位与取样 (拆后无法回溯)', '断开润滑泵与冷却电机动力电挂牌'],
  349. '发电机': ['断开主断路器并验电, 挂牌上锁', '等待定子/绕组残余电压释放', '滑环作业前确认无残压',
  350. '转子锁投入后方可进入联轴器区', '测绝缘前断开变流器侧连接'],
  351. '主轴承': ['机组停机, 转子锁投入', '集中润滑泵断电挂牌 (防误动作喷脂)', '取脂样前记录上次加注时间与量',
  352. '主轴区作业须防坠落措施'],
  353. '偏航': ['偏航电机断电挂牌', '偏航制动器保压确认 (防机舱自转)', '解缆前确认电缆无扭转应力'],
  354. '变流器': ['断开网侧与机侧开关, 验电挂牌', '等待直流母线电容放电至安全电压 (按手册等待时间)',
  355. '水冷系统作业前泄压', '禁止带电插拔功率模块'],
  356. '主控与传感网': ['断开对应回路电源并验电', 'UPS 侧确认已隔离', '光纤作业防激光直视'],
  357. }
  358. # 记录项 (每次处置必留, 否则下次无法回溯)
  359. _RECORD = ['处置日期与人员', '故障复位前后的读数对比', '更换件的件号与批次', '实测值与判定依据',
  360. '未解决项与下轮观察点']
  361. def compose_repair_plan(code=''):
  362. """按本体图谱编制现场处置预案 (2026-08-28 用户令: 自己编制, 对照经典设计, 用 ontology 决策机制与知识图谱).
  363. 与 ont_chain_fault 的区别: 那个是**转录**(码义/手册步骤/工单/图纸原样给出);
  364. 本函数是**编排** — 用图谱把转录素材组织成可执行预案, 每段标明来源性质:
  365. [转录] 手册与工单原文 (可靠, 出处可查)
  366. [编排] 我按实证量与检修常识排的顺序 (可质疑, 现场可调)
  367. [通用] 检修安全规程 (非本场专用条款, 以业主作业票为准)
  368. [候选] 参数表按物理量匹配的判定值 (非厂商绑定, 以铭牌图纸复核)
  369. """
  370. d = db()
  371. code = str(code).strip()
  372. a = d.get(f'alarmcode/{code}')
  373. if not a:
  374. return dict(text=f'码 {code} 不在本体', error=True)
  375. pr = a['props']
  376. sysn = pr.get('system')
  377. base = ont_chain_fault(code=code).get('data') or {}
  378. # ① 排查顺序: 按该系统原因节点的实证量排 (手册引用次数 + 工单命中×3) — 先查最常见的
  379. causes = []
  380. for o in d.values():
  381. if o['type'] != 'FailureMode' or o['props'].get('level') != '部件级原因':
  382. continue
  383. if sysn and o['props'].get('system') != sysn:
  384. continue
  385. cp = o['props']
  386. causes.append(dict(name=cp['name'].split('·')[-1].strip(),
  387. n_ref=cp.get('手册引用次数', 0), n_wo=cp.get('工单命中', 0),
  388. score=cp.get('手册引用次数', 0) + cp.get('工单命中', 0) * 3,
  389. steps=(cp.get('典型排查') or [])[:2]))
  390. causes.sort(key=lambda x: -x['score'])
  391. # ② 备件与工具: 从该系统工单的处置动作与元器件反推 (现场真换过什么)
  392. parts, acts = {}, {}
  393. for o in d.values():
  394. if o['type'] != 'WorkOrder':
  395. continue
  396. q = o['props']
  397. txt = ' '.join(str(q.get(k, '') or '') for k in ('name', 'loc2', 'part'))
  398. if sysn and not re.search({'变桨': '变桨|桨|叶片|液压|蓄能', '偏航': '偏航|解缆',
  399. '齿轮箱': '齿轮|齿箱|润滑油|滤芯', '发电机': '发电机|定子|滑环',
  400. '主轴承': '主轴', '变流器': '变频|变流|IGBT|水冷',
  401. '主控与传感网': '主控|通讯|传感'}.get(sysn, sysn), txt):
  402. continue
  403. if q.get('part'):
  404. parts[str(q['part'])] = parts.get(str(q['part']), 0) + 1
  405. if q.get('action'):
  406. acts[str(q['action'])] = acts.get(str(q['action']), 0) + 1
  407. # ③ 验收: 复位后观察窗 (按该码本场发生频次定 — 高频码需更长观察)
  408. n_site = int(pr.get('本场计数', 0))
  409. watch = '复位后连续观察 7 天' if n_site >= 100 else '复位后连续观察 3 天'
  410. return dict(
  411. code=code, name=pr.get('name', ''), system=sysn, n_site=n_site,
  412. safety=_SAFETY.get(sysn, ['按业主作业票执行停机与隔离']),
  413. order=[c for c in causes[:5]],
  414. manual_steps=base.get('steps', []),
  415. parts=sorted(parts.items(), key=lambda kv: -kv[1])[:6],
  416. actions=sorted(acts.items(), key=lambda kv: -kv[1])[:5],
  417. drawings=base.get('docs', []),
  418. workorders=base.get('workorders', [])[:5],
  419. accept=[f'故障码 {code} 不再复现 ({watch})',
  420. '相关信号回到判定范围内 (见各步判定规范)',
  421. '若为更换件: 记录件号批次并更新台账',
  422. '未解决项写入下轮观察点'],
  423. record=_RECORD,
  424. provenance=dict(转录='上海电气故障处理手册(海上 SWT-4.0-130) + 本场工单台账 2020~2024',
  425. 编排='排查顺序按本体图谱的原因节点实证量(手册引用+工单命中×3)排序 — 可质疑可调',
  426. 通用='安全前提为通用检修规程, 非本场专用条款, 以业主作业票与厂家手册为准',
  427. 候选='判定规范来自实机参数表(2024-10-21), 按物理量匹配 — 非厂商绑定, 现场以铭牌图纸复核'))
  428. def ont_chain_fault(code=''):
  429. """链二 抢修: 报警码 → 码义/系统 → 失效模式 → 处置 WI 分步 (现场手册原文转录, 不产判级)."""
  430. d = db()
  431. code = str(code).strip()
  432. a = d.get(f'alarmcode/{code}')
  433. if not a:
  434. return dict(text=f'码 {code} 不在本体 (本场未出现且手册未收录 — 未收录≠无此码)', error=True)
  435. pr = a['props']
  436. L = [f"[alarmcode/{code}] {pr.get('name','')} | 系统 {pr.get('system') or '未归属'}"
  437. f" | 本场 {pr.get('本场计数',0)} 条 | 告警类型 {pr.get('告警类型') or '—'}"]
  438. if pr.get('故障原因'): L.append(f"码表故障原因: {pr['故障原因']}")
  439. fms = [o for o in d.values() if o['type'] == 'FailureMode' and f'alarmcode/{code}' in o.get('links', {}).get('indicates', [])]
  440. for f in fms:
  441. L.append(f"→ 失效模式 [{f['id']}] {f['props']['name']} (本场该类报警 {f['props'].get('本场报警条数',0)} 条, 工单 {f['props'].get('工单条数',0)} 条)")
  442. wi = d.get(f'wi/fault/{code}')
  443. if wi:
  444. L.append(f"→ 处置 WI [{wi['id']}] ({wi['props']['n_step']} 步, 源: {wi['props']['source']})")
  445. for st in wi['props']['steps']:
  446. L.append(f" {st['序']}. [{st.get('类别','')}] {st['步骤']}")
  447. else:
  448. L.append('→ 本码无手册处置条目 (手册覆盖本场 161/322 码) — 需查图纸或厂商支持')
  449. # 技术依据: 先按码义关键词匹配文件名, 再退回系统级 (原来只按 system → "叶片高压"推荐吊具手册)
  450. _kws = [w for w in re.findall(r'[\u4e00-\u9fa5]{2,}', str(pr.get('name', ''))) if len(w) >= 2]
  451. _pri = [o for o in d.values() if o['type'] == 'Doc' and any(w in o['props']['name'] for w in _kws)]
  452. _sys = [o for o in d.values() if o['type'] == 'Doc' and o['props'].get('system') == pr.get('system')]
  453. _seen, docs = set(), []
  454. for o in _pri + _sys:
  455. if o['id'] in _seen: continue
  456. _seen.add(o['id']); docs.append(o)
  457. if len(docs) >= 4: break
  458. if docs: L.append('技术依据: ' + '; '.join(f"[{o['id']}] {o['props']['name']}" for o in docs))
  459. # name_en 带出: 本体 AlarmCode/FailureMode 上就存着厂商对译表的权威译名; 不带出来,
  460. # 出口投影只能拿裸描述去查 (码不在串里 → 查不到 → pending)。经典页与 v2 同受益。
  461. _nm = pr.get('name', '')
  462. if str(_nm).strip().lower() in ('nan', 'none', 'null'): # pandas NaN 漏成字符串 (码表 17 个码)
  463. _nm = ''
  464. _data = dict(code=code, name=_nm, name_en=pr.get('name_en'), system=pr.get('system'),
  465. n_site=pr.get('本场计数', 0), alarm_type=pr.get('告警类型', ''),
  466. cause_hint=pr.get('故障原因', ''),
  467. failmodes=[dict(id=f['id'], name=f['props']['name'], name_en=f['props'].get('name_en'),
  468. n_alarm=f['props'].get('本场报警条数', 0), n_wo=f['props'].get('工单条数', 0))
  469. for f in fms],
  470. steps=[dict(st, **_step_enrich(st.get('步骤', ''), pr.get('system'), code))
  471. for st in (wi['props']['steps'] if wi else [])],
  472. wi_source=(wi['props']['source'] if wi else None),
  473. docs=[dict(id=o['id'], name=o['props']['name'], kind=o['props'].get('kind'),
  474. rel=o['props'].get('rel')) for o in docs])
  475. # 链二第三段: 该系统历史工单 (现场真做过什么 — 手册给"应该怎么修", 工单给"实际怎么修的")
  476. sysn = pr.get('system')
  477. if sysn:
  478. wos = [o for o in d.values() if o['type'] == 'WorkOrder' and _wo_sys(o) == sysn]
  479. wos.sort(key=lambda o: str(o['props'].get('date', '')), reverse=True)
  480. if wos:
  481. from collections import Counter as _C
  482. acts = _C(o['props'].get('action') for o in wos if o['props'].get('action'))
  483. L.append(f'历史工单 ({sysn}, 台账 2020~2024): {len(wos)} 条 · 处置 '
  484. + ', '.join(f'{k}×{v}' for k, v in acts.most_common(4)))
  485. for o in wos[:5]:
  486. q = o['props']
  487. L.append(f" {q.get('date','')} {q.get('turbine','')} {str(q.get('name',''))[:26]}"
  488. f" → {q.get('action','')} {str(q.get('part','') or '')[:14]}")
  489. L.append(' (工单窗止 2024-11-21; 2025-26 现场未提供 ⇒ "0 条"是记录缺失非未修过)')
  490. _nz = lambda v: '' if v in (None, 'None', 'nan') else str(v)
  491. # ★同时带出 *_en: 工单对象本来就存着 name_en / action_en / part_en (来自厂商
  492. # 对译表与构词表), 那是权威译名。原来只取中文字段, 英文侧只能事后翻译 ——
  493. # 而事后翻译对厂商报警名会猜错 (见 _data_text_en 的"不猜译"纪律)。
  494. # 下游按 key_en → key 投影即可, 中文侧不受影响。
  495. # 空日期不留空格: 大部件更换里有 7 条**台账源头就没填** (源表 time_on/time_off
  496. # 与「大部件维修时间」三处皆空), 界面留空会被读成"数据丢了"。显式写出来。
  497. _dt = lambda p: (_nz(p.get('date'))[:10]
  498. or ('台账未填' if p.get('date_note') else '—'))
  499. _data['workorders'] = [dict(date=_dt(o['props']), date_en=('not recorded in ledger'
  500. if not _nz(o['props'].get('date')) and o['props'].get('date_note')
  501. else None),
  502. turbine=_nz(o['props'].get('turbine')),
  503. name=_nz(o['props'].get('name'))[:40],
  504. name_en=_nz(o['props'].get('name_en'))[:60] or None,
  505. action=_nz(o['props'].get('action')) or '—',
  506. action_en=_nz(o['props'].get('action_en')) or None,
  507. part=_nz(o['props'].get('part'))[:18],
  508. part_en=_nz(o['props'].get('part_en'))[:30] or None)
  509. for o in wos[:8]]
  510. _data['wo_total'] = len(wos)
  511. return dict(text='\n'.join(L), data=_data)
  512. # 这里原有一份 _WOKW -- 与 kb_ingest 旧 SYSKW **同样的病**:
  513. # 变桨|..|液压|蓄能|轮毂|叶片|叶根 首匹配即中且变桨在最前, 于是「偏航液压泵过热」
  514. # 先撞上 液压 判成变桨, 根本走不到偏航那条。入库侧 2026-09-02 已改按原厂结构树派生,
  515. # **服务侧这份拷贝没跟着改** = memory library-gate-not-wired-to-production 的原样复发:
  516. # 判据落库不等于接上生产路径。此处删拷贝, 直接调同一个 _sys_of。
  517. from .kb_ingest import _sys_of as _kb_sys_of
  518. def _wo_sys(o):
  519. """工单 -> 九系统。与入库侧 appliesTo 走**同一个判据**, 不再各自维护关键词表。"""
  520. p = o.get('props', {})
  521. txt = ' '.join(str(p.get(k, '') or '') for k in ('name', 'loc2', 'part'))
  522. return _kb_sys_of(txt)
  523. def ont_workorder(turbine='', system='', keyword='', limit=20):
  524. """检修工单查询 (链二"检修工单"段): 按台/系统/关键词; 现场实际做过什么 + 处置分布."""
  525. d = db()
  526. t, sysn, kw = str(turbine).strip(), str(system).strip(), str(keyword).strip()
  527. if t and not t.startswith('WTG'):
  528. t = f'WTG{int(t):02d}' if t.isdigit() else t
  529. wos = [o for o in d.values() if o['type'] == 'WorkOrder']
  530. if t: wos = [o for o in wos if o['props'].get('turbine') == t]
  531. if sysn: wos = [o for o in wos if _wo_sys(o) == sysn]
  532. if kw: wos = [o for o in wos if kw in json.dumps(o['props'], ensure_ascii=False)]
  533. if not wos:
  534. return dict(text=f'无匹配工单 (台 {t or "全场"} / 系统 {sysn or "全部"} / 关键词 {kw or "无"}); '
  535. '台账窗 2020-01~2024-11, 2025-26 现场未提供 ⇒ 空≠未修过')
  536. from collections import Counter as _C
  537. acts = _C(o['props'].get('action') for o in wos if o['props'].get('action'))
  538. parts = _C(o['props'].get('part') for o in wos if o['props'].get('part'))
  539. wos.sort(key=lambda o: str(o['props'].get('date', '')), reverse=True)
  540. L = [f'工单 {len(wos)} 条 (台 {t or "全场"} / 系统 {sysn or "全部"}{"/ 关键词 " + kw if kw else ""})',
  541. '处置分布: ' + ', '.join(f'{k}×{v}' for k, v in acts.most_common(6)),
  542. '涉及部件: ' + ', '.join(f'{k}×{v}' for k, v in parts.most_common(6)),
  543. '']
  544. for o in wos[:int(limit)]:
  545. q = o['props']
  546. L.append(f"[{o['id']}] {q.get('date','')} {q.get('turbine','')} · {str(q.get('name',''))[:30]}"
  547. f" · {q.get('loc2','')} · {q.get('action','')} {str(q.get('part','') or '')[:16]}")
  548. L.append('')
  549. L.append('口径: 台账窗 2020-01-03~2024-11-21 (六类源 data-first 核过: 集团月表 2025-26 全场无记录, '
  550. '两个"年度总表.xls"实为报警 XML 导出); 电量损失列只登记不聚合 (累计快照直接 sum 虚高)。')
  551. return dict(text='\n'.join(L))
  552. def ont_chain_preventive(system='', component=''):
  553. """链一 预防: 部件/系统 → 该系统失效模式 → 预防性任务 → WI 步骤与工时."""
  554. d = db()
  555. key = str(system or component).strip()
  556. fms = [o for o in d.values() if o['type'] == 'FailureMode' and key and key in o['props'].get('system', '')]
  557. # 部件 id 是英文 (component/fleet/gearbox), key 是中文 → 先经映射再比 (原直接 in 比对恒 False)
  558. _CID = {'变桨': 'pitch', '偏航': 'yaw', '齿轮箱': 'gearbox', '发电机': 'generator', '主轴承': 'main_bearing',
  559. '变流器': 'converter', '塔架与基础': 'tower', '主控与传感网': 'control',
  560. '叶片与叶根': 'blade'} # 2026-09-02 结构树重分类后 component/fleet/blade 已存在
  561. _cid = f"component/fleet/{_CID.get(key, '')}" if key in _CID else None
  562. tasks = [o for o in d.values() if o['type'] == 'MaintTask' and o['props'].get('task_kind') == '预防性'
  563. and _cid and _cid in (o.get('links', {}).get('appliesTo') or [])]
  564. if not key:
  565. return dict(text='需给 system/component (如 齿轮箱/变桨/主轴承)', error=True)
  566. fms_sorted = sorted(fms, key=lambda x: -(x['props'].get('本场报警条数') or 0))
  567. L = [f'== {key} 预防链 ==', f'失效模式 {len(fms)} 类 · 预防性任务 {len(tasks)} 项']
  568. for f in fms_sorted[:6]:
  569. pr = f['props']
  570. L.append(f"· [{f['id']}] {pr['name']} — 本场报警 {pr.get('本场报警条数',0)} 条 / 工单 {pr.get('工单条数',0)} 条")
  571. for t in tasks[:10]:
  572. wi = (t.get('links', {}).get('hasInstruction') or [None])[0]
  573. w = d.get(wi) if wi else None
  574. L.append(f"· 任务 [{t['id']}] {t['props']['name']} (周期 {t['props'].get('interval')})"
  575. + (f" → WI {w['props'].get('步骤号')} {len(w['props']['steps'])} 步" if w else ''))
  576. if not tasks: L.append('(该系统无匹配的年度维护条目 — 维护作业指导书按位置代号编排, 未必逐系统对齐)')
  577. # ★结构化 data (2026-09-03 新前端): 原来只回 text 拼串, 英文页只能整块显示中文 —— 文本 blob
  578. # 无法按字段投影 name_en。data 带 *_en 字段, 出口 _jdump 按 lang 投影, 前端按 key 取文案。
  579. def _fm(f):
  580. pr = f['props']
  581. return dict(id=f['id'], name=pr.get('name'), name_en=pr.get('name_en'),
  582. n_alarm=pr.get('本场报警条数') or 0, n_wo=pr.get('工单条数') or 0)
  583. def _task(t):
  584. wi = (t.get('links', {}).get('hasInstruction') or [None])[0]
  585. w = d.get(wi) if wi else None
  586. return dict(id=t['id'], name=t['props'].get('name'), name_en=t['props'].get('name_en'),
  587. interval=t['props'].get('interval'),
  588. wi=(dict(id=w['id'], n_step=len(w['props'].get('steps') or []),
  589. steps=w['props'].get('steps') or []) if w else None))
  590. data = dict(system=key, n_fm=len(fms), n_task=len(tasks),
  591. failmodes=[_fm(f) for f in fms_sorted], tasks=[_task(t) for t in tasks])
  592. return dict(text='\n'.join(L), data=data)
  593. def ont_fault_tree(system=''):
  594. """链三 故障树: 系统 → 失效模式(按实证量排) → 各自的征兆码与处置; 附本场证据量."""
  595. d = db()
  596. key = str(system).strip()
  597. fms = [o for o in d.values() if o['type'] == 'FailureMode' and (not key or key in o['props'].get('system', ''))]
  598. fms.sort(key=lambda x: -(x['props'].get('本场报警条数', 0) + x['props'].get('工单条数', 0) * 10))
  599. tops = [f for f in fms if f['props'].get('level') != '部件级原因']
  600. L = [f"== 故障树 {key or '全场'} ({len(tops)} 个系统级失效模式, 按实证量排) =="]
  601. for f in tops[:8]:
  602. pr = f['props']
  603. L.append(f"[{f['id']}] {pr['name']} · 报警 {pr.get('本场报警条数',0)} 条 / 工单 {pr.get('工单条数',0)} 条 / 码 {pr.get('n_codes',0)} 个")
  604. if pr.get('示例码'): L.append(f" 征兆码: {', '.join(pr['示例码'][:5])}")
  605. # 二层: causedBy 部件级原因 (手册排查方向 + 工单实证计数)
  606. for cid in (f.get('links', {}).get('causedBy') or []):
  607. c = d.get(cid)
  608. if not c: continue
  609. cp = c['props']
  610. L.append(f" ← 原因 [{cid}] {cp['name'].split('·')[-1].strip()}"
  611. f" · 手册引用 {cp.get('手册引用次数',0)} 次 / 工单命中 {cp.get('工单命中',0)} 次 / 涉码 {cp.get('n_codes',0)}")
  612. for st in (cp.get('典型排查') or [])[:2]:
  613. L.append(f" · {st}")
  614. L.append('口径: 一层=系统×失效类(归纳自本场码表+工单); 二层=手册 solutionName 排查方向归一 + 工单原因文本计数。')
  615. L.append(' 因果来自手册与工单原文, 非我方推断; 树深两层不外推; 现场定位仍需图纸与实测。')
  616. _data = dict(system=key or '全场', nodes=[
  617. dict(id=f['id'], name=f['props']['name'].split('·')[-1].strip(),
  618. fm_class=f['props'].get('fm_class'),
  619. n_alarm=f['props'].get('本场报警条数', 0), n_wo=f['props'].get('工单条数', 0),
  620. codes=(f['props'].get('示例码') or [])[:5],
  621. causes=[dict(id=c, name=(d.get(c, {}).get('props', {}).get('name', c)).split('·')[-1].strip(),
  622. n_ref=d.get(c, {}).get('props', {}).get('手册引用次数', 0),
  623. n_wo=d.get(c, {}).get('props', {}).get('工单命中', 0),
  624. n_codes=d.get(c, {}).get('props', {}).get('n_codes', 0),
  625. steps=(d.get(c, {}).get('props', {}).get('典型排查') or [])[:3])
  626. for c in (f.get('links', {}).get('causedBy') or []) if c in d])
  627. for f in tops[:8]])
  628. return dict(text='\n'.join(L), data=_data)
  629. def ont_maint_plan(top=10):
  630. """链四 检修计划: 双轴排序 (风险 × 损失) 的排程建议.
  631. 风险 = 本体 Verdict 判级 (定论/危险 > 报警 > 预警) × 命中系统数;
  632. 损失 = 该台窗内停机损失 MWh (SCADA 实测, 非台账 — 台账止 2024-11 且有物理上限问题);
  633. 两轴都给出, 不合成单一分数 (合成会掩盖"高风险低损失"与"低风险高损失"两类不同处置)。"""
  634. d = db()
  635. RANK = {'定论': 4, '危险': 4, '报警': 3, '预警': 2, '候选': 1}
  636. agg = {}
  637. for o in d.values():
  638. if o['type'] != 'Verdict': continue
  639. lv = o['props'].get('level')
  640. if lv not in RANK: continue
  641. if o['props'].get('consumable') is False: # F3: 非部件级消费行不进排程 (证据在, 判级不下游)
  642. continue
  643. about = ' '.join(o.get('links', {}).get('about') or []) + ' ' + o['id']
  644. t = next((x[:6] for x in re.findall(r'WTG\d{2}', about)), None)
  645. if not t: continue
  646. sysn = o['props'].get('system') or (o['props'].get('title', '') or o['id'].split('/')[-1])[:28]
  647. a = agg.setdefault(t, dict(turbine=t, risk=0, n=0, items=[], tiers=set()))
  648. a['risk'] = max(a['risk'], RANK[lv]); a['n'] += 1
  649. a['items'].append(f"{sysn}[{lv}]"); a['tiers'].add(o['props'].get('review_tier', ''))
  650. # 损失轴: SCADA 实测停机损失 (窗内)
  651. import pandas as _pd, pathlib as _pl
  652. lp = P.store() / 'loss_monthly.parquet'
  653. loss = {}
  654. if lp.exists():
  655. lm = _pd.read_parquet(lp)
  656. lm = lm[(lm.month.astype(str) >= '2026-01') & (lm.state == '停机')]
  657. loss = (lm.groupby('turbine')['loss'].sum() / 1000).round(0).to_dict()
  658. rows = sorted(agg.values(), key=lambda x: (-x['risk'], -loss.get(x['turbine'], 0), -x['n']))[:int(top)]
  659. L = ['== 检修排程建议 (双轴: 风险级 × 实测停机损失) ==']
  660. L.append(f"{'机组':<8}{'风险':<6}{'命中':<5}{'停机损失MWh':<13}命中项")
  661. for a in rows:
  662. lv = {4: '定论/危险', 3: '报警', 2: '预警', 1: '候选'}[a['risk']]
  663. L.append(f"{a['turbine']:<8}{lv:<6}{a['n']:<5}{loss.get(a['turbine'], 0):<13,.0f}{'; '.join(a['items'][:4])}")
  664. hi_loss = sorted(loss.items(), key=lambda kv: -kv[1])[:3]
  665. L.append('')
  666. L.append('损失轴独立看 (高损失但未必高风险级, 需查是否限电/外部): ' +
  667. ', '.join(f'{t} {v:,.0f} MWh' for t, v in hi_loss))
  668. L.append('口径: 风险=Verdict 判级(筛查级/候选, 非定谳) × 命中系统数; 损失=2026 窗内"停机"态 SCADA 实测')
  669. L.append(' (不含限电与调度令; 工单台账损失止 2024-11 且需过物理上限核, 未用)。')
  670. L.append('★ 本表只排序不定案: 执行前过 SOP §6.1 审级 + 天气窗 (ont_scenario) + 备件与船期。')
  671. return dict(text='\n'.join(L))
  672. def ont_doc_search(keyword='', kind=''):
  673. """链三 技术支持: 图纸/原理/手册检索 (按名称与类别; 返回路径供现场调阅)."""
  674. d = db()
  675. kw, kd = str(keyword).strip(), str(kind).strip()
  676. hits = [o for o in d.values() if o['type'] == 'Doc'
  677. and (not kw or kw in o['props']['name'] or kw in str(o['props'].get('rel', '')))
  678. and (not kd or kd == o['props'].get('kind'))]
  679. if not hits:
  680. kinds = sorted({o['props'].get('kind') for o in d.values() if o['type'] == 'Doc'})
  681. return dict(text=f'未命中 (关键词 {kw!r} 类别 {kd!r}); 可用类别: {kinds}')
  682. L = [f'技术资料命中 {len(hits)} 份:']
  683. for o in hits[:15]:
  684. pr = o['props']
  685. L.append(f"[{o['id']}] {pr['name']} · {pr['kind']}" + (f" · {pr['system']}" if pr.get('system') else '') + f" · {pr['rel']}")
  686. return dict(text='\n'.join(L))
  687. def ont_fleet_verdicts(level='', tier=''):
  688. """全场判级速查 — 紧凑表, 一次给全 (2026-08-28 功能测试逮).
  689. 原来问"全场哪些机组有报警级"只能走 ont_query(type=Verdict), 它回 371 个对象的完整 JSON;
  690. 进上下文时被截断到 2000 字符, 模型只看到前几条, 于是自己写"(需补充, 当前数据截断)" —
  691. 答案不是错而是**残**。列举类问题要的是覆盖不是细节, 所以这里只回一行一条的紧凑表:
  692. 台号 | 部件/系统 | 级别 | 审级。38 台全量约 1.5KB, 一轮取齐, 不触截断。
  693. level/tier 可选过滤 (如 level='报警' 或 '定论')。
  694. """
  695. d = db()
  696. rows = []
  697. for k, v in d.items():
  698. if v.get('type') != 'Verdict':
  699. continue
  700. pr = v.get('props') or {}
  701. lv = str(pr.get('level') or pr.get('判级') or '')
  702. tr = str(pr.get('review_tier') or pr.get('审级') or '')
  703. if level and level not in lv:
  704. continue
  705. if tier and tier not in tr:
  706. continue
  707. # 台号: id 里的 WTGnn, 取不到就跳过 (L1/vib 等非台级 verdict)
  708. m = re.search(r'WTG(\d{2})', k)
  709. if not m:
  710. continue
  711. part = k.split('/')[-1]
  712. rows.append((m.group(1), part, lv, tr, k))
  713. if not rows:
  714. return dict(text=f'无匹配判级 (level={level or "全部"}, tier={tier or "全部"})')
  715. # ★按台聚合而非逐条列 (2026-08-28): 逐条 371 行 = 17KB, 仍会触上下文截断;
  716. # 一台一行 (该台各级别下的系统清单 + 一个代表 id 供引用) 全量约 2KB, 一轮取齐。
  717. ORD = {'定论': 0, '危险': 1, '报警': 2, '预警': 3, '候选': 4}
  718. # 正常级不逐系统列 (占全量 4.2KB 的一多半, 且不是提问要的东西): 压成一句计数。
  719. NORMAL = ('优秀', '良好', '正样本', '销案')
  720. by_t = {}
  721. for tid, part, lv, tr, oid in rows:
  722. e = by_t.setdefault(tid, dict(lv={}, worst=9, rep=oid, tier=tr))
  723. e['lv'].setdefault(f'{lv}({tr or "—"})', []).append(part)
  724. r = ORD.get(lv, 9)
  725. if r < e['worst']:
  726. e['worst'], e['rep'], e['tier'] = r, oid, tr
  727. lines = []
  728. for tid in sorted(by_t, key=lambda t: (by_t[t]['worst'], t)):
  729. e = by_t[tid]
  730. ab, nm = {}, 0
  731. for k, v in e['lv'].items():
  732. (nm := nm + len(v)) if k.split('(')[0] in NORMAL else ab.setdefault(k, v)
  733. seg = '; '.join(f"{k} {','.join(v[:6])}{'…' if len(v) > 6 else ''}" for k, v in
  734. sorted(ab.items(), key=lambda kv: ORD.get(kv[0].split('(')[0], 9)))
  735. if nm:
  736. seg = (seg + '; ' if seg else '') + f'其余 {nm} 项优秀/良好'
  737. lines.append(f"{tid}# {seg} [{e['rep']}]")
  738. head = (f'全场判级按台汇总 {len(by_t)} 台 / {len(rows)} 条' +
  739. (f' (筛 level={level})' if level else '') + ':')
  740. tail = '\n注: 一台可有多条(不同系统各自判级), 上表按台合并并按最严级别排序; 未列台不等于正常。'
  741. return dict(text=head + '\n' + '\n'.join(lines) + tail, n=len(rows), n_turbine=len(by_t))
  742. def ont_search(q='', top=8):
  743. """语义+术语混合检索 — 不知道该调哪个工具时的入口 (2026-08-28 检索层)。
  744. 此前模型只能靠 ont_query 的字段过滤找对象, 遇到"哪些机组风险在上升"这类
  745. 没有确定入口的问法就得多轮试探。本工具直接按问句召回最相关的对象并给出 id 与摘要,
  746. 模型拿到 id 后再用 ont_get 取全文即可, 省掉试探。
  747. """
  748. from src.ontology.retrieval import search as _s
  749. hits = _s(q, top=int(top))
  750. if not hits:
  751. return dict(text=f'未检索到与「{q}」相关的对象')
  752. d = db()
  753. lines = []
  754. for oid, sc, src in hits:
  755. o = d.get(oid) or {}
  756. pr = o.get('props') or {}
  757. # 摘要取值顺序 (2026-08-28 实测): verdict 的 props 没有 name 字段, 原来只显示
  758. # "Verdict level=预警" —— 模型看不出这条是什么, 只能靠 id 猜, 于是拿"齿轮箱高速轴预警"
  759. # 去回答"换件后振动降下来了吗", 得出与事实相反的结论。摘要必须带实质内容。
  760. nm = (pr.get('name') or pr.get('summary') or pr.get('conclusion') or pr.get('note')
  761. or pr.get('component') or '')
  762. extra = ' '.join(f'{k}={pr[k]}' for k in
  763. ('turbine', 'level', 'status', 'direction', 'ratio', 'pre', 'post',
  764. 'replace_date', 'review_tier', 'date')
  765. if pr.get(k) not in (None, ''))
  766. lines.append(f"[{oid}] {o.get('type','')} {str(nm)[:70]} {extra}".rstrip())
  767. return dict(text=f'检索「{q}」命中 {len(hits)} 个对象 ({hits[0][2]}):\n' + '\n'.join(lines) +
  768. '\n用 ont_get 取任一 id 的全文。', n=len(hits))
  769. TOOLS = {
  770. 'ont_search': (ont_search, {'q': '哪些机组风险在上升', 'top': 8},
  771. '★混合检索 (术语+语义): 不确定该调哪个工具、或问法没有明确入口时先用它召回相关对象 id'),
  772. 'ont_fleet_verdicts': (ont_fleet_verdicts, {'level': '报警', 'tier': ''},
  773. '★全场判级速查 (全场级列举问题首选: 一次给全 38 台的 台号|部件|级别|审级 紧凑表, 不会被截断)'),
  774. 'ont_chain_fault': (ont_chain_fault, dict(code='3225'), '链二抢修: 报警码→码义/系统→失效模式→手册分步处置WI'),
  775. 'ont_chain_preventive': (ont_chain_preventive, dict(system='齿轮箱'), '链一预防: 系统/部件→失效模式→预防性任务→WI步骤'),
  776. 'ont_fault_tree': (ont_fault_tree, dict(system='变桨'), '链三故障树: 系统失效模式按实证量排序+征兆码'),
  777. 'ont_maint_plan': (ont_maint_plan, dict(top=10), '链四计划: 按风险级与命中数排序的检修排程建议'),
  778. 'ont_workorder': (ont_workorder, dict(turbine='29', system='主轴承'), '检修工单查询: 按台/系统/关键词, 现场实际做过什么+处置分布'),
  779. 'ont_doc_search': (ont_doc_search, dict(keyword='齿轮箱', kind='原理图'), '技术支持: 图纸/原理/手册检索, 返回现场可调阅路径'),
  780. 'ont_brief_pack': (ont_brief_pack, {'turbine': '29'}, '★一站式取证包 (台级问题首选, 一轮取齐: 判级/机制/沙盘/验收/损失/纪律)'),
  781. 'ont_stats': (ont_stats, {}, '本体库概览+判读纪律 (全场级问题开局)'),
  782. 'ont_turbine_brief': (ont_turbine_brief, {'turbine': '29'}, '台级汇总: 判级(带审级)/机制/工单/油样/决策 — 台级问题的入口'),
  783. 'ont_get': (ont_get, {'id': 'verdict/WTG29/TCM-16'}, '按 id 取对象全文 (props+links)'),
  784. 'ont_query': (ont_query, {'type': 'Verdict', 'turbine': '29', 'contains': '', 'exact': '', 'limit': 20},
  785. '按类型/台/关键词过滤查询 (台号用 turbine= 精确参数; contains 数字按词边界; exact= 全等)'),
  786. 'ont_links': (ont_links, {'id': 'mechanism/WTG29/lub_starvation'}, '对象的正向链接与反向被引'),
  787. 'ont_mechanism_search': (ont_mechanism_search, {'keyword': '润滑'}, '失效机制库检索'),
  788. 'ont_scenario': (ont_scenario, {'turbine': '29'}, '排期沙盘/Scenario 推演结果 (建议级, L3 人裁)'),
  789. 'ont_loss': (ont_loss, {'turbine': '', 'months': 3}, '损失分析: 近N月分态/台级损失结构 (kWh; 金额须带电价区间口径; 限电≠设备责任)'),
  790. 'ont_risk': (ont_risk, {}, '风险跟踪面: 联动监视读数+机制状态+验收状态机+盲区纪律 (安全风险类入口)'),
  791. }
  792. CLAIM_CHECK = dict(name='claim_check', description='接地闸: 最终回答前必须调用; 台号/数字须来自本会话工具事实, 越界即拒.',
  793. inputSchema={'type': 'object', 'properties': {'answer': {'type': 'string'}}, 'required': ['answer']})
  794. def _schema(params):
  795. props = {k: {'type': ('number' if isinstance(v, (int, float)) and not isinstance(v, bool) else 'string'),
  796. 'description': f'例: {v}'} for k, v in (params or {}).items()}
  797. return {'type': 'object', 'properties': props}
  798. def tools_list():
  799. return [dict(name=n, description=desc, inputSchema=_schema(ex)) for n, (fn, ex, desc) in TOOLS.items()] + [CLAIM_CHECK]
  800. def tools_call(name, args):
  801. if name == 'claim_check':
  802. if not FACTS:
  803. return {'content': [{'type': 'text', 'text': '接地闸: 尚无工具事实, 先取证。'}], 'isError': True}
  804. ok, bad = grounding((args or {}).get('answer', ''), '\n'.join(FACTS))
  805. txt = '接地闸 通过' if ok else f"接地闸 不通过: 越界台号 {bad['turbines']} / 数字 {bad['numbers']} — 只用工具返回的量改写后再查"
  806. return {'content': [{'type': 'text', 'text': txt}], 'isError': not ok}
  807. if name not in TOOLS:
  808. return {'content': [{'type': 'text', 'text': f'未知工具 {name}'}], 'isError': True}
  809. r = TOOLS[name][0](**(args or {}))
  810. _fact(f"[{name}] {str(r.get('text', ''))[:4000]}")
  811. return {'content': [{'type': 'text', 'text': str(r.get('text', ''))}], 'isError': bool(r.get('error'))}
  812. def serve():
  813. out = sys.stdout
  814. for line in sys.stdin:
  815. line = line.strip()
  816. if not line:
  817. continue
  818. try:
  819. msg = json.loads(line)
  820. except Exception:
  821. continue
  822. mid, method, params = msg.get('id'), msg.get('method', ''), msg.get('params') or {}
  823. resp = None
  824. try:
  825. if method == 'initialize':
  826. resp = {'protocolVersion': params.get('protocolVersion', PROTOCOL),
  827. 'capabilities': {'tools': {'listChanged': False}},
  828. 'serverInfo': {'name': 'ontology', 'version': '0.1.0'},
  829. 'instructions': ('如东风场语义本体 (对象/判级/机制/沙盘)。规则: ①台级问题**首选 ont_brief_pack 一轮取齐**, '
  830. '全场级问题先 ont_stats ②判级只转述对象原文并注明对象id与审级, 禁自创判级 '
  831. '③证据窗外不背书, 未列≠正常, 不可判≠阴性 ④最终回答前必过 claim_check ⑤取证够即答, 勿重复查询。')}
  832. elif method == 'ping':
  833. resp = {}
  834. elif method == 'tools/list':
  835. resp = {'tools': tools_list()}
  836. elif method == 'tools/call':
  837. resp = tools_call(params.get('name'), params.get('arguments'))
  838. elif method.startswith('notifications/'):
  839. continue
  840. else:
  841. if mid is not None:
  842. out.write(json.dumps({'jsonrpc': '2.0', 'id': mid, 'error': {'code': -32601, 'message': method}}) + '\n'); out.flush()
  843. continue
  844. except Exception:
  845. if mid is not None:
  846. out.write(json.dumps({'jsonrpc': '2.0', 'id': mid, 'error': {'code': -32000, 'message': traceback.format_exc()[-600:]}}) + '\n'); out.flush()
  847. continue
  848. if mid is not None:
  849. out.write(json.dumps({'jsonrpc': '2.0', 'id': mid, 'result': resp}, ensure_ascii=False, default=str) + '\n'); out.flush()
  850. if __name__ == '__main__':
  851. argparse.ArgumentParser().parse_args()
  852. serve()