feat(governance): AI 治理事件處理鏈四軌交付(C/D/B/A)
Some checks failed
Code Review / ai-code-review (push) Successful in 48s
run-migration / migrate (push) Failing after 45s
CD Pipeline / tests (push) Successful in 3m46s
Type Sync Check / check-type-sync (push) Successful in 2m8s
CD Pipeline / build-and-deploy (push) Failing after 31m14s
CD Pipeline / post-deploy-checks (push) Has been skipped

【十二人專家團隊全景掃描 + 並行四軌實施】

統帥質疑「有讓 12-agent 一起協作嗎」後,依照團隊規則完成全鏈路交付:
onboarder + critic + db-expert + debugger + frontend-designer 並行掃描,
找到 6 大 Gap,再由 fullstack-engineer × 4、refactor-specialist 協作落地。

【Track C — trust_drift 雙寫整併】

兩條獨立寫 event_type=trust_drift 路徑互不呼叫,下游 consumer 拿到雙份資料
無法判定 source-of-truth。整併保留 governance_agent.check_trust_drift(功能
更全:auto-deprecate + Telegram + PG),TrustDriftDetector 降為純統計 lib,
W-6 watchdog 改呼叫 governance_agent。新增 TestSinglePgWritePerDriftScenario
驗證同一 drift 場景只觸發一次 PG 寫入。

  變更:
    - apps/api/src/services/trust_drift_detector.py(lib only,不再寫 PG)
    - apps/api/tests/test_trust_drift_watchdog.py(W-6 改 mock governance_agent)

【Track D — governance_remediation_dispatch 派遣表】

ai_governance_events 是不可變 Event Sourcing,不能塞執行狀態。新建派遣表
作為投影層:1 event → 0..N dispatches,狀態可變、可重試、可審計。

  - PgEnum 5 種 event_type + 7 階段狀態機(pending → dispatched → executing →
    succeeded/failed/cancelled/skipped)
  - 失敗重試 INSERT 新 row(不改舊 row 的 status,保留審計痕跡)
  - Partial unique index ux_grd_one_active_per_event 強制「同事件唯一活躍」
  - 4 個複合 index 支援 worker poll、去重查詢、觀測面板
  - FK 對應 ai_governance_events / playbooks / incidents / approval_records
    全部 SET NULL(avoid cascade lock,但 governance_event 用 RESTRICT)

  變更:
    - apps/api/src/db/models.py(GovernanceRemediationDispatch ORM class)
    - apps/api/migrations/governance_remediation_dispatch_2026-05-03.sql
    - apps/api/src/repositories/governance_remediation_dispatch_repo.py
      (6 個 async 函式 + 3 個自訂例外:DispatchAlreadyActive /
       InvalidStatusTransition / DispatchNotFound)
    - apps/api/src/models/governance_dispatch.py(DecisionContextV1 等 4 schema)
    - apps/api/tests/test_governance_remediation_dispatch.py(29 tests)

【Track B — /governance 頁面】

後端 PR1 三個 endpoint + 前端 PR2-5 完整三 Tab。

PR1 後端:
  - GET /api/v1/ai/governance/events(events_tab,含 event_type/severity/
    狀態/時間範圍篩選 + 分頁)
  - GET /api/v1/ai/governance/queue(queue_tab,含 graceful fallback:
    dispatch 表不存在時回 table_pending=True 不拋 500)
  - GET /api/v1/ai/governance/summary(slo_tab 30d 違反時序圖)
  - severity 映射規則寫死(critic 建議未來移 settings)

PR2-5 前端:
  - /governance 路由 + AppLayout + Compliance Badge 橫幅 + PageTabs
  - SLO Tab:3 KPI 卡片(Syne 28px + StatusOrb + 7d sparkline)+
    30d 違反 stacked BarChart
  - Events Tab:篩選列 + 表格 + inline 展開行(JSON / 修復建議 / 派遣記錄)
  - Queue Tab:HITL 待辦卡片 + 信任度進度條 + 批准/拒絕按鈕(本 PR console.log)
  - Sidebar 加入「AI 治理」入口(ShieldCheck icon)
  - i18n 雙語完整(governance namespace + nav.governance)
  - 7 個新元件:slo-kpi-card / slo-violation-chart / events-table /
    events-filter-bar / event-detail-drawer / queue-item-card / queue-history-tabs

  變更:
    - apps/api/src/api/v1/ai_governance.py(router)
    - apps/api/src/services/governance_query_service.py
    - apps/api/src/models/governance.py(Pydantic V2 schemas)
    - apps/api/tests/test_ai_governance_endpoints.py(21 tests)
    - apps/web/src/app/[locale]/governance/(page + 3 tabs)
    - apps/web/src/components/governance/(7 元件)
    - apps/web/messages/{zh-TW,en}.json(governance namespace)
    - apps/web/src/components/layout/sidebar.tsx(+1 行)
    - apps/api/src/main.py(router include)

【Track A — GovernanceDispatcher 決策融合】

把治理事件接到 remediation 執行器,走北極星方向決策融合(LLM × Playbook trust
× MCP),符合「禁寫死規則」鐵律。

  - 設計鐵律:DecisionFusionAdapter 是新增 wrapper,**不修改任何 Tier 3 檔**
    (decision_manager / learning_service / trust_engine),只 consume 既有 API
  - 三維融合公式:confidence = 0.4×llm + 0.3×playbook_trust + 0.3×mcp_consistency
    (權重加 TODO 標明未來由 AI 自學調整)
  - 三分支決策路徑:
    confidence ≥ 0.85 → auto_dispatch(status=dispatched)
    0.65 ≤ confidence < 0.85 → pending_approval(HITL)
    confidence < 0.65 → skip + log
  - decision_context JSONB 完整記錄三維輸入快照(給未來 fine-tune 用)
  - poll 30s 掃 unresolved 事件,仿 governance loop 模式
  - 重複事件擋去重(呼叫 get_active_for_event)

  變更:
    - apps/api/src/services/governance_dispatcher.py
    - apps/api/src/services/decision_fusion_adapter.py
    - apps/api/tests/test_governance_dispatcher.py(14 tests)
    - apps/api/src/main.py(lifespan task 接 run_governance_dispatcher_loop)

