承接 Wave 6/7/8 多 engineer 在 agent 限額前完成的代碼,補 commit 解 production HEAD 隱性 import error(decision_fusion 已被 decision_manager 引用但檔案 untracked)。 新增(後端核心): - decision_fusion.py (562 行) — P2.1 方法 III(OpenClaw + Hermes + Elephant 三 LLM 融合) - aiops_timeline.py + aiops_timeline_service.py — critic B4 修復 /api/v1/aiops/timeline endpoint,DB 存取抽到 service 層遵守 leWOOOgo 積木化 - migrations/p2_decision_fusion_columns.sql + rollback — approval_records fusion 欄位 修改(後端整合): - decision_manager.py — fusion 三斷鏈修補(critic B1+B2+B3): · B1: 寫 _evidence_snapshot_ref 到 token.proposal_data · B2: fusion 前計算 complexity_score 並寫 token · B3: fusion composite 寫 token.proposal_data["decision_fusion"] - auto_approve.py — fusion + consensus 認識(critic B3+B5): · composite > 0.7 → auto_execute_eligible bypass min_confidence · source=consensus_engine + score>=0.6 → 規則可信路徑 - consensus_engine.py — db-fix _save_consensus 重用 agent_sessions - governance_agent.py — db-fix _alert PG 寫入 ai_governance_events - approval_db.py — fusion 3 欄位 + 2 partial index + CheckConstraint - db/models.py — schema 對齊 migration - core/config.py — vuln #1 修復:OLLAMA_URL/_FALLBACK_URL field_validator 拒絕公網 IP + 外部域名,僅允許私網/loopback/K8s SVC 白名單 - core/feature_flags.py — P2 fusion + consensus flags - main.py — governance_agent lifespan 啟動 - failover_alerter.py — Wave8-X2: in-memory dedup fallback(Redis 拒絕後不 fail-open) - ollama_*.py — metrics 整合 + recovery 改善 - auto_repair_service.py — verifier 接線 新增(測試 2438 行): - test_decision_fusion.py / test_governance_agent.py / test_consensus_integration.py - test_p2_db_fixes.py / test_wave8_fusion_fixes.py - test_config_url_validation.py(vuln #1 12 tests) - test_failover_alerter.py +Wave8-X2 in-memory dedup 補測 驗收: 116 tests pass (decision_fusion + wave8_fusion + config_url + consensus + governance + p2_db_fixes + failover_alerter) Conflict resolution: - 3 檔(config.py + auto_approve.py + decision_manager.py)git stash pop 衝突 保留 stashed (engineer 最終版),補回 ValueError 「公網 IP」字樣對齊 test Note: 此 commit 解 production HEAD 隱性 import error 仍未修: vuln #4 prompt injection / debugger B14 quota fail-closed / B25-B26 drain_pending_tasks / B8 governance fail alert Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-Authored-By: Multiple Engineers (Wave 6/7/8) <noreply@anthropic.com>
377 lines
14 KiB
Python
377 lines
14 KiB
Python
"""AI 自我治理 Agent
|
||
|
||
四項自檢,每 1 小時執行一次:
|
||
1. trust_drift — Playbook trust_score < 0.2 → 告警建議廢棄
|
||
2. knowledge_degradation — KM 7 天未更新 > 20% 總量 → 告警知識衰退
|
||
3. llm_hallucination — 近 100 筆 evidence verification_result=failed 比例 > 10%
|
||
4. execution_blast_radius — 近 100 筆 auto_repair_executions.success=False 比例 > 15%
|
||
|
||
所有 check 互相隔離(try/except),任一失敗不阻斷其他項目。
|
||
|
||
2026-04-26 P2.2 by Claude
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
from datetime import timedelta
|
||
from typing import Any
|
||
|
||
import structlog
|
||
from sqlalchemy import func, select
|
||
|
||
from src.db.base import get_db_context
|
||
from src.db.models import (
|
||
AiGovernanceEvent,
|
||
AutoRepairExecution,
|
||
IncidentEvidence,
|
||
KnowledgeEntryRecord,
|
||
PlaybookRecord,
|
||
)
|
||
from src.models.knowledge import EntryStatus
|
||
from src.utils.timezone import now_taipei
|
||
|
||
logger = structlog.get_logger(__name__)
|
||
|
||
# =============================================================================
|
||
# 閾值常數
|
||
# =============================================================================
|
||
TRUST_DRIFT_THRESHOLD = 0.2 # playbook trust_score 低於此值 → 告警
|
||
KM_STALE_DAYS = 7 # 知識條目超過幾天未更新視為陳舊
|
||
KM_STALE_RATIO = 0.20 # 陳舊比例超過此值 → 告警
|
||
HALLUCINATION_RATE_THRESHOLD = 0.10 # LLM verification failed 比例超過此值 → 告警
|
||
EXECUTION_FAIL_RATE_THRESHOLD = 0.15 # 執行失敗比例超過此值 → 告警
|
||
RECENT_LIMIT = 100 # 最近幾筆做統計
|
||
|
||
|
||
# =============================================================================
|
||
# GovernanceAgent
|
||
# =============================================================================
|
||
|
||
class GovernanceAgent:
|
||
"""AI 自我治理 Agent — 4 項自檢 + 1h 排程
|
||
|
||
2026-04-26 P2.2 by Claude
|
||
"""
|
||
|
||
def __init__(self, alerter=None) -> None:
|
||
# alerter: FailoverAlerter instance(可注入,預設從 singleton 取得)
|
||
self._alerter = alerter
|
||
|
||
# =========================================================================
|
||
# 1. Playbook 信任度漂移
|
||
# =========================================================================
|
||
|
||
async def check_trust_drift(self) -> dict[str, Any]:
|
||
"""Playbook trust_score < 0.2 → 告警建議廢棄
|
||
|
||
2026-04-26 P2.2 by Claude
|
||
"""
|
||
async with get_db_context() as db:
|
||
result = await db.execute(
|
||
select(PlaybookRecord).where(
|
||
PlaybookRecord.status.not_in(["deprecated", "archived"])
|
||
)
|
||
)
|
||
all_records = result.scalars().all()
|
||
|
||
total = len(all_records)
|
||
drifted = [r for r in all_records if float(r.trust_score) < TRUST_DRIFT_THRESHOLD]
|
||
drifted_ids = [r.playbook_id for r in drifted[:10]]
|
||
|
||
if drifted:
|
||
await self._alert(
|
||
"trust_drift",
|
||
{
|
||
"drifted_count": len(drifted),
|
||
"total_playbooks": total,
|
||
"playbook_ids": drifted_ids,
|
||
"threshold": TRUST_DRIFT_THRESHOLD,
|
||
},
|
||
)
|
||
|
||
logger.info(
|
||
"governance_trust_drift_checked",
|
||
total=total,
|
||
drifted=len(drifted),
|
||
)
|
||
return {"checked": total, "drifted": len(drifted)}
|
||
|
||
# =========================================================================
|
||
# 2. 知識庫衰退
|
||
# =========================================================================
|
||
|
||
async def check_knowledge_degradation(self) -> dict[str, Any]:
|
||
"""KM 7 天未更新 > 20% 總量 → 告警知識衰退
|
||
|
||
2026-04-26 P2.2 by Claude
|
||
"""
|
||
stale_cutoff = now_taipei() - timedelta(days=KM_STALE_DAYS)
|
||
|
||
async with get_db_context() as db:
|
||
# 非 archived 總數
|
||
total_result = await db.execute(
|
||
select(func.count()).select_from(KnowledgeEntryRecord).where(
|
||
KnowledgeEntryRecord.status != EntryStatus.ARCHIVED
|
||
)
|
||
)
|
||
total = total_result.scalar() or 0
|
||
|
||
# 7 天內未更新(updated_at < cutoff)且非 archived
|
||
stale_result = await db.execute(
|
||
select(func.count()).select_from(KnowledgeEntryRecord).where(
|
||
KnowledgeEntryRecord.status != EntryStatus.ARCHIVED,
|
||
KnowledgeEntryRecord.updated_at < stale_cutoff,
|
||
)
|
||
)
|
||
stale = stale_result.scalar() or 0
|
||
|
||
ratio = stale / total if total > 0 else 0.0
|
||
|
||
if total > 0 and ratio > KM_STALE_RATIO:
|
||
await self._alert(
|
||
"knowledge_degradation",
|
||
{
|
||
"stale_count": stale,
|
||
"total_count": total,
|
||
"stale_ratio": round(ratio, 3),
|
||
"threshold": KM_STALE_RATIO,
|
||
"stale_days": KM_STALE_DAYS,
|
||
},
|
||
)
|
||
|
||
logger.info(
|
||
"governance_knowledge_degradation_checked",
|
||
total=total,
|
||
stale=stale,
|
||
ratio=round(ratio, 3),
|
||
)
|
||
return {"total": total, "stale": stale, "ratio": round(ratio, 3)}
|
||
|
||
# =========================================================================
|
||
# 3. LLM 幻覺率
|
||
# =========================================================================
|
||
|
||
async def check_llm_hallucination(self) -> dict[str, Any]:
|
||
"""最近 100 筆 IncidentEvidence verification_result=failed 比例 > 10% → 告警
|
||
|
||
verification_result 可能值:success / degraded / failed / timeout
|
||
只有 'failed' 視為幻覺(LLM 判斷錯誤導致執行後驗證失敗)
|
||
|
||
2026-04-26 P2.2 by Claude
|
||
"""
|
||
async with get_db_context() as db:
|
||
# 取最近 RECENT_LIMIT 筆有 verification_result 的記錄
|
||
result = await db.execute(
|
||
select(IncidentEvidence.verification_result)
|
||
.where(IncidentEvidence.verification_result.is_not(None))
|
||
.order_by(IncidentEvidence.collected_at.desc())
|
||
.limit(RECENT_LIMIT)
|
||
)
|
||
rows = result.scalars().all()
|
||
|
||
total = len(rows)
|
||
if total == 0:
|
||
logger.info("governance_hallucination_checked", total=0, rate=0.0)
|
||
return {"total": 0, "failed": 0, "rate": 0.0}
|
||
|
||
failed = sum(1 for r in rows if r == "failed")
|
||
rate = failed / total
|
||
|
||
if rate > HALLUCINATION_RATE_THRESHOLD:
|
||
await self._alert(
|
||
"llm_hallucination",
|
||
{
|
||
"failed_count": failed,
|
||
"total_checked": total,
|
||
"hallucination_rate": round(rate, 3),
|
||
"threshold": HALLUCINATION_RATE_THRESHOLD,
|
||
},
|
||
)
|
||
|
||
logger.info(
|
||
"governance_hallucination_checked",
|
||
total=total,
|
||
failed=failed,
|
||
rate=round(rate, 3),
|
||
)
|
||
return {"total": total, "failed": failed, "rate": round(rate, 3)}
|
||
|
||
# =========================================================================
|
||
# 4. 執行失敗率 (Blast Radius)
|
||
# =========================================================================
|
||
|
||
async def check_execution_blast_radius(self) -> dict[str, Any]:
|
||
"""最近 100 筆 AutoRepairExecution.success=False 比例 > 15% → 告警
|
||
|
||
2026-04-26 P2.2 by Claude
|
||
"""
|
||
async with get_db_context() as db:
|
||
result = await db.execute(
|
||
select(AutoRepairExecution.success)
|
||
.order_by(AutoRepairExecution.created_at.desc())
|
||
.limit(RECENT_LIMIT)
|
||
)
|
||
rows = result.scalars().all()
|
||
|
||
total = len(rows)
|
||
if total == 0:
|
||
logger.info("governance_blast_radius_checked", total=0, rate=0.0)
|
||
return {"total": 0, "failed": 0, "rate": 0.0}
|
||
|
||
failed = sum(1 for r in rows if not r)
|
||
rate = failed / total
|
||
|
||
if rate > EXECUTION_FAIL_RATE_THRESHOLD:
|
||
await self._alert(
|
||
"execution_blast_radius",
|
||
{
|
||
"failed_count": failed,
|
||
"total_executions": total,
|
||
"failure_rate": round(rate, 3),
|
||
"threshold": EXECUTION_FAIL_RATE_THRESHOLD,
|
||
},
|
||
)
|
||
|
||
logger.info(
|
||
"governance_blast_radius_checked",
|
||
total=total,
|
||
failed=failed,
|
||
rate=round(rate, 3),
|
||
)
|
||
return {"total": total, "failed": failed, "rate": round(rate, 3)}
|
||
|
||
# =========================================================================
|
||
# 全跑(exception 隔離)
|
||
# =========================================================================
|
||
|
||
async def run_self_check(self) -> dict[str, Any]:
|
||
"""4 項全跑,每項獨立 try/except 隔離,任一失敗不影響其他項目
|
||
|
||
2026-04-26 P2.2 by Claude
|
||
"""
|
||
results: dict[str, Any] = {}
|
||
checks = [
|
||
("trust_drift", self.check_trust_drift),
|
||
("knowledge_degradation", self.check_knowledge_degradation),
|
||
("llm_hallucination", self.check_llm_hallucination),
|
||
("execution_blast_radius", self.check_execution_blast_radius),
|
||
]
|
||
|
||
for check_name, check_func in checks:
|
||
try:
|
||
results[check_name] = await check_func()
|
||
except Exception as e:
|
||
logger.exception(
|
||
"governance_check_failed",
|
||
check=check_name,
|
||
error=str(e),
|
||
)
|
||
results[check_name] = {"error": str(e)}
|
||
|
||
# 2026-04-27 Wave8-X3 by Claude — B8 全失敗聚合告警
|
||
# ≥3 項失敗代表治理機制本身故障,必須送出緊急告警
|
||
failed_checks = [k for k, v in results.items() if isinstance(v, dict) and "error" in v]
|
||
if len(failed_checks) >= 3:
|
||
try:
|
||
await self._alert(
|
||
"governance_self_failure",
|
||
{
|
||
"failed_checks": failed_checks,
|
||
"total_checks": 4,
|
||
"errors": {k: results[k].get("error") for k in failed_checks},
|
||
},
|
||
)
|
||
except Exception:
|
||
logger.exception("governance_self_failure_alert_failed")
|
||
|
||
logger.info("governance_self_check_complete", results=results)
|
||
return results
|
||
|
||
# =========================================================================
|
||
# 告警輸出
|
||
# =========================================================================
|
||
|
||
async def _alert(self, event_type: str, payload: dict[str, Any]) -> None:
|
||
"""structlog 告警 + PG 持久化 + Telegram 推送(via FailoverAlerter)
|
||
|
||
2026-04-26 P2.2 by Claude
|
||
2026-04-26 P2-DB-Fix by Claude — db-expert P0 三修(P0.1): 補 PG 寫入 ai_governance_events
|
||
ADR-085 鐵律:AI 學習成果不可存 Cache,必須落地 PG
|
||
"""
|
||
# 1. 寫 PG(ADR-085 鐵律 — 失敗不阻斷主流程)
|
||
try:
|
||
from sqlalchemy import insert as _sa_insert
|
||
async with get_db_context() as db:
|
||
await db.execute(
|
||
_sa_insert(AiGovernanceEvent).values(
|
||
event_type=event_type,
|
||
details=payload,
|
||
)
|
||
)
|
||
await db.commit()
|
||
except Exception as _pg_err:
|
||
logger.warning("governance_pg_write_failed", error=str(_pg_err))
|
||
|
||
# 2. structlog(保留既有行為)
|
||
logger.warning("governance_alert", event_type=event_type, **payload)
|
||
|
||
# Lazy import:延遲到實際呼叫時才取 alerter,避免啟動時循環依賴
|
||
alerter = self._alerter
|
||
if alerter is None:
|
||
try:
|
||
from src.services.failover_alerter import get_failover_alerter
|
||
alerter = get_failover_alerter()
|
||
except Exception as e:
|
||
logger.warning("governance_alerter_get_failed", error=str(e))
|
||
return
|
||
|
||
try:
|
||
await alerter.alert_governance(event_type, payload)
|
||
except Exception as e:
|
||
logger.warning("governance_telegram_alert_failed", error=str(e))
|
||
|
||
|
||
# =============================================================================
|
||
# Singleton + 排程迴圈
|
||
# =============================================================================
|
||
|
||
_agent: GovernanceAgent | None = None
|
||
|
||
|
||
def get_governance_agent() -> GovernanceAgent:
|
||
"""取得 GovernanceAgent singleton
|
||
|
||
2026-04-26 P2.2 by Claude
|
||
"""
|
||
global _agent
|
||
if _agent is None:
|
||
_agent = GovernanceAgent()
|
||
return _agent
|
||
|
||
|
||
def reset_governance_agent() -> None:
|
||
"""重置 singleton(測試用)
|
||
|
||
2026-04-26 P2.2 by Claude
|
||
"""
|
||
global _agent
|
||
_agent = None
|
||
|
||
|
||
async def run_governance_loop(interval_seconds: int = 3600) -> None:
|
||
"""每 1 小時執行一次 GovernanceAgent.run_self_check()
|
||
|
||
沿用 main.py 的 asyncio.create_task + sleep 迴圈模式(無 APScheduler)。
|
||
coalesce 效果:每次 sleep interval_seconds,不堆積多次執行。
|
||
|
||
2026-04-26 P2.2 by Claude
|
||
"""
|
||
agent = get_governance_agent()
|
||
while True:
|
||
try:
|
||
await agent.run_self_check()
|
||
except Exception as e:
|
||
logger.warning("governance_loop_error", error=str(e))
|
||
await asyncio.sleep(interval_seconds)
|