kb_ingest.py 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519
  1. # -*- coding: utf-8 -*-
  2. """知识库图谱化摄入 (2026-08-28 用户令: 四条检修链 + 所有知识库图谱化).
  3. 链一 预防: 数据/表现 → 原理图纸 → 失效模式·部件 → 预防性检修 → WI
  4. 链二 抢修: 故障报警 → 原理图纸 & 工单 → 失效模式·部件 → 故障抢修 → WI
  5. 链三 故障树 + 技术支持: 失效模式 causedBy 树 / Doc explains / 数据逆向
  6. 链四 计划: 按损失·风险排序的排程建议
  7. 源 (全部现场资料, 逐个声明出处 — 码义靠猜会翻案, 见 memory cross-vendor-channel-numbering):
  8. A 故障处理手册.xlsx 海上·SWT-4.0-130 737 条 → AlarmCode + WorkInstruction (分步处置)
  9. B 广核如海…中英文对译表.xlsx 3175 行 → AlarmCode 中文名 (本场码覆盖 263/322)
  10. C 如海故障代码表.xlsx 2793 行 → 告警类型/故障原因/统一风机状态
  11. D 维护作业指导书.xlsx (目视/机械/电气测试) → 预防性 MaintTask + WI
  12. E 技术资料目录 → Doc 索引 (原理图/电气图/手册/定值/结构树)
  13. 纪律: 只收本场实际出现过的码 (alarms.parquet 322 个) + 手册有处置的码; 未覆盖的码显式留白不编造。
  14. 跑法: .venv/bin/python -m src.ontology.kb_ingest
  15. """
  16. try:
  17. from app_common.app_common_guanlan.api import install_root as _install_root
  18. except ImportError: # 理论不可达;包结构异常时回退到按位置上跳
  19. from pathlib import Path as _P
  20. def _install_root(_f): return _P(_f).resolve().parents[4]
  21. from src import paths as P
  22. import pathlib, re, warnings
  23. import pandas as pd
  24. from .store import Store
  25. warnings.filterwarnings('ignore')
  26. TECH = P.RAW_ROOT / '西门子4.0技术资料' # 机理层资料在 data/raw 根下 (原写死 mac 路径)
  27. ONT = P.ont()
  28. ST = P.store()
  29. # 部件/系统归属 (与 windscada workorder 同一套关键词 — 单源纪律)
  30. # ★系统归属改由**原厂结构树**派生 (2026-09-02 用户裁), 不再手写关键词。
  31. #
  32. # 旧写法两个错叠在一起, 把变桨那 298 条挂错了 108 条:
  33. # ① 关键词过宽: 「液压/轮毂/叶片」被划进变桨, 而偏航有液压站、舱门有液压站、
  34. # 主液压站是独立的; 轮毂属主轴系; 叶片扰流器是气动件
  35. # ② 顺序优先且首命中即返回: 变桨那条排第一, 「偏航液压泵过热」里的「液压」先命中,
  36. # 永远轮不到「偏航」那条
  37. # 实逮: 7120 偏航液压泵过热 / 7122 偏航液压油位低 / 64040 主轴承(轮毂)温度过高
  38. # 全部挂在变桨下, 连带把 failmode 的「本场报警 N 条」计数也污染了。
  39. #
  40. # 新写法: 词表取自结构树节点名 (叶子/二级/一级), **最长匹配优先**。
  41. # 结构树的名字本身是专有的 —— 「偏航液压制动系统」「变桨泵站」不会互相误伤,
  42. # 而裸「液压」根本不是任何节点名, 于是不再产生泛词误配。
  43. # 顺序不再决定归属, 特异性决定。
  44. _SYS_TERMS_CACHE = None
  45. def _sys_terms():
  46. """(词, 项目九系统) 列表, 按词长降序 —— 长词=更专, 先匹配。"""
  47. global _SYS_TERMS_CACHE
  48. if _SYS_TERMS_CACHE is not None:
  49. return _SYS_TERMS_CACHE
  50. import json as _j, pathlib as _p
  51. # 结构树二级 → 项目九系统 (九系统是交付分册口径, 保持不动; 见 CLAUDE.md 两层并存)
  52. L2 = {'叶片': '叶片与叶根', '导流罩模块': '叶片与叶根',
  53. '转子系统': '变桨', '变桨液压': '变桨', '变桨系统': '变桨',
  54. '传动结构': '齿轮箱', '齿轮箱系统': '齿轮箱',
  55. '发电机': '发电机', '发电机系统': '发电机', '集中润滑': '主轴承',
  56. '偏航器': '偏航', '偏航系统': '偏航',
  57. '塔筒': '塔架与基础', '基础环': '塔架与基础', '塔筒爬梯装置': '塔架与基础',
  58. '变压器和开关设备': '变流器', '变频系统': '变流器', '动力装置': '变流器',
  59. '主控系统': '主控与传感网', '控制和保护系统': '主控与传感网',
  60. '塔基控制柜': '主控与传感网',
  61. '机舱罩': None, '机架': None, '机舱吊车系统': None, '灭火设备': None,
  62. '急救装置': None, '等电位连接': None, '液压系统': None} # None = 不强判
  63. terms = []
  64. # path 必须相对**仓库根**而非 cwd -- 服务侧 (mcp_server) 的 cwd 与入库侧不同,
  65. # 读不到会静默退化成半张词表 (只剩九系统名 + 同义词), 分类质量无声下降。
  66. # 读不到就响亮打印, 不假装成功。
  67. _root = _install_root(__file__)
  68. _f = P.objects_json()
  69. try:
  70. db_ = _j.loads(_f.read_text(encoding='utf-8'))
  71. except Exception as _e:
  72. import sys as _s
  73. print('WARN _sys_terms 读不到本体 %s (%s) -- 词表退化, 分类将不完整' % (_f, _e), file=_s.stderr)
  74. db_ = {}
  75. # ① RDS-PP 叶子: 最专, 经其所属二级映射到九系统
  76. for k, v in db_.items():
  77. if not k.startswith('maintsystem/rdspp/'):
  78. continue
  79. nm = str((v.get('props') or {}).get('name_zh') or '')
  80. if not nm or not any('\u4e00' <= c <= '\u9fff' for c in nm):
  81. continue
  82. # 叶子名里含哪个二级词就归哪个二级 (如「偏航驱动」「偏航泵站阀块」→ 偏航器)
  83. sysn = next((L2[t] for t in sorted(L2, key=len, reverse=True)
  84. if t in nm and L2.get(t)), None)
  85. if sysn is None:
  86. for a, b in (('偏航', '偏航'), ('变桨', '变桨'), ('叶片', '叶片与叶根'),
  87. ('主轴', '主轴承'), ('齿轮', '齿轮箱'), ('发电机', '发电机')):
  88. if a in nm:
  89. sysn = b
  90. break
  91. if sysn:
  92. terms.append((nm, sysn))
  93. # ② 结构树二级/一级
  94. for k, v in db_.items():
  95. if not k.startswith('maintsystem/tree/'):
  96. continue
  97. nm = str((v.get('props') or {}).get('name_zh') or '')
  98. if L2.get(nm):
  99. terms.append((nm, L2[nm]))
  100. # ③ 兜底: 九系统自身的名字 (专有, 不含泛词)
  101. for nm in ('变桨', '偏航', '齿轮箱', '发电机', '主轴承', '变流器', '塔架与基础',
  102. '主控与传感网', '叶片与叶根'):
  103. terms.append((nm, nm))
  104. # ④ 结构树词表覆盖不到但确属专有的同义词 (**不含任何泛词**)
  105. # ★这批是旧 SYSKW 里**本来就对**的专有词。我换结构树词表时把它们连同泛词一起删了,
  106. # dry-run 逮到 228 条撤边里混着「风向标阻塞」「通讯模块错误」「网侧DI4信号错误」——
  107. # 全是判得出的。**过度纠正也是错**: 该删的是共享泛词 (液压/轮毂), 不是专有词。
  108. terms += [('风向标', '主控与传感网'), ('风速仪', '主控与传感网'), ('风速传感', '主控与传感网'),
  109. ('PLC', '主控与传感网'), ('UPS', '主控与传感网'), ('光纤', '主控与传感网'),
  110. ('RMIO', '主控与传感网'), ('看门狗', '主控与传感网'),
  111. ('网侧', '变流器'), ('机侧', '变流器'), ('ISU', '变流器'), ('INU', '变流器'),
  112. ('直流母线', '变流器'), ('水冷', '变流器'), ('并网', '变流器')]
  113. # 齿轮箱族叶子词: 结构树只到「传动结构/齿轮箱系统」二级, 叶子名在手册里另有叫法。
  114. # `齿轮油`/`变速箱`/`齿轮支架`/`高速齿轮` 都是**专有复合词**, 不是共享泛词 ——
  115. # 注意不能加裸 `齿轮`: 偏航齿轮也含它, 且与 `偏航` 同为 2 字, 最长匹配分不开。
  116. # (dry-run 逮: 4100~4315 九条齿轮油告警 + 七条变速箱维护作业全被撤边。)
  117. terms += [('齿轮油', '齿轮箱'), ('变速箱', '齿轮箱'), ('齿轮支架', '齿轮箱'),
  118. ('高速齿轮', '齿轮箱'), ('中速齿轮', '齿轮箱'), ('低速齿轮', '齿轮箱'),
  119. ('气象站', '主控与传感网'),
  120. # 现场码表写的是「风速计」不是「风速仪」-- 只收后者会把 8108/8000/8186/
  121. # 8184/1005 等 296 条本场报警判成无归属 (dry-run 实逮的倒退向)。
  122. ('风速计', '主控与传感网'), ('风速', '主控与传感网'), ('风向', '主控与传感网')]
  123. terms += [('变浆', '变桨'), ('桨距', '变桨'), ('桨叶', '变桨'),
  124. ('解缆', '偏航'), ('扭缆', '偏航'), ('对风', '偏航'),
  125. ('齿箱', '齿轮箱'), ('定子', '发电机'), ('绕组', '发电机'),
  126. ('滑环', '发电机'), ('励磁', '发电机'), ('主轴', '主轴承'),
  127. ('变频', '变流器'), ('变流', '变流器'), ('逆变', '变流器'),
  128. ('IGBT', '变流器'), ('塔筒', '塔架与基础'), ('塔架', '塔架与基础'),
  129. ('叶根', '叶片与叶根'), ('叶片', '叶片与叶根')]
  130. # ★第二梯队: **角色泛名**, 只在第一梯队全不命中时才用。
  131. # 最长匹配在这里选错轴 —— 「偏航位置传感器错误」里 `传感器`(3字) 压过 `偏航`(2字),
  132. # 把它判成主控与传感网。传感器属于它所服务的系统, 除非文本没点名任何系统。
  133. # (memory alarm-same-sensor-not-independent: 温度与同部件保护告警常同测点。)
  134. seen, out = set(), []
  135. for t, sysn in sorted(terms, key=lambda x: -len(x[0])):
  136. if t not in seen:
  137. seen.add(t)
  138. out.append((t, sysn))
  139. out += [(t, sysn) for t, sysn in _TIER_TAIL if t not in seen]
  140. _SYS_TERMS_CACHE = out
  141. return out
  142. # 失效模式分类关键词 (ISO14224 风格)
  143. FMKW = [(r'磨损|磨蚀|点蚀|剥落', '磨损'), (r'疲劳|裂纹|开裂|断裂', '疲劳'),
  144. (r'润滑|油脂|缺油|油位低|打脂', '润滑失效'), (r'腐蚀|锈|盐雾', '腐蚀'),
  145. (r'过温|高温|温度高|过热|超温', '过热'), (r'松动|预紧|力矩|紧固|螺栓', '松动/紧固失效'),
  146. (r'高压|低压|压力|液压|气压|蓄能|油压', '液压/气动失效'),
  147. (r'污染|异物|杂质|堵塞|滤芯', '污染/异物'), (r'短路|断路|接地|绝缘|电压|电流|供电|电源', '电气失效'),
  148. (r'通讯|通信|软件|程序|逻辑|超时|看门狗', '控制/软件'), (r'密封|渗漏|泄漏|漏油', '密封失效'),
  149. (r'传感器|编码器|测量|信号异常|反馈', '测量链失效')]
  150. # 「叶片X」既可能是叶片本体 (气动/结构), 也可能是**变桨执行件** —— 结构树把后者放在
  151. # 轮毂/变桨液压 (变桨阀块) 与 轮毂/转子系统 (叶片轴承/变桨执行机构) 之下。
  152. # 判别靠伴随词: 阀/编码器/驱动/电机/轴承/限位/顺桨 ⇒ 变桨; 扰流器/失速条/尾缘/开裂/雷击 ⇒ 叶片本体。
  153. # (dry-run 实逮: 「3103 轮毂:叶片A阀错误」按裸「叶片」判成叶片与叶根, 实为变桨阀块。)
  154. # ── 梯队尾部: 第一梯队 (结构树专名) 全不命中时, 按此**次序**兜底 ─────────────
  155. # ② 总成: `轮毂` 是共享设施词 (旧 SYSKW 的病根之一, 词表断言明令它不得进第一梯队) ——
  156. # 「轮毂处于服务状态时主轴转速过高」里它会抢走 `主轴`。但结构树把变桨执行与叶片轴承
  157. # 都放在轮毂总成内, 所以文本**只**点了轮毂时 (「轮毂温度高」) 归变桨是结构树支持的。
  158. # **位置决定它安全: 同一个词在第一梯队是 bug, 在兜底位是信息。**
  159. # ③ 角色泛名: 传感器/通讯/控制器 说的是**角色**不是系统。「偏航位置传感器错误」里
  160. # `传感器`(3字) 会按最长匹配压过 `偏航`(2字) —— 传感器属于它所服务的系统。
  161. # 总成必须排在角色泛名之前:「轮毂温度传感器错误」轮毂是系统名, 传感器是角色名。
  162. _TIER_TAIL = (('轮毂', '变桨'),
  163. ('传感器', '主控与传感网'), ('通讯', '主控与传感网'),
  164. ('通信', '主控与传感网'), ('控制器', '主控与传感网'),
  165. ('编码器', '主控与传感网'))
  166. _PITCH_ACT = re.compile(r'阀|编码器|驱动|电机|轴承|限位|顺桨|蓄能|油缸|液压缸|变桨|润滑|角度|桨角'
  167. r'|油压|压力|高压|低压|桨距|反馈|同步|校准') # 叶片A低油压/叶片高压 = 变桨液压不是叶片本体
  168. # 「蓄能器A/B/C」带**叶片字母后缀** = 每叶片一套的变桨蓄能器 (3411/3412/3413/3216/3218)。
  169. # 裸「蓄能器」不判 -- 主液压站与高速制动器也各有蓄能器, 九系统里没有它们的家。
  170. # (我删裸「蓄能」这个泛词时把带后缀的一并误伤了, dry-run 逮。)
  171. _PITCH_ACC = re.compile(r'蓄能器[^,,。;;]{0,4}[ABC](?![a-zA-Z])|[ABC]\s*蓄能器|叶片\s*[ABC].{0,6}蓄能')
  172. _BLADE_OWN = re.compile(r'扰流器|失速条|尾缘|前缘|开裂|雷击|接闪|防雷|避雷|叶尖|打磨|螺栓|翼板')
  173. # 九系统里**没有家**的系统名 (L2 映射里判 None 的那批: 液压站/灭火/急救/机架/吊车/机舱罩)。
  174. # 它们必须在角色泛名兜底**之前**拦下 —— 否则「液压压力传感器错误」会靠 `传感器` 落进
  175. # 主控与传感网, 那是错的: 它点了系统(液压站), 只是这个分类体系没给它位置。
  176. # **判不出**和**没有家**都返回 None, 但成因不同; 这是分类体系的缺口, 不是判据的漏。
  177. # (与「塔基/变压器和开关设备」同类 —— 用户 2026-09-01 裁「先交付, 回头再改」。)
  178. _HOMELESS = re.compile(r'液压站|液压|灭火|烟雾|消防|急救|应急照明|紧急停止|急停'
  179. r'|机舱罩|机架|吊车|刹车|制动器')
  180. def _sys_of(txt):
  181. """文本 → 九系统。**位置优先**, 同位再比长度; 判不出返回 None (宁可无归属也不错挂)。
  182. 为什么按位置而不是最长匹配: 中文是修饰语在前, **前置的系统词限定整个词组**。
  183. 「偏航齿轮箱油位低」里 `齿轮箱`(3字) 会按最长匹配压过 `偏航`(2字) —— 但那是偏航
  184. 减速箱不是主齿轮箱。同族的错这是第三次 (前两次: 液压压过偏航 / 传感器压过偏航),
  185. 根因都是"用词长当专指度"。位置是更贴合中文构词的专指度代理。
  186. 梯队仍在: 第一梯队 (结构树专名) 全不命中才轮到总成 `轮毂`, 再才是角色泛名。
  187. """
  188. t = str(txt)
  189. if '叶片' in t and not _BLADE_OWN.search(t) and _PITCH_ACT.search(t):
  190. return '变桨' # 叶片X阀 / 叶片编码器 = 变桨执行件
  191. if _PITCH_ACC.search(t):
  192. return '变桨' # 蓄能器A/B/C = 每叶片一套的变桨蓄能器
  193. terms = _sys_terms()
  194. n_head = len(terms) - len(_TIER_TAIL) # 第一梯队长度
  195. hits = [(t.index(w), -len(w), sysn) for w, sysn in terms[:n_head] if w in t]
  196. if hits:
  197. return min(hits)[2]
  198. hm = _HOMELESS.search(t)
  199. tail = [(t.index(w), sysn) for w, sysn in terms[n_head:] if w in t]
  200. # 无家闸**也按位置**: 「CAN通讯错误—刹车信号」主语是通讯不是刹车, 位置在前者说了算。
  201. # 只在无家词出现得**更早**时才判 None, 否则它只是顺带提到。
  202. if hm and (not tail or hm.start() < min(x[0] for x in tail)):
  203. return None # 点了系统, 但九系统没这个家
  204. for w, sysn in terms[n_head:]: # 总成 → 角色泛名, 保持既定次序
  205. if w in t:
  206. return sysn
  207. return None
  208. def _fm_of(txt):
  209. for pat, c in FMKW:
  210. if re.search(pat, str(txt)): return c
  211. return '未定'
  212. def _comp_id(sysname):
  213. # ★补「叶片与叶根」→ blade: 它是九系统之一, 但此前**没有对应的 component 对象**,
  214. # 于是任何判成叶片的都静默落进 misc。dry-run 逮到 (3103 轮毂:叶片A阀 → misc)。
  215. return f'component/fleet/{ {"变桨": "pitch", "偏航": "yaw", "齿轮箱": "gearbox", "发电机": "generator", "主轴承": "main_bearing", "变流器": "converter", "塔架与基础": "tower", "主控与传感网": "control", "叶片与叶根": "blade"}.get(sysname, "misc") }'
  216. def load_sources():
  217. """四源装载 + 覆盖率报数 (缺哪块响亮, 不静默降级)."""
  218. out, miss = {}, []
  219. def rd(key, path, **kw):
  220. f = TECH / path
  221. if not f.exists(): miss.append(str(path)); return None
  222. try:
  223. out[key] = pd.read_excel(f, **kw); return out[key]
  224. except Exception as e:
  225. miss.append(f'{path} 读失败 {str(e)[:40]}'); return None
  226. rd('hb', '故障处理/故障处理手册.xlsx', sheet_name='海上')
  227. rd('tr', '广核如海风电场西门子风机故障代码中英文对译表.xlsx', sheet_name='Sheet1')
  228. rd('rh', '如海故障代码表.xlsx', sheet_name='Sheet1')
  229. for sh in ('目视部分', '机械部分', '电气测试'):
  230. rd(f'wi_{sh}', '维护相关/维护作业指导书.xlsx', sheet_name=sh)
  231. if miss:
  232. print('⚠ 源缺失/读失败:', miss)
  233. return out
  234. def build(dry=False):
  235. src = load_sources()
  236. s = Store(ONT / 'objects.json')
  237. al = pd.read_parquet(ST / 'alarms.parquet')
  238. live_codes = set(al.code.astype(str).str.strip())
  239. live_cnt = al.code.astype(str).str.strip().value_counts().to_dict()
  240. stat = dict(AlarmCode=0, WorkInstruction=0, FailureMode=0, MaintTask=0, Doc=0, Component=0)
  241. # 部件对象 (fleet 级, 供失效模式挂靠)
  242. # ★九系统全集: 原先这里只有 8 个, 漏了「叶片与叶根」—— 而 `_comp_id()` 的映射表里**有**它,
  243. # 于是 458 条 alarmcode 的 about 指向 `component/fleet/blade` 全是悬空引用。
  244. # 2026-09-12 跑 `python -m src.ontology.audit` 逮到(悬空 458 条), 与 `_comp_id` 注释里
  245. # "补「叶片与叶根」→ blade" 那次修改是同一件事漏了后半截。
  246. for sysname in ('变桨', '偏航', '齿轮箱', '发电机', '主轴承', '变流器', '塔架与基础', '主控与传感网', '叶片与叶根'):
  247. cid = _comp_id(sysname)
  248. if cid not in s.objects:
  249. s.put(dict(id=cid, type='Component', props=dict(name=sysname, level='系统', scope='fleet'), links={}))
  250. stat['Component'] += 1
  251. # ---- 链二: 报警码 → 处置 WI ----
  252. hb, tr, rh = src.get('hb'), src.get('tr'), src.get('rh')
  253. cn = {}
  254. if tr is not None:
  255. for _, r in tr.iterrows():
  256. cn[str(r['故障代码']).strip()] = str(r.get('中文名称', '') or '').strip()
  257. rhmap = {}
  258. if rh is not None and '状态编码' in rh.columns:
  259. for _, r in rh.iterrows():
  260. k = str(r['状态编码']).strip()
  261. rhmap[k] = dict(告警类型=str(r.get('告警类型', '') or ''), 故障原因=str(r.get('故障原因', '') or ''),
  262. 统一状态=str(r.get('统一风机状态', '') or ''))
  263. hbmap = {}
  264. if hb is not None:
  265. hb4 = hb[hb['机型'].astype(str).str.contains('4.0-130', na=False)]
  266. for _, r in hb4.iterrows():
  267. code = str(r['故障代码']).strip()
  268. steps = []
  269. for i in range(10):
  270. intro = r.get(f'solutions.{i}.introduction')
  271. name = r.get(f'solutions.{i}.solutionName')
  272. if pd.notna(intro) and str(intro).strip():
  273. steps.append(dict(序=i + 1, 类别=str(name or '').strip(), 步骤=str(intro).strip()))
  274. hbmap[code] = dict(desc=str(r['故障描述']).strip(), steps=steps)
  275. for code in sorted(live_codes | set(hbmap)):
  276. h = hbmap.get(code, {})
  277. name = h.get('desc') or cn.get(code) or ''
  278. meta = rhmap.get(code, {})
  279. sysname = _sys_of(name + ' ' + meta.get('故障原因', ''))
  280. links = {}
  281. if sysname: links['about'] = [_comp_id(sysname)]
  282. s.put(dict(id=f'alarmcode/{code}', type='AlarmCode',
  283. props=dict(code=code, name=name or '(码表未收录)', name_cn=cn.get(code, ''),
  284. system=sysname, 告警类型=meta.get('告警类型', ''), 故障原因=meta.get('故障原因', ''),
  285. 本场计数=int(live_cnt.get(code, 0)), 本场出现=code in live_codes,
  286. 有处置=bool(h.get('steps')),
  287. source='故障处理手册.xlsx 海上SWT-4.0-130 + 对译表 + 如海故障代码表'),
  288. links=links))
  289. stat['AlarmCode'] += 1
  290. if h.get('steps'):
  291. wid = f'wi/fault/{code}'
  292. s.put(dict(id=wid, type='WorkInstruction',
  293. props=dict(name=f'{code} {h["desc"]} — 抢修处置', kind='抢修',
  294. steps=h['steps'], n_step=len(h['steps']),
  295. source='上海电气 故障处理手册.xlsx (海上 SWT-4.0-130)'),
  296. links=dict(about=[f'alarmcode/{code}'], **({'appliesTo': [_comp_id(sysname)]} if sysname else {}))))
  297. stat['WorkInstruction'] += 1
  298. # 抢修任务 (事件驱动)
  299. s.put(dict(id=f'task/fault/{code}', type='MaintTask',
  300. props=dict(name=f'{code} {h["desc"]} 抢修', task_kind='抢修',
  301. trigger=f'报警码 {code}', source='故障处理手册'),
  302. links=dict(hasInstruction=[wid], about=[f'alarmcode/{code}'],
  303. **({'appliesTo': [_comp_id(sysname)]} if sysname else {}))))
  304. stat['MaintTask'] += 1
  305. # ---- 链一: 预防性检修 WI + 任务 (维护作业指导书 三部分) ----
  306. for sh, kind in (('目视部分', '目视检查'), ('机械部分', '机械作业'), ('电气测试', '电气测试')):
  307. d = src.get(f'wi_{sh}')
  308. if d is None: continue
  309. d = d.copy(); d.columns = [str(c).strip() for c in d.columns]
  310. itemcol = '操作项目' if '操作项目' in d.columns else d.columns[1]
  311. for _, r in d.iterrows():
  312. step, item, std = r.get('步骤'), r.get(itemcol), r.get('检查标准')
  313. if pd.isna(step) or pd.isna(item): continue
  314. step, item = str(step).strip(), str(item).strip()
  315. if not step or not item: continue
  316. loc = (re.match(r'^([A-Z]\d{1,2})', item) or [None, ''])[1]
  317. sysname = _sys_of(item + ' ' + str(std or ''))
  318. wid = f'wi/maint/{step}'
  319. steps = [dict(序=i + 1, 步骤=x.strip()) for i, x in enumerate(re.split(r'[\n;]', str(std or ''))) if x.strip()]
  320. s.put(dict(id=wid, type='WorkInstruction',
  321. props=dict(name=item[:80], kind=kind, 步骤号=step, 位置代号=loc,
  322. steps=steps or [dict(序=1, 步骤='(手册未给分步, 见原文)')],
  323. 工时h=(float(r['作业时间']) if pd.notna(r.get('作业时间')) and str(r.get('作业时间')).replace('.','',1).isdigit() else None),
  324. 技术人员=str(r.get('技术人员') or '') or None,
  325. source='SWT-4.0-130 维护作业指导书.xlsx · ' + sh),
  326. links={'appliesTo': [_comp_id(sysname)]} if sysname else {}))
  327. stat['WorkInstruction'] += 1
  328. s.put(dict(id=f'task/maint/{step}', type='MaintTask',
  329. props=dict(name=item[:80], task_kind='预防性', interval='年度',
  330. 位置代号=loc, source='年度维护检查表 (A9B1039475) + 维护作业指导书'),
  331. links=dict(hasInstruction=[wid], **({'appliesTo': [_comp_id(sysname)]} if sysname else {}))))
  332. stat['MaintTask'] += 1
  333. wof = ST / 'workorders.parquet'
  334. wo = pd.read_parquet(wof) if wof.exists() else pd.DataFrame()
  335. # ---- 失效模式: 从实证归纳 (报警码描述 + 工单), 不编造; 每个模式记来源与证据量 ----
  336. fm_acc = {}
  337. for oid, o in list(s.objects.items()):
  338. if o['type'] != 'AlarmCode': continue
  339. pr = o['props']; sysname = pr.get('system')
  340. if not sysname: continue
  341. cls = _fm_of(str(pr.get('name', '')) + ' ' + str(pr.get('故障原因', '')))
  342. if cls == '未定': continue
  343. k = (sysname, cls)
  344. a = fm_acc.setdefault(k, dict(codes=[], n_alarm=0, wo=0))
  345. a['codes'].append(pr['code']); a['n_alarm'] += int(pr.get('本场计数', 0))
  346. if wof.exists():
  347. for _, r in wo.iterrows():
  348. txt = ' '.join(str(r.get(c, '') or '') for c in ('故障名称', '故障描述', '维修对象', '故障位置二级'))
  349. sysname, cls = _sys_of(txt), _fm_of(txt)
  350. if not sysname or cls == '未定': continue
  351. fm_acc.setdefault((sysname, cls), dict(codes=[], n_alarm=0, wo=0))['wo'] += 1
  352. for (sysname, cls), a in sorted(fm_acc.items()):
  353. fid = f'failmode/{sysname}/{cls}'
  354. s.put(dict(id=fid, type='FailureMode',
  355. props=dict(name=f'{sysname} · {cls}', fm_class=cls, system=sysname,
  356. n_codes=len(a['codes']), 本场报警条数=a['n_alarm'], 工单条数=a['wo'],
  357. 示例码=a['codes'][:8],
  358. source='归纳自本场报警码表(有系统归属者)+工单文本, 非厂商FMEA — 粒度=系统×失效类'),
  359. links=dict(affects=[_comp_id(sysname)],
  360. indicates=[f'alarmcode/{c}' for c in a['codes'][:20]],
  361. mitigatedBy=[f'task/fault/{c}' for c in a['codes'][:20] if f'task/fault/{c}' in s.objects])))
  362. stat['FailureMode'] += 1
  363. # ---- 链三 故障树: 系统级失效模式 causedBy 部件级原因节点 ----
  364. # 原因节点 = 手册 solutionName (排查方向, 448 种) 归一 + 工单"故障原因"实证计数。
  365. # 纪律: 原因来自手册与工单原文, 不是我推的因果; 每个节点记 n_codes/n_ref/工单命中, 树深两层不外推。
  366. CAUSE_NORM = [
  367. (r'线路|电缆|线缆|端子|接线|插头|插件|连接器', '线路·接线', '电气失效'),
  368. (r'温度传感器|压力传感器|传感器|编码器|探头|开关信号|限位', '传感器·编码器', '测量链失效'),
  369. (r'IO模块|IO板|模块|板卡|主板|电网模块|CPU|通讯板', '模块·板卡', '电气失效'),
  370. (r'风扇|散热|滤棉|滤芯|冷却|换热|水泵', '散热·通风·冷却', '过热'),
  371. (r'接触器|继电器|断路器|保险|熔断|开关电源|UPS|电源', '开关·供电元件', '电气失效'),
  372. (r'参数|设定|软件|程序|逻辑|复位|重启', '参数·软件', '控制/软件'),
  373. (r'液压|油泵|阀|油管|蓄能|油缸|压力', '液压元件', '液压/气动失效'),
  374. (r'电机|马达|驱动器|变频|变流', '驱动·变流元件', '电气失效'),
  375. (r'螺栓|紧固|力矩|预紧', '螺栓·紧固', '松动/紧固失效'),
  376. (r'油脂|润滑|注脂|油位', '润滑供给', '润滑失效'),
  377. (r'轴承|齿轮|齿面|联轴器', '机械传动件', '磨损'),
  378. (r'密封|渗漏|漏油|油封', '密封件', '密封失效'),
  379. ]
  380. def _cause_of(name):
  381. for pat, node, cls in CAUSE_NORM:
  382. if re.search(pat, str(name)): return node, cls
  383. return None, None
  384. # a) 从手册: 码 → 排查方向; 聚合成原因节点并连到该码所属的系统级失效模式
  385. cause_acc = {}
  386. for code, h in hbmap.items():
  387. a = s.objects.get(f'alarmcode/{code}')
  388. if not a: continue
  389. sysname = a['props'].get('system')
  390. if not sysname: continue
  391. for st in h.get('steps', []):
  392. node, cls = _cause_of(st.get('类别', '') + ' ' + st.get('步骤', ''))
  393. if not node: continue
  394. k = (sysname, node)
  395. acc = cause_acc.setdefault(k, dict(cls=cls, codes=set(), n_ref=0, wo=0, steps=[]))
  396. acc['codes'].add(code); acc['n_ref'] += 1
  397. if len(acc['steps']) < 6: acc['steps'].append(f"[{code}] {st.get('步骤','')[:70]}")
  398. # b) 从工单实证: 故障原因/排查项目文本命中同一批原因节点 → 计数 (真实发生过多少次)
  399. if wof.exists():
  400. for _, r in wo.iterrows():
  401. txt = ' '.join(str(r.get(c, '') or '') for c in ('故障原因', '排查项目', '元器件名称', '维修对象'))
  402. sysname, _ = _sys_of(txt), None
  403. node, cls = _cause_of(txt)
  404. if not sysname or not node: continue
  405. acc = cause_acc.setdefault((sysname, node), dict(cls=cls, codes=set(), n_ref=0, wo=0, steps=[]))
  406. acc['wo'] += 1
  407. for (sysname, node), a in sorted(cause_acc.items()):
  408. cid = f'failmode/{sysname}/原因/{node}'
  409. parent = f'failmode/{sysname}/{a["cls"]}'
  410. s.put(dict(id=cid, type='FailureMode',
  411. props=dict(name=f'{sysname} · {node}', fm_class=a['cls'], system=sysname, level='部件级原因',
  412. n_codes=len(a['codes']), 手册引用次数=a['n_ref'], 工单命中=a['wo'],
  413. 示例码=sorted(a['codes'])[:8], 典型排查=a['steps'],
  414. source='手册 solutionName 归一 + 工单故障原因/排查项目文本计数; 树深两层, 不外推更深因果'),
  415. links=dict(affects=[_comp_id(sysname)],
  416. indicates=[f'alarmcode/{c}' for c in sorted(a['codes'])[:20]])))
  417. stat['FailureMode'] += 1
  418. stat.setdefault('causedBy', 0)
  419. # 系统级失效模式 ← causedBy ← 部件级原因 (父存在才连, 不造父)
  420. if parent in s.objects:
  421. pa = s.objects[parent]
  422. lk = pa.setdefault('links', {}).setdefault('causedBy', [])
  423. if cid not in lk:
  424. lk.append(cid); stat['causedBy'] += 1
  425. s.put(pa, force=True, when='2026-08-28')
  426. # ---- 技术资料索引 (Doc): 图纸/原理/手册/定值可检索 ----
  427. KIND = [(r'原理图|原理', '原理图'), (r'电气图|接线', '电气图'), (r'液压图', '液压图'), (r'爆炸图', '爆炸图'),
  428. (r'结构树', '结构树'), (r'定值|参数', '定值参数'), (r'维护手册|安装维护|维护', '维护手册'),
  429. (r'故障处理|故障逻辑|故障代码', '故障处置'), (r'培训|课件', '培训'), (r'说明书', '说明书')]
  430. if TECH.exists():
  431. for f in sorted(TECH.rglob('*')):
  432. if not f.is_file() or f.name.startswith('~$') or f.suffix.lower() not in ('.pdf', '.xlsx', '.xls', '.doc', '.docx', '.pptx'):
  433. continue
  434. nm = f.name
  435. kind = next((k for pat, k in KIND if re.search(pat, nm)), '其他')
  436. sysname = _sys_of(nm)
  437. s.put(dict(id='doc/' + re.sub(r'[^0-9A-Za-z\u4e00-\u9fa5]+', '_', str(f.relative_to(TECH)))[:110],
  438. type='Doc',
  439. props=dict(name=nm[:90], kind=kind, path=str(f), rel=str(f.relative_to(TECH)),
  440. system=sysname, size_kb=round(f.stat().st_size / 1024)),
  441. links={'explains': [_comp_id(sysname)]} if sysname else {}))
  442. stat['Doc'] += 1
  443. # ---- 孤儿消化 (审计 ②): 不硬塞归属, 而是给"未归属"一个明确的家 ----
  444. s.put(dict(id='catalog/alarmcode', type='Doc',
  445. props=dict(name='报警码本 (本场出现 ∪ 手册收录)', kind='码本',
  446. path='(派生自 故障处理手册 + 对译表 + 如海故障代码表 + 本场 alarms)',
  447. note='未归属系统的码 = 码表无部件线索, 非"无关"; 现场定位需查图纸或厂商'),
  448. links={}))
  449. n_orph_code = 0
  450. for oid, o in list(s.objects.items()):
  451. if o['type'] != 'AlarmCode' or o.get('links'): continue
  452. o.setdefault('links', {})['partOf'] = ['catalog/alarmcode']
  453. s.put(o, force=True, when='2026-08-28'); n_orph_code += 1
  454. # 文档: 无系统归属者按顶层目录归类 (目录名本身就是分类信息)
  455. n_orph_doc = 0
  456. for oid, o in list(s.objects.items()):
  457. if o['type'] != 'Doc' or o.get('links') or oid == 'catalog/alarmcode': continue
  458. top = str(o['props'].get('rel', '')).split('/')[0][:40] or '根目录'
  459. cat = 'catalog/doc/' + re.sub(r'[^0-9A-Za-z\u4e00-\u9fa5]+', '_', top)[:60]
  460. if cat not in s.objects:
  461. s.put(dict(id=cat, type='Doc', props=dict(name=f'资料目录 · {top}', kind='目录',
  462. path=str(TECH / top)), links={}))
  463. o.setdefault('links', {})['partOf'] = [cat]
  464. s.put(o, force=True, when='2026-08-28'); n_orph_doc += 1
  465. stat['孤儿归位'] = dict(码=n_orph_code, 文档=n_orph_doc)
  466. if dry:
  467. print('DRY:', stat); return stat
  468. s.save()
  469. print('kb_ingest:', stat)
  470. return stat
  471. if __name__ == '__main__':
  472. # 控制台可能是 GBK(中文 Windows 代码页 936): 正文里的 ✔ ✗ ✅ ⚠ 这类字符编不出来会抛
  473. # UnicodeEncodeError, 脚本干成了事却以退出码 1 结束(同类坑见 src/console.py)。降级为 '?' 而不是崩;
  474. # 不用 import 是为了兼顾 python -m 与直接当脚本跑两种启动方式。
  475. import sys as _sys
  476. for _s in (_sys.stdout, _sys.stderr):
  477. try: _s.reconfigure(errors='replace')
  478. except Exception: pass
  479. import sys
  480. build(dry='--dry' in sys.argv)