backfill.py 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190
  1. # -*- coding: utf-8 -*-
  2. """backfill.py — 回灌器: cases.json(生命周期结局) → 判据记分卡 + 误报台账 (运维 agent 柱1 飞轮右半).
  3. 飞轮: findings→case(cases.py 左半, 发现) →[人工 工单/修/回验]→ case 结局 →**本模块**聚合成
  4. 学习信号 →[人裁]→ 判据 n+1 版本化 → 重部署。本模块 = 附录 §8 G3 离线批式学习的机器件。
  5. ★RULE-3 铁律 (禁在线自适应): 本模块**只出报告/提案, 不改任何阈值文件**。
  6. 阈值/判据更新 = 人裁 + thresholds.yaml 版本化 + 重部署 (禁运行中自更新)。
  7. proposals 段 verdict 全 =【参考】, 是"提请人看"的证据, 非"已应用"的动作。飞轮的"轮"在人手里转。
  8. 纯函数 (backfill): 读 cases list → 出 report dict; 无副作用/无时钟 (日期从 history.ts 字符串解析)。
  9. 判据归因: 一个 case 的结局记给它 evidence 里每个 distinct discriminator_id (decay 除外) —
  10. 多判据喂同一 case 则各记一次 (确诊=各 TP, 误报=各 FP)。
  11. """
  12. try:
  13. from app_common.app_common_guanlan.api import install_root as _install_root
  14. except ImportError: # 理论不可达;包结构异常时回退到按位置上跳
  15. from pathlib import Path as _P
  16. def _install_root(_f): return _P(_f).resolve().parents[4]
  17. from src import paths as P
  18. from datetime import datetime
  19. def _pdate(s):
  20. """'YYYY-MM-DD'(或前10字符) → datetime; 失败→None (无时钟依赖)。"""
  21. try:
  22. return datetime.strptime(str(s)[:10], '%Y-%m-%d')
  23. except (ValueError, TypeError):
  24. return None
  25. def _case_discriminators(case):
  26. """case 的责任判据 = evidence 里 distinct discriminator_id (排除 decay/field_measurement)。"""
  27. out = []
  28. for e in (case.get('evidence') or []):
  29. did = isinstance(e, dict) and e.get('discriminator_id')
  30. if did and did not in ('decay', 'field_measurement') and did not in out:
  31. out.append(did)
  32. return out
  33. def _confirmed_ts(case):
  34. """history 里首个 to_state=CONFIRMED 的 ts (确诊时刻); 无→None。"""
  35. for h in (case.get('history') or []):
  36. if isinstance(h, dict) and h.get('to_state') == 'CONFIRMED':
  37. return h.get('ts')
  38. return None
  39. def backfill(cases, precision_floor=0.7, min_resolved=3):
  40. """回灌聚合 (纯函数, RULE-3: 出提案不改阈值)。
  41. cases: cases.json 解析 list。precision_floor/min_resolved: 提案触发阈 (低精度且够样本才提请人裁)。
  42. 结局分类 (per case 终局): CLOSED=真阳(TP, 判据抓对且修复回验过) / REFUTED=误报(FP) /
  43. RELAPSED=复发 / CONFIRMED·TICKETED·IN_REPAIR·VERIFY=在办(未结) / CANDIDATE·WATCH·NORMAL=未决。
  44. """
  45. stats = {} # did → 计数器
  46. def _s(did):
  47. return stats.setdefault(did, {
  48. 'n_total': 0, 'n_tp_closed': 0, 'n_fp_refuted': 0, 'n_relapsed': 0,
  49. 'n_confirmed_open': 0, 'n_unresolved': 0, 'lead_days': [], 'repair_pass': 0, 'repair_fail': 0})
  50. fp_ledger, relapse_ledger = [], []
  51. ACTIVE_UNRESOLVED = {'NORMAL', 'WATCH', 'CANDIDATE'}
  52. OPEN_INFLIGHT = {'CONFIRMED', 'TICKETED', 'IN_REPAIR', 'VERIFY'}
  53. for c in (cases or []):
  54. if not isinstance(c, dict):
  55. continue
  56. state = c.get('state')
  57. dids = _case_discriminators(c) or ['(unmapped)']
  58. for did in dids:
  59. s = _s(did)
  60. s['n_total'] += 1
  61. if state == 'CLOSED':
  62. s['n_tp_closed'] += 1
  63. elif state == 'REFUTED':
  64. s['n_fp_refuted'] += 1
  65. elif state == 'RELAPSED':
  66. s['n_relapsed'] += 1
  67. elif state in OPEN_INFLIGHT:
  68. s['n_confirmed_open'] += 1
  69. elif state in ACTIVE_UNRESOLVED:
  70. s['n_unresolved'] += 1
  71. # 提前量: opened_at → 确诊 ts
  72. cts = _confirmed_ts(c)
  73. d0, d1 = _pdate(c.get('opened_at')), _pdate(cts)
  74. if d0 and d1 and d1 >= d0:
  75. s['lead_days'].append((d1 - d0).days)
  76. # 修复有效性
  77. vr = (c.get('verify') or {}).get('result')
  78. if vr == 'pass':
  79. s['repair_pass'] += 1
  80. elif vr == 'fail':
  81. s['repair_fail'] += 1
  82. # 台账 (case 级, 不按判据展开)
  83. if state == 'REFUTED':
  84. fp_ledger.append({'case_id': c.get('case_id'), 'system': c.get('system'),
  85. 'discriminators': dids, 'refuted_basis': c.get('refuted_basis', '')})
  86. elif state == 'RELAPSED':
  87. relapse_ledger.append({'case_id': c.get('case_id'), 'system': c.get('system'),
  88. 'relapse_of': c.get('relapse_of'), 'discriminators': dids})
  89. # 派生指标 + 提案 (RULE-3: 参考级, 提请人裁 n+1)
  90. by_disc, proposals = {}, []
  91. for did, s in sorted(stats.items()):
  92. resolved = s['n_tp_closed'] + s['n_fp_refuted'] # 已结 = TP+FP
  93. prec = round(s['n_tp_closed'] / resolved, 3) if resolved else None
  94. lead = sorted(s['lead_days'])
  95. med_lead = lead[len(lead) // 2] if lead else None
  96. rep_tot = s['repair_pass'] + s['repair_fail']
  97. by_disc[did] = {**{k: v for k, v in s.items() if k != 'lead_days'},
  98. 'n_resolved': resolved, 'precision_proxy': prec, 'median_lead_days': med_lead,
  99. 'repair_effectiveness': round(s['repair_pass'] / rep_tot, 3) if rep_tot else None}
  100. if prec is not None and resolved >= min_resolved and prec < precision_floor:
  101. proposals.append({
  102. 'discriminator_id': did, 'verdict': '参考',
  103. 'reason': f'精度代理 {prec} < {precision_floor} (已结 {resolved}: {s["n_tp_closed"]}真阳/'
  104. f'{s["n_fp_refuted"]}误报) → 提请人裁: 该判据阈值/守卫是否需 n+1 收紧',
  105. 'action_required': '人裁 + thresholds.yaml 版本化 + 重部署 (禁自动应用, RULE-3)'})
  106. return {
  107. 'n_cases': len([c for c in (cases or []) if isinstance(c, dict)]),
  108. 'by_discriminator': by_disc,
  109. 'fp_ledger': fp_ledger,
  110. 'relapse_ledger': relapse_ledger,
  111. 'proposals': proposals,
  112. '_rule3': 'RULE-3: 本报告=提请人裁的证据链, 非自动应用; 判据 n+1 须人裁+版本化+重部署, 禁在线自适应。',
  113. }
  114. def render_md(report, farm='?'):
  115. """报告 → 人读 markdown 摘要 (提案在顶, 台账在中, 记分卡在底)。"""
  116. L = [f'> ⚠️ **回灌报告 · {farm}** — `src/sop/backfill.py` 生成, 勿手改。'
  117. f'**{report["_rule3"]}**\n', f'# 回灌记分卡 · {farm} ({report["n_cases"]} cases)\n']
  118. props = report['proposals']
  119. L.append(f'## 提请人裁 ({len(props)} 项)' + (' — 无 (无判据触发低精度阈)' if not props else ''))
  120. for p in props:
  121. L.append(f'- **{p["discriminator_id"]}** 【{p["verdict"]}】: {p["reason"]}\n - 须: {p["action_required"]}')
  122. L.append(f'\n## 误报台账 ({len(report["fp_ledger"])} 条)')
  123. for f in report['fp_ledger']:
  124. L.append(f'- `{f["case_id"]}` ({f["system"]}) 判据{f["discriminators"]} → 证伪依据: {f["refuted_basis"]}')
  125. L.append(f'\n## 复发台账 ({len(report["relapse_ledger"])} 条)')
  126. for r in report['relapse_ledger']:
  127. L.append(f'- `{r["case_id"]}` ({r["system"]}) 复发自 {r["relapse_of"]}')
  128. L.append('\n## 判据记分卡\n| 判据 | 总 | 真阳(销案) | 误报(证伪) | 复发 | 在办 | 未决 | 精度代理 | 中位提前量d | 修复有效 |')
  129. L.append('|---|--|--|--|--|--|--|--|--|--|')
  130. for did, s in report['by_discriminator'].items():
  131. L.append(f'| {did} | {s["n_total"]} | {s["n_tp_closed"]} | {s["n_fp_refuted"]} | {s["n_relapsed"]} '
  132. f'| {s["n_confirmed_open"]} | {s["n_unresolved"]} | {s["precision_proxy"]} '
  133. f'| {s["median_lead_days"]} | {s["repair_effectiveness"]} |')
  134. return '\n'.join(L)
  135. # ---------------------------------------------------------------------------
  136. if __name__ == '__main__':
  137. # 控制台可能是 GBK(中文 Windows 代码页 936): 正文里的 ✔ ✗ ✅ ⚠ 这类字符编不出来会抛
  138. # UnicodeEncodeError, 脚本干成了事却以退出码 1 结束(同类坑见 src/console.py)。降级为 '?' 而不是崩;
  139. # 不用 import 是为了兼顾 python -m 与直接当脚本跑两种启动方式。
  140. import sys as _sys
  141. for _s in (_sys.stdout, _sys.stderr):
  142. try: _s.reconfigure(errors='replace')
  143. except Exception: pass
  144. # CLI: python -m src.sop.backfill <farm|path> —— 读 cases.json → 出 backfill_report.{json,md}
  145. import argparse
  146. import json
  147. import sys
  148. from pathlib import Path
  149. ROOT = _install_root(__file__)
  150. ap = argparse.ArgumentParser()
  151. ap.add_argument('target', help='farm 名 (读 outputs/<farm>/sop/cases.json) 或直接 cases.json 路径')
  152. ap.add_argument('--dry', action='store_true')
  153. a = ap.parse_args()
  154. p = Path(a.target)
  155. if not p.exists():
  156. p = P.sop(a.target) / 'cases.json'
  157. if not p.exists():
  158. print(f'❌ 找不到 cases.json: {a.target}')
  159. sys.exit(2)
  160. cases = json.loads(p.read_text(encoding='utf-8'))
  161. farm = p.parent.parent.name if p.parent.name == 'sop' else a.target
  162. rep = backfill(cases)
  163. print(f'✅ {farm}: {rep["n_cases"]} cases → {len(rep["by_discriminator"])} 判据, '
  164. f'{len(rep["proposals"])} 提案, {len(rep["fp_ledger"])} 误报, {len(rep["relapse_ledger"])} 复发')
  165. if not a.dry:
  166. (p.parent / 'backfill_report.json').write_text(json.dumps(rep, ensure_ascii=False, indent=2), encoding='utf-8')
  167. (p.parent / 'backfill_report.md').write_text(render_md(rep, farm), encoding='utf-8')
  168. print(f' 写盘: {(p.parent / "backfill_report.json").relative_to(ROOT)} + .md')