【驗證】

1836 個 unit test 全過(29 skipped 為既有 PG integration env 問題)

【調度教訓 — 已記入 memory】

- vuln-verifier 應在 fullstack-engineer **之前**跑(避免並行讀到已修代碼誤判)
- critic 雙輪審查不可省(第二輪抓到 NaN sentinel + Prom rule 連鎖)
- 北極星「禁寫死規則」搭配 decision-fusion 確實實施

【未動 Tier 3 — 已驗證】

git diff 確認本 commit 完全沒改 decision_manager.py / learning_service.py /
trust_engine.py,只新增 wrapper service consume 既有 API。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-05-03 12:42:40 +08:00
parent 577250a678
commit e45b055e0e
29 changed files with 6510 additions and 92 deletions

View File

@@ -0,0 +1,538 @@
"""
GovernanceDispatcher 決策融合適配器
======================================
將 decision_fusion / playbook_service / Ollama 的既有能力
組合成「給治理事件用的三維融合介面」。
設計原則:
- 不修改任何 Tier 3 檔decision_manager / learning_service / trust_engine
- 只 consume 公開 APIread-only
- 三維融合LLM × Playbook trust × MCP 情報
- Exception 隔離:任一維度失敗 → 中立值 0.5,不阻塞主流程
融合公式起始權重TODO 移到 settings 由 AI 自學調整):
confidence = w_llm * llm_score + w_playbook * playbook_trust + w_mcp * mcp_score
w_llm=0.4, w_playbook=0.3, w_mcp=0.3
決策分支(閾值 TODO 移到 settings
confidence >= 0.85 → auto_dispatch
0.65 <= conf < 0.85 → pending_approval
conf < 0.65 → skip
2026-05-03 ogt + Claude Sonnet 4.6(亞太): GovernanceDispatcher Wave 2E 實作
"""
from __future__ import annotations
import asyncio
import re
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Literal
import httpx
import structlog
from src.core.config import get_settings
if TYPE_CHECKING:
from src.db.models import AiGovernanceEvent
logger = structlog.get_logger(__name__)
# =============================================================================
# 常數
# TODO: 移到 settingsADR-P2E-FUTURE屆時可讓 AI 自學調整
# =============================================================================
# 三維融合權重0.4 / 0.3 / 0.3
_W_LLM: float = 0.4 # TODO: 由 AI 自學調整,初始值 0.4
_W_PLAYBOOK: float = 0.3 # TODO: 由 AI 自學調整,初始值 0.3
_W_MCP: float = 0.3 # TODO: 由 AI 自學調整,初始值 0.3
# 決策分支閾值
# TODO: 移到 settings未來由 AI 根據 false-positive rate 動態調整
_AUTO_DISPATCH_THRESHOLD: float = 0.85 # >= 此值 → auto_dispatch
_PENDING_APPROVAL_THRESHOLD: float = 0.65 # >= 此值 < AUTO → pending_approval
# # < 此值 → skip
# Ollama 推理超時(秒)
_LLM_TIMEOUT_SEC: float = 30.0
# Prometheus 查詢超時(秒)
_PROM_TIMEOUT_SEC: float = 5.0
# =============================================================================
# FusedDecision 資料結構
# =============================================================================
@dataclass
class FusedDecision:
"""三維融合決策輸出。
所有分數均為 0.0-1.00.5 為中立值,任一維度失敗時使用)。
decision_path 決定 GovernanceDispatcher 寫入哪種 dispatch。
Attributes:
confidence: 三維加權融合分數0.0-1.0
recommended_action: LLM 推薦的修復動作摘要≤200 字)
matched_playbook_id: 最高相似度的 Playbook ID可 None
playbook_trust: matched_playbook 的 trust_score可 None
llm_reasoning: LLM 原始輸出摘要dict供 decision_context JSONB 記錄)
mcp_snapshot: MCP 情報快照dict供 decision_context JSONB 記錄)
decision_path: auto_dispatch / pending_approval / skip
llm_score: LLM 分數0.0-1.0
playbook_score: Playbook 信任分數0.0-1.0,無 playbook 時 0.3
mcp_score: MCP 感官品質分數0.0-1.0
"""
confidence: float
recommended_action: str
matched_playbook_id: str | None
playbook_trust: float | None
llm_reasoning: dict[str, Any]
mcp_snapshot: dict[str, Any]
decision_path: Literal["auto_dispatch", "pending_approval", "skip"]
llm_score: float
playbook_score: float
mcp_score: float
# =============================================================================
# DecisionFusionAdapter
# =============================================================================
class DecisionFusionAdapter:
"""治理事件決策融合適配器。
將 decision_fusion / playbook_service / MCP 的既有能力組合成
「給治理事件用的三維融合介面」。本類不修改任何 Tier 3 檔,只 consume。
不注入 Tier 3 class
- DecisionManager — 有 incident 中心的複雜狀態機,不適合治理事件
- TrustEngine — 只管理 incident 信任分數
- LearningService — 只管理 KM 寫入路徑
本 Adapter 直接呼叫:
- Ollama仿 decision_fusion._score_hermes 模式)→ LLM 推理
- playbook_service.get_recommendations → Playbook trust
- Prometheus provider → MCP 情報
"""
def __init__(self) -> None:
self._settings = get_settings()
# =========================================================================
# Public API
# =========================================================================
async def fuse_decision(self, event: "AiGovernanceEvent") -> FusedDecision:
"""三維融合LLM × Playbook × MCP → FusedDecision。
三個維度並行評估asyncio.gather任一失敗靜默降為 0.5。
依 confidence 決定 decision_path。
Args:
event: AiGovernanceEvent ORM 物件(不修改此物件)
Returns:
FusedDecision 含完整三維快照,供 dispatcher 寫入 decision_context
"""
# 並行取三維分數
results = await asyncio.gather(
self._score_llm(event),
self._score_playbook(event),
self._score_mcp(event),
return_exceptions=True,
)
# 安全解包Exception → 中立值 0.5
llm_result = results[0]
playbook_result = results[1]
mcp_result = results[2]
if isinstance(llm_result, Exception):
logger.warning(
"fusion_llm_score_failed",
event_id=event.id,
event_type=event.event_type,
error=str(llm_result),
)
llm_result = (0.5, "LLM 評估失敗,使用中立值)", {})
if isinstance(playbook_result, Exception):
logger.warning(
"fusion_playbook_score_failed",
event_id=event.id,
error=str(playbook_result),
)
playbook_result = (0.3, None, None)
if isinstance(mcp_result, Exception):
logger.warning(
"fusion_mcp_score_failed",
event_id=event.id,
error=str(mcp_result),
)
mcp_result = (0.5, {})
llm_score, recommended_action, llm_reasoning = llm_result
playbook_score, matched_playbook_id, playbook_trust = playbook_result
mcp_score, mcp_snapshot = mcp_result
# 三維加權融合
# TODO: 移到 settings未來由 AI 自學調整 _W_LLM / _W_PLAYBOOK / _W_MCP
confidence = (
_W_LLM * llm_score
+ _W_PLAYBOOK * playbook_score
+ _W_MCP * mcp_score
)
confidence = max(0.0, min(1.0, confidence))
# 決策分支
# TODO: 閾值移到 settings未來由 AI 根據 false-positive rate 動態調整
if confidence >= _AUTO_DISPATCH_THRESHOLD:
decision_path: Literal["auto_dispatch", "pending_approval", "skip"] = "auto_dispatch"
elif confidence >= _PENDING_APPROVAL_THRESHOLD:
decision_path = "pending_approval"
else:
decision_path = "skip"
logger.info(
"governance_fusion_complete",
event_id=event.id,
event_type=event.event_type,
llm_score=round(llm_score, 4),
playbook_score=round(playbook_score, 4),
mcp_score=round(mcp_score, 4),
confidence=round(confidence, 4),
decision_path=decision_path,
)
return FusedDecision(
confidence=confidence,
recommended_action=recommended_action,
matched_playbook_id=matched_playbook_id,
playbook_trust=playbook_trust,
llm_reasoning=llm_reasoning,
mcp_snapshot=mcp_snapshot,
decision_path=decision_path,
llm_score=llm_score,
playbook_score=playbook_score,
mcp_score=mcp_score,
)
# =========================================================================
# 維度 1LLM 推理Ollama qwen3:8b — 仿 decision_fusion._score_hermes
# =========================================================================
async def _score_llm(
self, event: "AiGovernanceEvent"
) -> tuple[float, str, dict[str, Any]]:
"""Ollama LLM 推理:治理事件情境 → 建議動作 + 信心度。
Prompt 設計:
- 提供 event_type + details 摘要sanitize 後)
- 要求輸出「信心度0-1+ 建議動作」
Returns:
(llm_score, recommended_action, llm_reasoning_dict)
"""
event_type = str(event.event_type or "unknown")
details_summary = self._summarize_details(event.details or {})
prompt = (
"你是 AIOps 治理分析員。根據以下治理事件,評估自動修復的可行性與建議動作。\n\n"
f"【事件類型】{event_type}\n"
f"【事件摘要】{details_summary}\n\n"
"請以以下格式回應(不超過 200 字):\n"
"CONFIDENCE: [0.0-1.0 的數字]\n"
"ACTION: [具體建議修復動作≤100字]\n\n"
"注意:\n"
"- CONFIDENCE 越高表示越適合自動執行\n"
"- 若事件模糊或影響範圍不明給低分0.3-0.5\n"
"- 若有明確、低風險的修復路徑可給高分0.7-0.9\n"
"只輸出 CONFIDENCE 和 ACTION 兩行,不要其他解釋。"
)
ollama_url = getattr(self._settings, "OLLAMA_URL", "http://192.168.0.111:11434")
try:
async with httpx.AsyncClient(
timeout=httpx.Timeout(_LLM_TIMEOUT_SEC, connect=5.0)
) as client:
resp = await client.post(
f"{ollama_url}/api/generate",
json={
"model": "qwen3:8b",
"prompt": prompt,
"stream": False,
"options": {"num_predict": 128, "temperature": 0.1},
},
)
if resp.status_code != 200:
logger.warning(
"fusion_llm_http_error",
status=resp.status_code,
event_id=event.id,
)
return 0.5, "LLM 不可用,使用中立值)", {"error": f"http_{resp.status_code}"}
raw_text = resp.json().get("response", "").strip()
except Exception as exc:
logger.warning("fusion_llm_request_failed", event_id=event.id, error=str(exc))
return 0.5, "LLM 連線失敗,使用中立值)", {"error": str(exc)}
# 移除 <think> 標籤qwen3 CoT 輸出)
clean = re.sub(r"<think>.*?</think>", "", raw_text, flags=re.DOTALL).strip()
# 解析 CONFIDENCE 行
llm_score = 0.5
conf_match = re.search(r"CONFIDENCE:\s*([01]?\.\d+|[01])", clean, re.IGNORECASE)
if conf_match:
try:
llm_score = max(0.0, min(1.0, float(conf_match.group(1))))
except ValueError:
pass
# 解析 ACTION 行
recommended_action = "LLM 未提供明確建議)"
action_match = re.search(r"ACTION:\s*(.+)", clean, re.IGNORECASE)
if action_match:
recommended_action = action_match.group(1).strip()[:200]
llm_reasoning = {
"raw_text_preview": raw_text[:300],
"parsed_confidence": llm_score,
"parsed_action": recommended_action,
"event_type": event_type,
}
logger.debug(
"fusion_llm_scored",
event_id=event.id,
llm_score=llm_score,
action_preview=recommended_action[:60],
)
return llm_score, recommended_action, llm_reasoning
# =========================================================================
# 維度 2Playbook 比對 + trust_score
# =========================================================================
async def _score_playbook(
self, event: "AiGovernanceEvent"
) -> tuple[float, str | None, float | None]:
"""Playbook 相似度比對 → 取最高 trust_score。
治理事件沒有 SymptomPattern用 event_type 作為 alert_name 搜尋。
無命中時返回保守初始值 (0.3, None, None)。
Returns:
(playbook_score, matched_playbook_id, playbook_trust)
"""
from src.models.playbook import SymptomPattern
from src.services.playbook_service import get_playbook_service
symptoms = SymptomPattern(
alert_names=[event.event_type or "unknown"],
affected_services=[],
severity_range=["P2"],
keywords=self._extract_keywords(event.details or {}),
)
try:
svc = get_playbook_service()
recommendations = await svc.get_recommendations(
symptoms=symptoms,
top_k=1,
use_rag=False, # 治理事件用 Jaccard 精確比對即可
)
except Exception as exc:
logger.warning("fusion_playbook_lookup_failed", event_id=event.id, error=str(exc))
return 0.3, None, None
if not recommendations:
logger.debug("fusion_playbook_no_match", event_id=event.id, event_type=event.event_type)
return 0.3, None, None
best = recommendations[0]
trust = float(best.playbook.trust_score)
playbook_id = best.playbook.playbook_id
logger.debug(
"fusion_playbook_matched",
event_id=event.id,
playbook_id=playbook_id,
trust_score=trust,
similarity=round(best.similarity_score, 4),
)
return trust, playbook_id, trust
# =========================================================================
# 維度 3MCP 情報Prometheus
# =========================================================================
async def _score_mcp(
self, event: "AiGovernanceEvent"
) -> tuple[float, dict[str, Any]]:
"""Prometheus 情報採集 → MCP 感官品質分數。
查詢與事件相關的核心指標autonomy_rate / hallucination_rate
MCP 不可用時返回中立值 (0.5, {})。
Returns:
(mcp_score, mcp_snapshot_dict)
"""
prom_url = getattr(
self._settings, "PROMETHEUS_URL", "http://prometheus.observability.svc:9090"
)
# 依 event_type 選擇查詢指標(治理事件相關)
queries: dict[str, str] = self._get_mcp_queries(event.event_type or "unknown")
snapshot: dict[str, Any] = {}
success_count = 0
total_count = len(queries)
if total_count == 0:
return 0.5, {"reason": "no_queries_for_event_type"}
try:
async with httpx.AsyncClient(timeout=_PROM_TIMEOUT_SEC) as client:
for metric_name, query in queries.items():
try:
resp = await client.get(
f"{prom_url}/api/v1/query",
params={"query": query},
)
data = resp.json()
if data.get("status") == "success":
result_list = data.get("data", {}).get("result", [])
if result_list:
value = float(result_list[0]["value"][1])
snapshot[metric_name] = round(value, 4)
success_count += 1
else:
snapshot[metric_name] = None # 有回應但無資料
except Exception as exc:
snapshot[metric_name] = f"error:{exc!s:.60}"
except Exception as exc:
logger.warning("fusion_mcp_prometheus_failed", event_id=event.id, error=str(exc))
return 0.5, {"error": str(exc)}
# 品質分數:成功取得資料的指標比例(映射到 [0.2, 0.9]
if total_count > 0:
ratio = success_count / total_count
mcp_score = 0.2 + 0.7 * ratio
else:
mcp_score = 0.5
snapshot["_meta"] = {
"success_count": success_count,
"total_queries": total_count,
"quality_score": round(mcp_score, 4),
}
logger.debug(
"fusion_mcp_scored",
event_id=event.id,
mcp_score=round(mcp_score, 4),
success=success_count,
total=total_count,
)
return mcp_score, snapshot
# =========================================================================
# Helpers
# =========================================================================
@staticmethod
def _summarize_details(details: dict[str, Any]) -> str:
"""從 details dict 提取可讀摘要≤300 字)。"""
if not details:
return "(無詳細資訊)"
parts: list[str] = []
# 常見欄位優先展示
for key in ("status", "impact", "remediation", "reason"):
val = details.get(key)
if val is None:
continue
if isinstance(val, dict):
inner = "; ".join(f"{k}={v}" for k, v in list(val.items())[:4])
parts.append(f"{key}: {inner}")
elif isinstance(val, (str, int, float)):
parts.append(f"{key}: {val!s:.80}")
if not parts:
# fallback: 取前幾個 top-level k=v
parts = [f"{k}={v!s:.40}" for k, v in list(details.items())[:5]]
return "; ".join(parts)[:300]
@staticmethod
def _extract_keywords(details: dict[str, Any]) -> list[str]:
"""從 details 提取關鍵字供 Playbook 搜尋(最多 5 個)。"""
keywords: list[str] = []
for key in ("remediation", "actionable", "impact"):
val = details.get(key)
if isinstance(val, dict):
for sub_key in ("next_action", "items"):
sub = val.get(sub_key)
if isinstance(sub, str):
keywords.append(sub[:50])
elif isinstance(sub, list):
keywords.extend(str(x)[:40] for x in sub[:2])
return keywords[:5]
@staticmethod
def _get_mcp_queries(event_type: str) -> dict[str, str]:
"""依 event_type 返回相關 Prometheus 查詢指標。
不硬寫 event_type → action 對應規則,僅決定「看哪些指標」。
"""
# 通用指標(所有 event_type 都查)
base_queries: dict[str, str] = {
"autonomy_rate": "sli:autonomy_rate:5m",
"decision_accuracy": "sli:decision_accuracy:5m",
}
# 依 event_type 補充針對性指標
extra: dict[str, str] = {}
if event_type in ("trust_drift", "execution_blast_radius"):
extra["km_growth_rate"] = "sli:km_growth_rate:24h"
elif event_type in ("knowledge_degradation", "kb_stale"):
extra["km_growth_rate"] = "sli:km_growth_rate:24h"
extra["confidence_calibration"] = "sli:confidence_calibration:1h"
elif event_type == "llm_hallucination":
extra["confidence_calibration"] = "sli:confidence_calibration:1h"
elif event_type == "governance_slo_data_gap":
extra["confidence_calibration"] = "sli:confidence_calibration:1h"
extra["km_growth_rate"] = "sli:km_growth_rate:24h"
return {**base_queries, **extra}
# =============================================================================
# Singleton
# =============================================================================
_adapter_instance: DecisionFusionAdapter | None = None
def get_decision_fusion_adapter() -> DecisionFusionAdapter:
"""取得 DecisionFusionAdapter 單例lazy init"""
global _adapter_instance
if _adapter_instance is None:
_adapter_instance = DecisionFusionAdapter()
return _adapter_instance
def reset_decision_fusion_adapter() -> None:
"""重置 singleton測試用"""
global _adapter_instance
_adapter_instance = None

