audit.py 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101
  1. # -*- coding: utf-8 -*-
  2. """本体机器审计器 (2026-08-28 用户令: ontology 深度优化和审核).
  3. 六项: ①悬空引用 ②孤儿对象 ③链接密度(WorkOrder.resolves/OilSample→部件) ④字段覆盖
  4. ⑤判级对拍(抽样 vs L1 实时) ⑥facet 分布。输出问题清单, 不静默PASS。
  5. 跑法: .venv/bin/python -m src.ontology.audit"""
  6. from src import paths as P
  7. import json, pathlib, random
  8. from collections import Counter
  9. ONT = P.objects_json()
  10. def audit(sample_turbines=3, seed=7):
  11. d = json.loads(ONT.read_text(encoding='utf-8'))
  12. issues, stats = [], {}
  13. # ① 悬空引用
  14. dangling = [(o['id'], rel, x) for o in d.values()
  15. for rel, ids in o.get('links', {}).items() for x in ids if x not in d]
  16. stats['悬空引用'] = len(dangling)
  17. if dangling:
  18. issues.append(f"①悬空引用 {len(dangling)} 条 (links 指向不存在 id), 样例: {dangling[:5]}")
  19. # ② 孤儿对象 (无出链且无入链; Turbine/元数据类豁免)
  20. linked = set()
  21. for o in d.values():
  22. for ids in o.get('links', {}).values():
  23. linked.update(ids)
  24. exempt = {'Turbine', 'MaintenanceWindow'}
  25. orphans = [o['id'] for o in d.values()
  26. if o['type'] not in exempt and not o.get('links') and o['id'] not in linked
  27. and not o['id'].startswith(('meta/', 'decision/user', 'decision/pending'))]
  28. stats['孤儿对象'] = len(orphans)
  29. if orphans:
  30. issues.append(f"②孤儿对象 {len(orphans)} 个 (无链接, 查询游走不可达), 类型分布: "
  31. f"{dict(Counter(d[i]['type'] for i in orphans))}, 样例: {orphans[:5]}")
  32. # ③ 链接密度
  33. wo = [o for o in d.values() if o['type'] == 'WorkOrder']
  34. wo_resolve = sum(1 for o in wo if o.get('links', {}).get('resolves'))
  35. stats['工单resolves率'] = f'{wo_resolve}/{len(wo)}'
  36. if wo_resolve == 0:
  37. issues.append(f"③工单 {len(wo)} 条零 resolves 链接 — 换件工单与机制/部件未连, '检修收益'能力断链")
  38. oil = [o for o in d.values() if o['type'] == 'OilSample']
  39. oil_comp = sum(1 for o in oil if any('component/' in x for x in o.get('links', {}).get('about', [])))
  40. stats['油样→部件链接率'] = f'{oil_comp}/{len(oil)}'
  41. if oil_comp == 0:
  42. issues.append(f"③油样 {len(oil)} 份只挂机组未挂部件 — 部件履历(29#主轴后序列类)本体不可查")
  43. # ④ 字段覆盖
  44. ev = [o for o in d.values() if o['type'] == 'Evidence']
  45. cc = sum(1 for o in ev if o['props'].get('crosscheck'))
  46. stats['Evidence.crosscheck覆盖'] = f'{cc}/{len(ev)}'
  47. if cc / max(len(ev), 1) < 0.2:
  48. issues.append(f"④Evidence crosscheck(三分判读)覆盖仅 {cc}/{len(ev)} — 转录证据未定支持/排除/不可判")
  49. vd = [o for o in d.values() if o['type'] == 'Verdict']
  50. vw = sum(1 for o in vd if o['props'].get('claim_window'))
  51. stats['Verdict.claim_window覆盖'] = f'{vw}/{len(vd)}'
  52. if vw < len(vd):
  53. issues.append(f"④Verdict claim_window 覆盖 {vw}/{len(vd)}")
  54. # ⑤ 判级对拍 (抽样台 vs L1 实时)
  55. import sys
  56. sys.path.insert(0, '.')
  57. from src.windscada import taxonomy
  58. mx = taxonomy.system_matrix()
  59. rng = random.Random(seed)
  60. ts = rng.sample(sorted(mx), sample_turbines)
  61. mism = []
  62. for t in ts:
  63. for sysn, v in mx[t].items():
  64. vid = f'verdict/L1/{t}/{sysn}'
  65. if vid not in d:
  66. mism.append((vid, 'MISSING'))
  67. elif d[vid]['props']['level'] != v['状态']:
  68. mism.append((vid, f"{d[vid]['props']['level']}≠{v['状态']}"))
  69. stats['判级对拍'] = f'{sample_turbines}台×九系统 {"全一致" if not mism else mism}'
  70. if mism:
  71. issues.append(f"⑤判级对拍不一致: {mism} — 本体快照落后于 L1, 须重跑 populate")
  72. # ⑥ facet 分布
  73. fc = Counter(o['props'].get('facet') for o in ev)
  74. stats['facet分布'] = dict(fc)
  75. return dict(issues=issues, stats=stats, n=len(d))
  76. if __name__ == '__main__':
  77. # 控制台可能是 GBK(中文 Windows 代码页 936): 正文里的 ✔ ✗ ✅ ⚠ 这类字符编不出来会抛
  78. # UnicodeEncodeError, 脚本干成了事却以退出码 1 结束(同类坑见 src/console.py)。降级为 '?' 而不是崩;
  79. # 不用 import 是为了兼顾 python -m 与直接当脚本跑两种启动方式。
  80. import sys as _sys
  81. for _s in (_sys.stdout, _sys.stderr):
  82. try: _s.reconfigure(errors='replace')
  83. except Exception: pass
  84. r = audit()
  85. print(f"本体审计: {r['n']} 对象, 问题 {len(r['issues'])} 项")
  86. for i in r['issues']:
  87. print(' ✗', i)
  88. print('\n统计:', json.dumps(r['stats'], ensure_ascii=False, indent=1))