|
| 1 | +"""운영자 아침 점검용 통합 헬스 요약. |
| 2 | +
|
| 3 | +기존에는 운영자가 시스템 상태를 파악하려면 여러 도구를 따로 돌려야 했다: |
| 4 | + - paper_runtime_status.py --all (전략별 runtime state) |
| 5 | + - evaluate_and_promote.py --check-only (artifact 동기화/freshness) |
| 6 | + - current_blockers.json 직접 확인 (go_live, hard_blockers) |
| 7 | +
|
| 8 | +이 모듈은 그 신호들을 하나로 모아 단일 verdict(OK / ATTENTION / BLOCKED)와 |
| 9 | +사람이 읽는 요약을 만든다. 순수 함수라 외부 상태를 직접 읽지 않고 주입받은 |
| 10 | +데이터로만 판정하므로 단위 테스트가 쉽다. |
| 11 | +
|
| 12 | +verdict 규칙(보수적 — 의심스러우면 주의 이상): |
| 13 | + - BLOCKED : frozen 전략 존재, 또는 hard_blocker 존재, 또는 artifact가 stale/손상. |
| 14 | + - ATTENTION: degraded/blocked_insufficient_evidence 전략 존재, 또는 manual freeze, |
| 15 | + 또는 최근 이상치(anomaly) 존재, 또는 go_live=false인데 live_candidate 표기 불일치. |
| 16 | + - OK : 위 어느 것에도 안 걸림. |
| 17 | +""" |
| 18 | + |
| 19 | +from __future__ import annotations |
| 20 | + |
| 21 | +from typing import Any |
| 22 | + |
| 23 | +# verdict 우선순위 (높을수록 심각) |
| 24 | +_VERDICT_RANK = {"OK": 0, "ATTENTION": 1, "BLOCKED": 2} |
| 25 | + |
| 26 | +# runtime state별 분류 |
| 27 | +_BLOCKING_STATES = {"frozen"} |
| 28 | +_ATTENTION_STATES = {"degraded", "blocked_insufficient_evidence"} |
| 29 | + |
| 30 | + |
| 31 | +def _worst(*verdicts: str) -> str: |
| 32 | + """주어진 verdict 중 가장 심각한 것을 반환.""" |
| 33 | + worst = "OK" |
| 34 | + for v in verdicts: |
| 35 | + if _VERDICT_RANK.get(v, 0) > _VERDICT_RANK[worst]: |
| 36 | + worst = v |
| 37 | + return worst |
| 38 | + |
| 39 | + |
| 40 | +def summarize_runtime_state(state: Any) -> dict[str, Any]: |
| 41 | + """단일 RuntimeState(또는 동등 객체)를 verdict + 요약으로 환원한다. |
| 42 | +
|
| 43 | + state는 .state / .strategy / .manual_freeze / .last_anomalies 속성을 가진 객체. |
| 44 | + """ |
| 45 | + name = getattr(state, "strategy", "?") |
| 46 | + s = getattr(state, "state", "unknown") |
| 47 | + manual_freeze = bool(getattr(state, "manual_freeze", False)) |
| 48 | + anomalies = list(getattr(state, "last_anomalies", []) or []) |
| 49 | + |
| 50 | + if s in _BLOCKING_STATES: |
| 51 | + verdict = "BLOCKED" |
| 52 | + elif s in _ATTENTION_STATES or manual_freeze or anomalies: |
| 53 | + verdict = "ATTENTION" |
| 54 | + else: |
| 55 | + verdict = "OK" |
| 56 | + |
| 57 | + notes = [] |
| 58 | + if s in _BLOCKING_STATES: |
| 59 | + notes.append(f"state={s}") |
| 60 | + elif s in _ATTENTION_STATES: |
| 61 | + notes.append(f"state={s}") |
| 62 | + if manual_freeze: |
| 63 | + notes.append("manual_freeze") |
| 64 | + if anomalies: |
| 65 | + notes.append(f"anomalies={len(anomalies)}") |
| 66 | + |
| 67 | + return { |
| 68 | + "strategy": name, |
| 69 | + "state": s, |
| 70 | + "verdict": verdict, |
| 71 | + "manual_freeze": manual_freeze, |
| 72 | + "anomaly_count": len(anomalies), |
| 73 | + "notes": notes, |
| 74 | + } |
| 75 | + |
| 76 | + |
| 77 | +def summarize_blockers(blockers: dict[str, Any] | None) -> dict[str, Any]: |
| 78 | + """current_blockers.json 페이로드를 verdict + 요약으로 환원한다.""" |
| 79 | + if not blockers: |
| 80 | + return { |
| 81 | + "verdict": "ATTENTION", |
| 82 | + "go_live": False, |
| 83 | + "hard_blocker_count": 0, |
| 84 | + "notes": ["current_blockers 없음/로드 실패"], |
| 85 | + "freshness_stale": True, |
| 86 | + } |
| 87 | + |
| 88 | + hard = list(blockers.get("hard_blockers") or []) |
| 89 | + go_live = bool(blockers.get("go_live", False)) |
| 90 | + live_candidates = list(blockers.get("live_candidates") or []) |
| 91 | + freshness = blockers.get("promotion_artifact_freshness") or {} |
| 92 | + # freshness가 dict면 stale 여부를 본다(없으면 보수적으로 미상=stale 취급하지 않음). |
| 93 | + stale = False |
| 94 | + if isinstance(freshness, dict): |
| 95 | + stale = bool(freshness.get("stale", False)) or freshness.get("status") in ("stale", "expired") |
| 96 | + |
| 97 | + notes = [] |
| 98 | + verdict = "OK" |
| 99 | + if hard: |
| 100 | + verdict = "BLOCKED" |
| 101 | + notes.append(f"hard_blockers={len(hard)}") |
| 102 | + if stale: |
| 103 | + verdict = _worst(verdict, "BLOCKED") |
| 104 | + notes.append("artifact_stale") |
| 105 | + # go_live=false인데 live_candidates가 비어있지 않으면 표기 불일치(주의). |
| 106 | + if not go_live and live_candidates: |
| 107 | + verdict = _worst(verdict, "ATTENTION") |
| 108 | + notes.append("go_live=false_but_live_candidates_present") |
| 109 | + |
| 110 | + return { |
| 111 | + "verdict": verdict, |
| 112 | + "go_live": go_live, |
| 113 | + "live_candidates": live_candidates, |
| 114 | + "hard_blocker_count": len(hard), |
| 115 | + "hard_blockers": hard, |
| 116 | + "freshness_stale": stale, |
| 117 | + "notes": notes, |
| 118 | + } |
| 119 | + |
| 120 | + |
| 121 | +def build_operator_health( |
| 122 | + runtime_states: list[Any], |
| 123 | + blockers: dict[str, Any] | None, |
| 124 | +) -> dict[str, Any]: |
| 125 | + """전략별 runtime state + current_blockers를 하나의 헬스 요약으로 합친다. |
| 126 | +
|
| 127 | + 반환: |
| 128 | + { |
| 129 | + "verdict": "OK" | "ATTENTION" | "BLOCKED", |
| 130 | + "strategy_count": N, |
| 131 | + "strategies": [summarize_runtime_state(...), ...], |
| 132 | + "blockers": summarize_blockers(...), |
| 133 | + "headline": 사람이 읽는 한 줄 요약, |
| 134 | + "attention_items": [...], # 운영자가 봐야 할 항목들 |
| 135 | + } |
| 136 | + """ |
| 137 | + strat_summaries = [summarize_runtime_state(s) for s in runtime_states] |
| 138 | + blocker_summary = summarize_blockers(blockers) |
| 139 | + |
| 140 | + verdict = "OK" |
| 141 | + for s in strat_summaries: |
| 142 | + verdict = _worst(verdict, s["verdict"]) |
| 143 | + verdict = _worst(verdict, blocker_summary["verdict"]) |
| 144 | + |
| 145 | + attention_items: list[str] = [] |
| 146 | + for s in strat_summaries: |
| 147 | + if s["verdict"] != "OK": |
| 148 | + attention_items.append(f"{s['strategy']}: {', '.join(s['notes']) or s['state']}") |
| 149 | + if blocker_summary["notes"]: |
| 150 | + attention_items.append("blockers: " + ", ".join(blocker_summary["notes"])) |
| 151 | + |
| 152 | + n = len(strat_summaries) |
| 153 | + n_ok = sum(1 for s in strat_summaries if s["verdict"] == "OK") |
| 154 | + if verdict == "OK": |
| 155 | + headline = f"전체 정상 — 전략 {n}개 모두 OK, go_live={blocker_summary['go_live']}" |
| 156 | + elif verdict == "ATTENTION": |
| 157 | + headline = f"주의 필요 — 전략 {n}개 중 {n_ok}개 OK, 확인 항목 {len(attention_items)}건" |
| 158 | + else: |
| 159 | + headline = f"차단 상태 — 운영 개입 필요, 확인 항목 {len(attention_items)}건" |
| 160 | + |
| 161 | + return { |
| 162 | + "verdict": verdict, |
| 163 | + "strategy_count": n, |
| 164 | + "strategies": strat_summaries, |
| 165 | + "blockers": blocker_summary, |
| 166 | + "headline": headline, |
| 167 | + "attention_items": attention_items, |
| 168 | + } |
0 commit comments