View File

@@ -0,0 +1,304 @@
"""
GovernanceDispatcher — 治理事件 → 修復派遣
============================================
Poll 模式:每 30s 掃 ai_governance_events 中 resolved=False 且
無活躍 dispatch 的事件,呼叫 DecisionFusionAdapter 三維融合後
寫入 governance_remediation_dispatch 表。
職責:
1. Poll unresolved 治理事件(不直接修改 ai_governance_events 表)
2. 呼叫 DecisionFusionAdapter.fuse_decision → FusedDecision
3. 依 decision_path 決定是否寫入 dispatch
4. 不執行 remediation實際執行由 approval_execution / auto_repair 消費 dispatch 表)
Tier 3 鐵線(絕不觸碰):
- decision_manager.py / learning_service.py / trust_engine.py
- 本模組透過 DecisionFusionAdapterwrapper間接使用這些能力
2026-05-03 ogt + Claude Sonnet 4.6(亞太): GovernanceDispatcher Wave 2E 實作
"""
from __future__ import annotations
import asyncio
from typing import Any
import structlog
from sqlalchemy import select
from src.db.base import get_db_context
from src.db.models import AiGovernanceEvent
from src.repositories.governance_remediation_dispatch_repo import (
DispatchAlreadyActive,
create_dispatch,
get_active_for_event,
)
from src.services.decision_fusion_adapter import FusedDecision, get_decision_fusion_adapter
logger = structlog.get_logger(__name__)
# =============================================================================
# 常數
# TODO: 移到 settingsADR-P2E-FUTURE目前暫時 hardcode
# =============================================================================
# Poll 間隔(秒)
# TODO: 移到 settings允許運維不重啟調整 poll 間隔
_DISPATCHER_INTERVAL_SEC: int = 30
# 每輪最多處理幾個事件(避免單輪阻塞過長)
_MAX_EVENTS_PER_CYCLE: int = 10
# 允許建立 dispatch 的 event_type對齊 governance_event_type enum
_DISPATCHABLE_EVENT_TYPES: frozenset[str] = frozenset({
"trust_drift",
"knowledge_degradation",
"llm_hallucination",
"execution_blast_radius",
"governance_slo_data_gap",
})
# =============================================================================
# 核心函數
# =============================================================================
async def dispatch_governance_event(event: AiGovernanceEvent) -> str | None:
"""處理單一治理事件:決策融合 → 寫 dispatch 記錄。
Args:
event: AiGovernanceEvent ORM 物件(唯讀,不修改)
Returns:
建立的 dispatch_idstr或 Noneskip / 已有活躍 dispatch
"""
event_id = event.id
event_type = event.event_type
# Step 1: 檢查是否已有活躍 dispatch冪等保護
existing = await get_active_for_event(event_id)
if existing is not None:
logger.debug(
"governance_dispatch_skipped_already_active",
event_id=event_id,
event_type=event_type,
existing_dispatch_id=existing.id,
existing_status=existing.dispatch_status,
)
return None
# Step 2: 決策融合三維LLM × Playbook × MCP
adapter = get_decision_fusion_adapter()
try:
decision: FusedDecision = await adapter.fuse_decision(event)
except Exception as exc:
logger.warning(
"governance_fusion_failed",
event_id=event_id,
event_type=event_type,
error=str(exc),
)
# LLM 失敗 fallbackskip + log不寫 dispatch
logger.info(
"governance_dispatch_fallback_skip",
event_id=event_id,
reason="fusion_exception",
)
return None
# Step 3: 依 decision_path 決定要不要寫 dispatch
if decision.decision_path == "skip":
logger.info(
"governance_dispatch_path_skip",
event_id=event_id,
event_type=event_type,
confidence=round(decision.confidence, 4),
)
return None
# Step 4: 決定 executor_type 與 dispatch_status
# auto_dispatch → dispatched下游 auto_repair 消費)
# pending_approval → pending等人工審核
if decision.decision_path == "auto_dispatch":
executor_type = "playbook_executor"
initial_status_note = "auto_dispatch"
else: # pending_approval
executor_type = "manual"
initial_status_note = "pending_approval"
# Step 5: 建構 decision_context JSONB完整三維快照
decision_context = _build_decision_context(event, decision)
# Step 6: 寫入 governance_remediation_dispatch用 repo 函數)
try:
dispatch_row = await create_dispatch(
event_id=event_id,
event_type=event_type,
executor_type=executor_type,
playbook_id=decision.matched_playbook_id,
decision_context=decision_context,
created_by="governance_dispatcher",
)
except DispatchAlreadyActive:
# 並行 race condition另一個 worker 先建立了 dispatch
logger.info(
"governance_dispatch_race_condition",
event_id=event_id,
event_type=event_type,
)
return None
except Exception as exc:
logger.warning(
"governance_dispatch_create_failed",
event_id=event_id,
event_type=event_type,
error=str(exc),
)
return None
logger.info(
"governance_dispatched",
dispatch_id=dispatch_row.id,
event_id=event_id,
event_type=event_type,
decision_path=decision.decision_path,
confidence=round(decision.confidence, 4),
executor_type=executor_type,
playbook_id=decision.matched_playbook_id,
)
return dispatch_row.id
async def _poll_unresolved_events() -> list[AiGovernanceEvent]:
"""查詢 unresolved 且 event_type 在 dispatchable 範圍內的治理事件。
Returns:
最多 _MAX_EVENTS_PER_CYCLE 筆 AiGovernanceEvent ORM 物件列表
"""
async with get_db_context() as db:
result = await db.execute(
select(AiGovernanceEvent)
.where(AiGovernanceEvent.resolved.is_(False))
.where(AiGovernanceEvent.event_type.in_(list(_DISPATCHABLE_EVENT_TYPES)))
.order_by(AiGovernanceEvent.triggered_at.asc())
.limit(_MAX_EVENTS_PER_CYCLE)
)
rows = result.scalars().all()
return list(rows)
def _build_decision_context(
event: AiGovernanceEvent,
decision: FusedDecision,
) -> dict[str, Any]:
"""建構 decision_context JSONB完整三維輸入快照
規格對齊 DecisionContextV1models/governance_dispatch.py
但直接建 dict 不依賴 Pydantic model避免引入額外依賴
Fields:
version: schema 版本v1
trigger_source: 觸發來源
suggested_action: AI 建議的修復動作摘要
fusion_scores: 三維分數詳情
llm_reasoning: LLM 原始輸出摘要
mcp_snapshot: MCP 情報快照
decision_path: 決策分支
confidence: 最終融合信心度
"""
return {
"version": "v1",
"trigger_source": "governance_dispatcher",
"triggered_metric": event.event_type,
"metric_value": decision.confidence,
"threshold": 0.85, # TODO: 移到 settings
"suggested_action": decision.recommended_action,
"fusion_scores": {
"llm_score": round(decision.llm_score, 4),
"playbook_score": round(decision.playbook_score, 4),
"mcp_score": round(decision.mcp_score, 4),
"confidence": round(decision.confidence, 4),
"weights": {"llm": 0.4, "playbook": 0.3, "mcp": 0.3}, # TODO: 移到 settings
},
"llm_reasoning": decision.llm_reasoning,
"mcp_snapshot": decision.mcp_snapshot,
"decision_path": decision.decision_path,
"matched_playbook_id": decision.matched_playbook_id,
"playbook_trust": decision.playbook_trust,
"affected_resources": [event.event_type],
"extra": {
"event_id": event.id,
"event_details_keys": list((event.details or {}).keys()),
},
}
# =============================================================================
# 排程迴圈(仿 run_governance_loop 模式)
# =============================================================================
async def run_governance_dispatcher_loop(
interval_seconds: int = _DISPATCHER_INTERVAL_SEC,
) -> None:
"""每 30s 掃 unresolved 事件 → dispatch。
仿照 governance_agent.run_governance_loop 模式:
- while True → try/except → sleep
- 任一事件失敗不阻塞其他事件(獨立 try/except
- CancelledError 向上傳播(允許 graceful shutdown
2026-05-03 ogt + Claude Sonnet 4.6(亞太): Wave 2E 實作
"""
logger.info(
"governance_dispatcher_loop_started",
interval_seconds=interval_seconds,
max_events_per_cycle=_MAX_EVENTS_PER_CYCLE,
)
while True:
try:
events = await _poll_unresolved_events()
if events:
logger.info(
"governance_dispatcher_cycle_start",
event_count=len(events),
)
dispatched = 0
skipped = 0
for event in events:
try:
result = await dispatch_governance_event(event)
if result is not None:
dispatched += 1
else:
skipped += 1
except asyncio.CancelledError:
raise
except Exception as exc:
logger.warning(
"governance_dispatcher_event_error",
event_id=event.id,
event_type=event.event_type,
error=str(exc),
)
skipped += 1
logger.info(
"governance_dispatcher_cycle_done",
total=len(events),
dispatched=dispatched,
skipped=skipped,
)
else:
logger.debug("governance_dispatcher_no_events")
except asyncio.CancelledError:
logger.info("governance_dispatcher_loop_cancelled")
raise
except Exception as exc:
logger.warning("governance_dispatcher_loop_error", error=str(exc))
await asyncio.sleep(interval_seconds)

