- docs/superpowers/specs/2026-04-15-MASTER-ai-autonomous-flywheel-v2.md (1456 行,§0-§8 全填完:42-cell 戰術矩陣、7 Phase 計畫、7 ADR 摘要、 15 KPI、21 Feature Flags、10 風險場景) - docs/adr/ADR-080-ai-autonomy-flywheel-overview.md (7 Phase 結構 + 4 北極星 + 7 架構師 Review Gates + Phase 退出條件) - apps/api/src/core/feature_flags.py (AIOpsFeatureFlags: P1~P6 總開關全 False + 15 細粒度子開關 is_phase_enabled() / is_sub_flag_enabled() + bool cast 安全) - apps/api/src/jobs/__init__.py + baseline_snapshot.py (Phase 0 基線快照 Job:MCP calls / Playbook confidence / general 比例 / learning loop rate / auto_repair — 寫入 aiops:baseline:latest) - apps/api/tests/test_feature_flags.py (21 tests — 全綠) - docs/HARD_RULES.md → v1.9 (新增 Phase 退出條件鐵律:禁止未過 exit conditions 宣告 Phase 完成) - CLAUDE.md 防失憶閘門 1:強制讀 MASTER §0 Session Resume Protocol Gate 0 Pass — 21/21 tests green Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
339 lines
13 KiB
Python
339 lines
13 KiB
Python
"""
|
||
AWOOOI AIOps Phase 0 — 基線快照 Job
|
||
=====================================
|
||
拍攝 AI 自主化飛輪「啟動前現況」,作為 Phase 0→1 進展衡量基準。
|
||
|
||
快照涵蓋 ADR-080 診斷表中的 6 大指標:
|
||
1. MCP 呼叫次數/24h(目標:> 0;現況預估:0)
|
||
2. Playbook trust/confidence 分佈(目標:動態;現況:全靜態)
|
||
3. 學習閉環觸發率(目標:≥ 99%;現況:0%,fire-and-forget)
|
||
4. 告警分類 general 比例(目標:< 10%;現況:~ 41%)
|
||
5. 修復動作 RESTART 比例(目標:< 40%;現況:~ 68%)
|
||
6. 自動執行成功次數/24h(目標:> 0;現況:0)
|
||
|
||
儲存策略:
|
||
- Redis Key `aiops:baseline:{timestamp_iso}` — 最新快照(TTL 永不過期)
|
||
- Redis Key `aiops:baseline:latest` — 指向最新快照的時間戳(方便 API 讀取)
|
||
|
||
使用方式:
|
||
python -m src.jobs.baseline_snapshot # 直接執行(一次性)
|
||
await take_baseline_snapshot() # 從程式碼呼叫
|
||
|
||
ADR-080: AI 自主化飛輪總綱
|
||
MASTER: docs/superpowers/specs/2026-04-15-MASTER-ai-autonomous-flywheel-v2.md §5 Phase 0
|
||
|
||
2026-04-15 ogt + Claude Sonnet 4.6 (亞太): Phase 0 — 初始建立
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import json
|
||
from datetime import timedelta
|
||
|
||
import structlog
|
||
from sqlalchemy import func, select, text
|
||
|
||
from src.core.redis_client import get_redis
|
||
from src.db.base import get_db_context
|
||
from src.db.models import (
|
||
AutoRepairExecution,
|
||
IncidentRecord,
|
||
KnowledgeEntryRecord,
|
||
)
|
||
from src.utils.timezone import now_taipei
|
||
|
||
logger = structlog.get_logger(__name__)
|
||
|
||
# Redis 鍵
|
||
BASELINE_KEY_PREFIX = "aiops:baseline:"
|
||
BASELINE_LATEST_KEY = "aiops:baseline:latest"
|
||
|
||
# Playbook Redis 前綴(同 playbook_repository.py)
|
||
PLAYBOOK_KEY_PREFIX = "playbook:"
|
||
|
||
|
||
async def take_baseline_snapshot() -> dict:
|
||
"""
|
||
拍攝一次完整基線快照並寫入 Redis。
|
||
|
||
Returns:
|
||
dict: 快照內容(含 snapshot_at 時間戳)
|
||
"""
|
||
now = now_taipei()
|
||
since_24h = now - timedelta(hours=24)
|
||
ts_iso = now.isoformat()
|
||
|
||
logger.info("baseline_snapshot_start", snapshot_at=ts_iso)
|
||
|
||
snapshot = {
|
||
"snapshot_at": ts_iso,
|
||
"phase": "P0",
|
||
"description": "AI 自主化飛輪 Phase 0 啟動前基線",
|
||
"metrics": {},
|
||
}
|
||
|
||
# ── 1. MCP 呼叫次數/24h ───────────────────────────────────────────────
|
||
# Phase 0 時 MCP 尚未接入任何決策流程 → 預期為 0
|
||
# Phase 1 完成後此數字應 > 0(PreDecisionInvestigator 開始呼叫)
|
||
mcp_calls_24h = await _count_mcp_calls_24h(since_24h)
|
||
snapshot["metrics"]["mcp_calls_24h"] = mcp_calls_24h
|
||
|
||
# ── 2. Playbook confidence 分佈(Redis 掃描)──────────────────────────
|
||
playbook_stats = await _playbook_confidence_stats()
|
||
snapshot["metrics"]["playbook"] = playbook_stats
|
||
|
||
# ── 3. 學習閉環觸發率 + 其他 DB 指標 ─────────────────────────────────
|
||
db_metrics = await _db_metrics(since_24h)
|
||
snapshot["metrics"].update(db_metrics)
|
||
|
||
# ── 4. 計算衍生指標 ───────────────────────────────────────────────────
|
||
snapshot["metrics"]["learning_loop_rate"] = _compute_learning_rate(
|
||
db_metrics.get("auto_repair_24h", 0),
|
||
db_metrics.get("learning_writes_24h", 0),
|
||
)
|
||
|
||
# ── 寫入 Redis ─────────────────────────────────────────────────────────
|
||
await _persist_to_redis(ts_iso, snapshot)
|
||
|
||
logger.info(
|
||
"baseline_snapshot_done",
|
||
snapshot_at=ts_iso,
|
||
mcp_calls_24h=mcp_calls_24h,
|
||
playbook_total=playbook_stats.get("total", 0),
|
||
incidents_24h=db_metrics.get("incidents_24h", 0),
|
||
auto_repair_success_24h=db_metrics.get("auto_repair_success_24h", 0),
|
||
)
|
||
|
||
return snapshot
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# Internal helpers
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
async def _count_mcp_calls_24h(since_24h) -> int:
|
||
"""
|
||
MCP 呼叫次數/24h。
|
||
|
||
Phase 0:無 MCP Calls Table → 從 audit_logs 嘗試計數。
|
||
Phase 1 建立 PreDecisionInvestigator 後,此處改為查 mcp_tool_calls 表。
|
||
"""
|
||
try:
|
||
async with get_db_context() as db:
|
||
# audit_logs 中 action='mcp_call' — Phase 0 預期 0 筆
|
||
result = await db.execute(
|
||
text(
|
||
"SELECT COUNT(*) FROM audit_logs "
|
||
"WHERE action = 'mcp_call' AND created_at >= :since"
|
||
),
|
||
{"since": since_24h},
|
||
)
|
||
return result.scalar_one_or_none() or 0
|
||
except Exception:
|
||
logger.exception("baseline_mcp_count_error")
|
||
return 0
|
||
|
||
|
||
async def _playbook_confidence_stats() -> dict:
|
||
"""
|
||
掃描 Redis 中全部 Playbook,統計 ai_confidence 分佈。
|
||
|
||
指標診斷:
|
||
- avg_confidence ≈ 0.3 → 佐證「全靜態」現況(Phase 0 基線)
|
||
- Phase 3 EWMA 上線後,此值應動態分散(std_dev 升高、avg 可能提升)
|
||
"""
|
||
stats = {
|
||
"total": 0,
|
||
"approved": 0,
|
||
"avg_confidence": 0.0,
|
||
"min_confidence": None,
|
||
"max_confidence": None,
|
||
"never_used": 0, # success_count + failure_count == 0
|
||
"action_type_dist": {},
|
||
}
|
||
|
||
try:
|
||
redis = get_redis()
|
||
confidences: list[float] = []
|
||
action_counts: dict[str, int] = {}
|
||
|
||
async for key in redis.scan_iter(match=f"{PLAYBOOK_KEY_PREFIX}PB-*", count=200):
|
||
raw = await redis.get(key)
|
||
if not raw:
|
||
continue
|
||
try:
|
||
pb = json.loads(raw)
|
||
except json.JSONDecodeError:
|
||
continue
|
||
|
||
stats["total"] += 1
|
||
|
||
if pb.get("status") == "approved":
|
||
stats["approved"] += 1
|
||
|
||
conf = pb.get("ai_confidence", 0.0) or 0.0
|
||
confidences.append(conf)
|
||
|
||
used = (pb.get("success_count", 0) or 0) + (pb.get("failure_count", 0) or 0)
|
||
if used == 0:
|
||
stats["never_used"] += 1
|
||
|
||
# 統計 repair_steps 中首個 action_type(代表主要修復動作)
|
||
steps = pb.get("repair_steps", [])
|
||
if steps:
|
||
first_action = steps[0].get("action_type", "unknown")
|
||
action_counts[first_action] = action_counts.get(first_action, 0) + 1
|
||
|
||
if confidences:
|
||
stats["avg_confidence"] = round(sum(confidences) / len(confidences), 4)
|
||
stats["min_confidence"] = round(min(confidences), 4)
|
||
stats["max_confidence"] = round(max(confidences), 4)
|
||
|
||
# RESTART 比例:佐證 ADR-080 診斷(目標 < 40%)
|
||
total_actions = sum(action_counts.values())
|
||
restart_count = action_counts.get("restart_service", 0)
|
||
stats["restart_ratio"] = round(restart_count / total_actions, 4) if total_actions else 0.0
|
||
stats["action_type_dist"] = action_counts
|
||
|
||
except Exception:
|
||
logger.exception("baseline_playbook_stats_error")
|
||
|
||
return stats
|
||
|
||
|
||
async def _db_metrics(since_24h) -> dict:
|
||
"""
|
||
從 PostgreSQL 取得核心計數指標。
|
||
"""
|
||
metrics: dict = {
|
||
"incidents_24h": 0,
|
||
"incidents_total": 0,
|
||
"general_alert_ratio": 0.0,
|
||
"auto_repair_24h": 0,
|
||
"auto_repair_success_24h": 0,
|
||
"km_total": 0,
|
||
"km_vectorized": 0,
|
||
"learning_writes_24h": 0,
|
||
"audit_logs_24h": 0,
|
||
}
|
||
|
||
try:
|
||
async with get_db_context() as db:
|
||
# Incident 數量(24h + 總計)
|
||
r = await db.execute(
|
||
select(func.count(IncidentRecord.incident_id)).where(
|
||
IncidentRecord.created_at >= since_24h
|
||
)
|
||
)
|
||
metrics["incidents_24h"] = r.scalar_one_or_none() or 0
|
||
|
||
r = await db.execute(select(func.count(IncidentRecord.incident_id)))
|
||
metrics["incidents_total"] = r.scalar_one_or_none() or 0
|
||
|
||
# general 告警比例(alert_category = 'general')
|
||
r = await db.execute(
|
||
select(func.count()).where(
|
||
IncidentRecord.alert_category == "general"
|
||
)
|
||
)
|
||
general_count = r.scalar_one_or_none() or 0
|
||
total = metrics["incidents_total"]
|
||
metrics["general_alert_ratio"] = round(general_count / total, 4) if total else 0.0
|
||
|
||
# 自動修復執行(24h)
|
||
r = await db.execute(
|
||
select(func.count(AutoRepairExecution.id)).where(
|
||
AutoRepairExecution.created_at >= since_24h
|
||
)
|
||
)
|
||
metrics["auto_repair_24h"] = r.scalar_one_or_none() or 0
|
||
|
||
r = await db.execute(
|
||
select(func.count(AutoRepairExecution.id)).where(
|
||
AutoRepairExecution.created_at >= since_24h,
|
||
AutoRepairExecution.success.is_(True),
|
||
)
|
||
)
|
||
metrics["auto_repair_success_24h"] = r.scalar_one_or_none() or 0
|
||
|
||
# KM 數量 + 向量化率
|
||
r = await db.execute(select(func.count(KnowledgeEntryRecord.id)))
|
||
metrics["km_total"] = r.scalar_one_or_none() or 0
|
||
|
||
r = await db.execute(
|
||
select(func.count()).where(
|
||
KnowledgeEntryRecord.embedding.is_not(None)
|
||
)
|
||
)
|
||
metrics["km_vectorized"] = r.scalar_one_or_none() or 0
|
||
|
||
# 學習寫入數(24h 內新增 KM)
|
||
r = await db.execute(
|
||
select(func.count()).where(
|
||
KnowledgeEntryRecord.created_at >= since_24h
|
||
)
|
||
)
|
||
metrics["learning_writes_24h"] = r.scalar_one_or_none() or 0
|
||
|
||
# audit_logs 24h 計數(Phase 0 預期 = 0)
|
||
r = await db.execute(
|
||
text(
|
||
"SELECT COUNT(*) FROM audit_logs WHERE created_at >= :since"
|
||
),
|
||
{"since": since_24h},
|
||
)
|
||
metrics["audit_logs_24h"] = r.scalar_one_or_none() or 0
|
||
|
||
except Exception:
|
||
logger.exception("baseline_db_metrics_error")
|
||
|
||
return metrics
|
||
|
||
|
||
def _compute_learning_rate(auto_repair_24h: int, learning_writes_24h: int) -> float:
|
||
"""
|
||
學習閉環觸發率 = learning_writes_24h / auto_repair_24h。
|
||
|
||
Phase 0 診斷:fire-and-forget → 比率為 0%(即使 auto_repair > 0,learning 也可能 = 0)
|
||
Phase 3 修復後目標:≥ 99%
|
||
"""
|
||
if auto_repair_24h == 0:
|
||
return 0.0
|
||
return round(min(learning_writes_24h / auto_repair_24h, 1.0), 4)
|
||
|
||
|
||
async def _persist_to_redis(ts_iso: str, snapshot: dict) -> None:
|
||
"""
|
||
將快照寫入 Redis:
|
||
- `aiops:baseline:{ts_iso}` — 歷史記錄(永不過期)
|
||
- `aiops:baseline:latest` — 最新快照全量(永不過期)
|
||
"""
|
||
try:
|
||
redis = get_redis()
|
||
payload = json.dumps(snapshot, ensure_ascii=False)
|
||
|
||
# 歷史記錄(保留全部 snapshot)
|
||
await redis.set(f"{BASELINE_KEY_PREFIX}{ts_iso}", payload)
|
||
|
||
# 最新快照(供 API 快速讀取)
|
||
await redis.set(BASELINE_LATEST_KEY, payload)
|
||
|
||
logger.info("baseline_snapshot_persisted", key=BASELINE_LATEST_KEY)
|
||
except Exception:
|
||
logger.exception("baseline_persist_error")
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# Entry point(直接執行)
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
async def _main() -> None:
|
||
snapshot = await take_baseline_snapshot()
|
||
print(json.dumps(snapshot, indent=2, ensure_ascii=False))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
asyncio.run(_main())
|