Files
awoooi/apps/api/src/jobs/baseline_snapshot.py
OG T db9e304a14 feat(adr-080): Phase 0 防護欄建立 — AI 自主化飛輪啟動
- 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>
2026-04-15 12:44:53 +08:00

339 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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 完成後此數字應 > 0PreDecisionInvestigator 開始呼叫)
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 > 0learning 也可能 = 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())