View File

@@ -0,0 +1,384 @@
"""
Governance Query Service — /governance 頁面 DB 查詢邏輯
======================================================
封裝 3 個 governance endpoint 的資料庫查詢。
Router 層禁直接存取 DBleWOOOgo 積木化鐵律)。
函式清單:
query_governance_events(...) → GovernanceEventsResponse
query_governance_queue(...) → GovernanceQueueResponse
query_governance_summary(...) → GovernanceSummaryResponse
Graceful fallback 規則:
queue endpoint — governance_remediation_dispatch 表可能尚未建立Track D 進行中)。
捕捉 sqlalchemy.exc.ProgrammingError表不存在後回傳 table_pending=True 的空列表,
確保 API 在表建立前不拋 500。
2026-05-02 ogt + Claude Sonnet 4.6 Asia/Taipei
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import structlog
from sqlalchemy import func, select, text
from sqlalchemy.exc import ProgrammingError
from src.db.base import get_db_context
from src.db.models import AiGovernanceEvent
from src.models.governance import (
DailyCount,
DispatchItem,
GovernanceEvent,
GovernanceEventsResponse,
GovernanceQueueResponse,
GovernanceSummaryResponse,
map_severity,
)
from src.utils.timezone import now_taipei
logger = structlog.get_logger(__name__)
# =============================================================================
# 常數
# =============================================================================
_TAIPEI = timezone(timedelta(hours=8))
# =============================================================================
# helpers
# =============================================================================
def _extract_impact(details: dict) -> str:
"""
從 details 抽摘要字串≤80 字。
優先讀 details["impact"]dict取 status + 主要 metric 欄位。
fallback 到 details 頂層常見欄位。
"""
impact_block = details.get("impact")
if isinstance(impact_block, dict):
parts: list[str] = []
if "status" in impact_block:
parts.append(str(impact_block["status"]))
# 主要 metric 欄位優先順序
for key in ("metric", "value", "rate", "ratio", "score", "count"):
if key in impact_block:
parts.append(f"{key}={impact_block[key]}")
break
summary = " ".join(parts)
return summary[:80] if summary else ""
# fallback: 頂層常見欄位
for key in ("message", "reason", "summary", "description"):
val = details.get(key)
if isinstance(val, str) and val:
return val[:80]
# 最後 fallback: 把 details 第一個 string value 截取
for val in details.values():
if isinstance(val, str) and val:
return val[:80]
return ""
def _to_governance_event(row: AiGovernanceEvent) -> GovernanceEvent:
details = row.details or {}
return GovernanceEvent(
id=row.id,
event_type=row.event_type,
severity=map_severity(row.event_type),
triggered_at=row.triggered_at,
resolved=row.resolved,
resolved_at=row.resolved_at,
impact=_extract_impact(details),
details=details,
remediation=details.get("remediation"),
dispatch_ids=details.get("dispatch_ids", []),
)
# =============================================================================
# Endpoint 1: events
# =============================================================================
async def query_governance_events(
*,
event_types: list[str] | None = None,
from_dt: datetime | None = None,
to_dt: datetime | None = None,
status: str | None = None, # "resolved" | "unresolved"
severity: str | None = None, # "critical" | "warning" | "info"
page: int = 1,
size: int = 20,
) -> GovernanceEventsResponse:
"""
查詢 ai_governance_events 表,支援多維度過濾與分頁。
severity 過濾在 Python 層完成event_type 映射);
其他過濾在 SQL 層完成(效能優先)。
"""
async with get_db_context() as db:
stmt = select(AiGovernanceEvent)
if event_types:
stmt = stmt.where(AiGovernanceEvent.event_type.in_(event_types))
if from_dt is not None:
stmt = stmt.where(AiGovernanceEvent.triggered_at >= from_dt)
if to_dt is not None:
stmt = stmt.where(AiGovernanceEvent.triggered_at <= to_dt)
if status == "resolved":
stmt = stmt.where(AiGovernanceEvent.resolved.is_(True))
elif status == "unresolved":
stmt = stmt.where(AiGovernanceEvent.resolved.is_(False))
stmt = stmt.order_by(AiGovernanceEvent.triggered_at.desc())
# 取全部結果severity 在 Python 層過濾(避免 DB 不認識 mapping 邏輯)
result = await db.execute(stmt)
all_rows = result.scalars().all()
events = [_to_governance_event(r) for r in all_rows]
# severity 過濾Python 層)
if severity:
from src.models.governance import _CRITICAL_TYPES, _WARNING_TYPES
if severity == "critical":
events = [e for e in events if e.event_type in _CRITICAL_TYPES]
elif severity == "warning":
events = [e for e in events if e.event_type in _WARNING_TYPES]
elif severity == "info":
events = [
e for e in events
if e.event_type not in _CRITICAL_TYPES and e.event_type not in _WARNING_TYPES
]
total = len(events)
offset = (page - 1) * size
page_items = events[offset: offset + size]
return GovernanceEventsResponse(
items=page_items,
total=total,
page=page,
size=size,
)
# =============================================================================
# Endpoint 2: queue
# =============================================================================
async def query_governance_queue(
*,
dispatch_status: str = "pending",
page: int = 1,
size: int = 20,
) -> GovernanceQueueResponse:
"""
查詢 governance_remediation_dispatch 表。
Track D 進行中,表可能尚未建立。
捕捉 ProgrammingError → 回傳 table_pending=True 的空 response。
proposed_action 從 decision_context JSONB 抽取Track D 完成後可改為真實 join
"""
try:
return await _query_dispatch_table(
dispatch_status=dispatch_status,
page=page,
size=size,
)
except ProgrammingError as exc:
logger.warning(
"governance_dispatch_table_not_ready",
error=str(exc),
)
return GovernanceQueueResponse(
items=[],
total=0,
page=page,
size=size,
table_pending=True,
)
except ImportError as exc:
logger.warning(
"governance_dispatch_model_not_ready",
error=str(exc),
)
return GovernanceQueueResponse(
items=[],
total=0,
page=page,
size=size,
table_pending=True,
)
async def _query_dispatch_table(
*,
dispatch_status: str,
page: int,
size: int,
) -> GovernanceQueueResponse:
"""實際查詢 governance_remediation_dispatch 表(不含 graceful fallback."""
# 動態 importTrack D 完成前 ORM class 可能不存在
# 使用 raw SQL 降低 ORM 模型缺失的耦合風險
sql = text("""
SELECT
d.id,
d.governance_event_id,
e.event_type,
d.dispatch_status,
d.decision_context,
d.playbook_id,
d.created_at,
d.dispatched_at,
d.completed_at,
d.operator_note
FROM governance_remediation_dispatch d
JOIN ai_governance_events e ON e.id = d.governance_event_id
WHERE d.dispatch_status = :dispatch_status
ORDER BY d.created_at DESC
""")
count_sql = text("""
SELECT count(*) AS cnt
FROM governance_remediation_dispatch
WHERE dispatch_status = :dispatch_status
""")
async with get_db_context() as db:
count_row = await db.execute(count_sql, {"dispatch_status": dispatch_status})
total = int(count_row.scalar_one_or_none() or 0)
rows = await db.execute(
sql.bindparams(dispatch_status=dispatch_status),
)
all_rows = rows.fetchall()
offset = (page - 1) * size
page_rows = all_rows[offset: offset + size]
items: list[DispatchItem] = []
for row in page_rows:
decision_ctx: dict = (row.decision_context or {}) if hasattr(row, "decision_context") else {}
proposed_action = _extract_proposed_action(decision_ctx)
# playbook_trust: Track D 完成後改為 JOIN playbooks 表取 trust_score
# 現階段從 decision_context 取 mock 值
playbook_trust_raw = decision_ctx.get("playbook_trust")
try:
playbook_trust = float(playbook_trust_raw) if playbook_trust_raw is not None else None
except (TypeError, ValueError):
playbook_trust = None
items.append(DispatchItem(
id=str(row.id),
governance_event_id=str(row.governance_event_id),
event_type=str(row.event_type),
dispatch_status=str(row.dispatch_status),
proposed_action=proposed_action,
playbook_id=str(row.playbook_id) if row.playbook_id else None,
playbook_trust=playbook_trust,
created_at=row.created_at,
dispatched_at=row.dispatched_at,
completed_at=row.completed_at,
operator_note=row.operator_note,
))
return GovernanceQueueResponse(
items=items,
total=total,
page=page,
size=size,
table_pending=False,
)
def _extract_proposed_action(decision_ctx: dict) -> str:
"""
從 decision_context JSONB 抽取 proposed_action≤120 字。
Track D 完成後此函式可改為從真實欄位讀取。
"""
for key in ("proposed_action", "action", "suggestion", "description", "summary"):
val = decision_ctx.get(key)
if isinstance(val, str) and val:
return val[:120]
return "(待補充)"
# =============================================================================
# Endpoint 3: summary
# =============================================================================
async def query_governance_summary(*, days: int = 30) -> GovernanceSummaryResponse:
"""
過去 N 天 SLO 違反時序統計 + compliance_rate。
compliance_rate = 1 - unresolved / totaltotal=0 時回 1.0
"""
since = now_taipei() - timedelta(days=days)
async with get_db_context() as db:
# 總數 & 未解決數
count_stmt = select(
func.count().label("total"),
func.count().filter(AiGovernanceEvent.resolved.is_(False)).label("unresolved"),
).where(AiGovernanceEvent.triggered_at >= since)
count_row = await db.execute(count_stmt)
counts = count_row.one()
total_events = int(counts.total)
unresolved_count = int(counts.unresolved)
# 每日計數DATE_TRUNC 在 Postgres 端執行)
daily_sql = text("""
SELECT
DATE_TRUNC('day', triggered_at AT TIME ZONE 'Asia/Taipei')::date AS day,
event_type,
count(*) AS cnt
FROM ai_governance_events
WHERE triggered_at >= :since
GROUP BY day, event_type
ORDER BY day ASC
""")
daily_result = await db.execute(daily_sql, {"since": since})
daily_rows = daily_result.fetchall()
# 彙整每日資料
daily_map: dict[str, dict[str, int]] = {}
for row in daily_rows:
day_str = row.day.strftime("%Y-%m-%d") if hasattr(row.day, "strftime") else str(row.day)
if day_str not in daily_map:
daily_map[day_str] = {}
daily_map[day_str][row.event_type] = int(row.cnt)
daily_counts = [
DailyCount(
date=day_str,
total=sum(by_type.values()),
by_type=by_type,
)
for day_str, by_type in sorted(daily_map.items())
]
if total_events == 0:
compliance_rate = 1.0
else:
compliance_rate = round(1.0 - unresolved_count / total_events, 4)
return GovernanceSummaryResponse(
compliance_rate=compliance_rate,
total_events=total_events,
unresolved_count=unresolved_count,
daily_counts=daily_counts,
)

View File

@@ -1,7 +1,16 @@
"""
AWOOOI AIOps Phase 6 — Trust Drift Detector信任度漂移偵測器
===============================================================
職責:偵測 Playbook trust_score 分布的兩種極端偏態:
【LIB ONLY — NO SIDE EFFECTS】
2026-05-02 ogt + Claude Sonnet 4.6(亞太): 整併雙寫路徑
背景:原本 watchdog W-6 呼叫 detector.run() 會直接寫 event_type=trust_drift 到
ai_governance_eventsgovernance_agent.check_trust_drift() 每 1h 也寫同一 event_type。
造成雙寫、語義混淆,下游 consumer 無法區分 source-of-truth。
整併決策governance_agent.check_trust_drift() 為唯一 source-of-truth功能更完整
含 auto-deprecate + Telegram 推送)。本模組降為純統計 lib不再自行寫 PG。
職責(整併後):純統計 lib偵測 Playbook trust_score 分布的兩種極端偏態:
極端 A「盲目樂觀」> 70% Playbook trust_score > 0.9
→ 可能是 PostExecutionVerifier 失效,或 RAG 資料被污染,讓所有 AI 都以為「我很棒」
@@ -11,13 +20,16 @@ AWOOOI AIOps Phase 6 — Trust Drift Detector信任度漂移偵測器
→ 可能是 EWMA 計算出錯,或所有執行都被誤判失敗,讓 AI 對自己完全沒信心
→ 學習機制可能卡死
設計原則:
設計原則(整併後)
1. 只讀 DB不修改任何數據
2. 違反 → 寫 trust_drift 事件到 ai_governance_events
3. 樣本不足(< 10 個 approved Playbook→ 跳過偵測,不告警
2. detect() / run() 只回傳 TrustDistribution不寫 ai_governance_events
3. save_drift_event() 保留供呼叫方(如需要分布事件)顯式呼叫,不在 run() 內自動觸發
4. 樣本不足(< 10 個 approved Playbook→ 跳過偵測,不告警
5. AI 治理事件的唯一寫入點governance_agent.check_trust_drift()
ADR-087: AI 自我治理閉環
2026-04-15 ogt + Claude Sonnet 4.6(亞太): Phase 6 初始建立
2026-05-02 ogt + Claude Sonnet 4.6(亞太): 降為 lib only移除 run() 自動 PG 寫入
"""
from __future__ import annotations
@@ -222,11 +234,14 @@ class TrustDriftDetector:
logger.error("trust_drift_event_save_error", error=str(e))
async def run(self) -> TrustDistribution:
"""完整執行:偵測 → 如有漂移則寫事件。"""
dist = await self.detect()
if dist.drift_detected:
await self.save_drift_event(dist)
return dist
"""統計偵測LIB ONLY只回傳 TrustDistribution不寫 ai_governance_events。
2026-05-02 ogt + Claude Sonnet 4.6(亞太): 整併雙寫路徑
原行為detect() 後若 drift_detected 自動呼叫 save_drift_event() 寫 PG。
改為:只回傳結果,由呼叫方決定是否寫入。
ai_governance_events 的唯一寫入點governance_agent.check_trust_drift()。
"""
return await self.detect()
# ─────────────────────────────────────────────────────────────────────────────