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
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:
538
apps/api/src/services/decision_fusion_adapter.py
Normal file
538
apps/api/src/services/decision_fusion_adapter.py
Normal file
@@ -0,0 +1,538 @@
|
||||
"""
|
||||
GovernanceDispatcher 決策融合適配器
|
||||
======================================
|
||||
將 decision_fusion / playbook_service / Ollama 的既有能力
|
||||
組合成「給治理事件用的三維融合介面」。
|
||||
|
||||
設計原則:
|
||||
- 不修改任何 Tier 3 檔(decision_manager / learning_service / trust_engine)
|
||||
- 只 consume 公開 API(read-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: 移到 settings(ADR-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.0(0.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,
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# 維度 1:LLM 推理(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
|
||||
|
||||
# =========================================================================
|
||||
# 維度 2:Playbook 比對 + 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
|
||||
|
||||
# =========================================================================
|
||||
# 維度 3:MCP 情報(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
|
||||
304
apps/api/src/services/governance_dispatcher.py
Normal file
304
apps/api/src/services/governance_dispatcher.py
Normal 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
|
||||
- 本模組透過 DecisionFusionAdapter(wrapper)間接使用這些能力
|
||||
|
||||
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: 移到 settings(ADR-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_id(str),或 None(skip / 已有活躍 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 失敗 fallback:skip + 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(完整三維輸入快照)。
|
||||
|
||||
規格對齊 DecisionContextV1(models/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)
|
||||
384
apps/api/src/services/governance_query_service.py
Normal file
384
apps/api/src/services/governance_query_service.py
Normal file
@@ -0,0 +1,384 @@
|
||||
"""
|
||||
Governance Query Service — /governance 頁面 DB 查詢邏輯
|
||||
======================================================
|
||||
封裝 3 個 governance endpoint 的資料庫查詢。
|
||||
Router 層禁直接存取 DB(leWOOOgo 積木化鐵律)。
|
||||
|
||||
函式清單:
|
||||
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)."""
|
||||
# 動態 import:Track 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 / total(total=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,
|
||||
)
|
||||
@@ -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_events;governance_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()
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user