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:
@@ -0,0 +1,386 @@
|
||||
"""
|
||||
GovernanceRemediationDispatch Repository
|
||||
=========================================
|
||||
Wave 2 D: 治理事件修復派遣 Repository 層
|
||||
|
||||
職責: GovernanceRemediationDispatch 的 CRUD 與狀態機操作
|
||||
設計: 純 async function,不依賴 Session class(對齊 approval_repository.py 風格)
|
||||
|
||||
狀態機合法轉換:
|
||||
pending → dispatched | skipped | cancelled
|
||||
dispatched → executing | failed | cancelled
|
||||
executing → succeeded | failed | cancelled
|
||||
failed → pending(僅當 attempt_count < max_attempts,且必須 INSERT 新 row)
|
||||
succeeded / cancelled / skipped:terminal(禁止任何轉換)
|
||||
|
||||
失敗重試:INSERT 新 row(attempt_count+1),舊 row 永遠保留 failed(審計痕跡)
|
||||
|
||||
2026-05-03 ogt + Claude Sonnet 4.6(亞太): Wave 2 D db-expert spec 實作
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from src.db.base import get_db_context
|
||||
from src.db.models import GovernanceRemediationDispatch, generate_uuid, taipei_now
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# =============================================================================
|
||||
# 狀態機常量
|
||||
# =============================================================================
|
||||
|
||||
# 合法轉換表:from_status → set(to_status)
|
||||
_VALID_TRANSITIONS: dict[str, set[str]] = {
|
||||
"pending": {"dispatched", "skipped", "cancelled"},
|
||||
"dispatched": {"executing", "failed", "cancelled"},
|
||||
"executing": {"succeeded", "failed", "cancelled"},
|
||||
# failed → pending 由 record_failure_and_retry 負責(INSERT 新 row)
|
||||
# succeeded / cancelled / skipped:terminal,無合法後繼
|
||||
}
|
||||
|
||||
TERMINAL_STATUSES: frozenset[str] = frozenset({"succeeded", "cancelled", "skipped"})
|
||||
ACTIVE_STATUSES: frozenset[str] = frozenset({"pending", "dispatched", "executing"})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 自訂例外
|
||||
# =============================================================================
|
||||
|
||||
class DispatchAlreadyActive(Exception):
|
||||
"""同一 governance_event_id 已有活躍 dispatch(partial unique index 違反)"""
|
||||
|
||||
|
||||
class InvalidStatusTransition(Exception):
|
||||
"""狀態機轉換不合法"""
|
||||
|
||||
|
||||
class DispatchNotFound(Exception):
|
||||
"""找不到指定 dispatch_id 的記錄"""
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Repository 函數
|
||||
# =============================================================================
|
||||
|
||||
async def create_dispatch(
|
||||
event_id: str,
|
||||
event_type: str,
|
||||
executor_type: str,
|
||||
*,
|
||||
playbook_id: str | None = None,
|
||||
incident_id: str | None = None,
|
||||
approval_id: str | None = None,
|
||||
decision_context: dict[str, Any] | None = None,
|
||||
max_attempts: int = 3,
|
||||
attempt_count: int = 0,
|
||||
created_by: str | None = "governance_dispatcher",
|
||||
) -> GovernanceRemediationDispatch:
|
||||
"""建立新的 pending dispatch row。
|
||||
|
||||
同一 event_id 同一時間只能有一筆活躍 dispatch。
|
||||
若違反 partial unique index (ux_grd_one_active_per_event),
|
||||
拋出 DispatchAlreadyActive。
|
||||
|
||||
Args:
|
||||
event_id: 關聯的 ai_governance_events.id
|
||||
event_type: 治理事件類型(governance_event_type enum value)
|
||||
executor_type: 執行器類型(如 playbook_executor / manual)
|
||||
playbook_id: 可選,關聯 playbooks.playbook_id
|
||||
incident_id: 可選,關聯 incidents.incident_id
|
||||
approval_id: 可選,關聯 approval_records.id
|
||||
decision_context: 決策上下文 dict(服務層用 DecisionContextV1 驗證後傳入)
|
||||
max_attempts: 最大重試次數(預設 3)
|
||||
attempt_count: 本 row 的嘗試計數(重試 INSERT 時帶入上筆 +1)
|
||||
created_by: 建立者標識
|
||||
|
||||
Returns:
|
||||
新建立的 GovernanceRemediationDispatch ORM 物件
|
||||
|
||||
Raises:
|
||||
DispatchAlreadyActive: 同 event_id 已有 pending/dispatched/executing row
|
||||
"""
|
||||
async with get_db_context() as db:
|
||||
row = GovernanceRemediationDispatch(
|
||||
id=generate_uuid(),
|
||||
governance_event_id=event_id,
|
||||
event_type=event_type,
|
||||
dispatch_status="pending",
|
||||
playbook_id=playbook_id,
|
||||
incident_id=incident_id,
|
||||
approval_id=approval_id,
|
||||
decision_context=decision_context or {},
|
||||
executor_type=executor_type,
|
||||
attempt_count=attempt_count,
|
||||
max_attempts=max_attempts,
|
||||
dispatched_at=taipei_now(),
|
||||
created_by=created_by,
|
||||
)
|
||||
db.add(row)
|
||||
try:
|
||||
await db.flush()
|
||||
await db.refresh(row)
|
||||
except IntegrityError as exc:
|
||||
await db.rollback()
|
||||
if "ux_grd_one_active_per_event" in str(exc.orig):
|
||||
raise DispatchAlreadyActive(
|
||||
f"event_id={event_id} 已有活躍 dispatch(pending/dispatched/executing)"
|
||||
) from exc
|
||||
raise
|
||||
|
||||
logger.info(
|
||||
"dispatch_created",
|
||||
dispatch_id=row.id,
|
||||
event_id=event_id,
|
||||
event_type=event_type,
|
||||
executor_type=executor_type,
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
async def get_active_for_event(
|
||||
event_id: str,
|
||||
) -> GovernanceRemediationDispatch | None:
|
||||
"""取得指定事件當前活躍的 dispatch(pending / dispatched / executing)。
|
||||
|
||||
Args:
|
||||
event_id: ai_governance_events.id
|
||||
|
||||
Returns:
|
||||
活躍 dispatch row,若無則 None
|
||||
"""
|
||||
async with get_db_context() as db:
|
||||
result = await db.execute(
|
||||
select(GovernanceRemediationDispatch)
|
||||
.where(GovernanceRemediationDispatch.governance_event_id == event_id)
|
||||
.where(GovernanceRemediationDispatch.dispatch_status.in_(list(ACTIVE_STATUSES)))
|
||||
.order_by(GovernanceRemediationDispatch.dispatched_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def transition_status(
|
||||
dispatch_id: str,
|
||||
from_status: str,
|
||||
to_status: str,
|
||||
*,
|
||||
last_error: str | None = None,
|
||||
) -> GovernanceRemediationDispatch:
|
||||
"""執行狀態機轉換(驗證 from_status 合法後更新)。
|
||||
|
||||
注意:failed → pending 的重試路徑應使用 record_failure_and_retry(),
|
||||
不應直接呼叫本函數(重試需要 INSERT 新 row)。
|
||||
|
||||
Args:
|
||||
dispatch_id: governance_remediation_dispatch.id
|
||||
from_status: 預期的當前狀態(不符則拋 InvalidStatusTransition)
|
||||
to_status: 目標狀態
|
||||
last_error: 失敗時的錯誤訊息(僅 to_status=failed 時有意義)
|
||||
|
||||
Returns:
|
||||
更新後的 GovernanceRemediationDispatch ORM 物件
|
||||
|
||||
Raises:
|
||||
DispatchNotFound: 找不到 dispatch_id
|
||||
InvalidStatusTransition: 狀態轉換不合法或當前狀態與 from_status 不符
|
||||
"""
|
||||
# 驗證轉換合法性
|
||||
allowed = _VALID_TRANSITIONS.get(from_status, set())
|
||||
if to_status not in allowed:
|
||||
raise InvalidStatusTransition(
|
||||
f"不允許的狀態轉換: {from_status!r} → {to_status!r}。"
|
||||
f"from_status={from_status!r} 的合法後繼: {allowed}"
|
||||
)
|
||||
|
||||
async with get_db_context() as db:
|
||||
result = await db.execute(
|
||||
select(GovernanceRemediationDispatch)
|
||||
.where(GovernanceRemediationDispatch.id == dispatch_id)
|
||||
)
|
||||
row = result.scalar_one_or_none()
|
||||
|
||||
if row is None:
|
||||
raise DispatchNotFound(f"dispatch_id={dispatch_id!r} 不存在")
|
||||
|
||||
current = row.dispatch_status
|
||||
if current != from_status:
|
||||
raise InvalidStatusTransition(
|
||||
f"dispatch_id={dispatch_id!r} 當前狀態 {current!r} 與預期 {from_status!r} 不符"
|
||||
)
|
||||
|
||||
row.dispatch_status = to_status
|
||||
|
||||
if to_status == "executing":
|
||||
row.started_at = taipei_now()
|
||||
if to_status in TERMINAL_STATUSES or to_status == "failed":
|
||||
row.completed_at = taipei_now()
|
||||
if last_error is not None:
|
||||
row.last_error = last_error
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(row)
|
||||
|
||||
logger.info(
|
||||
"dispatch_status_transitioned",
|
||||
dispatch_id=dispatch_id,
|
||||
from_status=from_status,
|
||||
to_status=to_status,
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
async def record_failure_and_retry(
|
||||
dispatch_id: str,
|
||||
error: str,
|
||||
) -> GovernanceRemediationDispatch | None:
|
||||
"""記錄失敗並決定是否重試。
|
||||
|
||||
策略:
|
||||
1. 將舊 row 標記為 failed(completed_at 填入,last_error 填入)
|
||||
2. 若 attempt_count + 1 < max_attempts,INSERT 新 pending row(attempt_count+1)
|
||||
3. 若已達上限,返回 None(不再重試)
|
||||
|
||||
舊 row 永遠保留 failed(審計痕跡),不改 status。
|
||||
|
||||
Args:
|
||||
dispatch_id: 當前失敗的 dispatch row id
|
||||
error: 錯誤訊息
|
||||
|
||||
Returns:
|
||||
新建立的 pending retry row,若已達重試上限則 None
|
||||
|
||||
Raises:
|
||||
DispatchNotFound: 找不到 dispatch_id
|
||||
InvalidStatusTransition: 舊 row 狀態不是 executing 或 dispatched
|
||||
"""
|
||||
async with get_db_context() as db:
|
||||
result = await db.execute(
|
||||
select(GovernanceRemediationDispatch)
|
||||
.where(GovernanceRemediationDispatch.id == dispatch_id)
|
||||
)
|
||||
row = result.scalar_one_or_none()
|
||||
|
||||
if row is None:
|
||||
raise DispatchNotFound(f"dispatch_id={dispatch_id!r} 不存在")
|
||||
|
||||
if row.dispatch_status not in ("executing", "dispatched"):
|
||||
raise InvalidStatusTransition(
|
||||
f"record_failure_and_retry 只能對 executing/dispatched 狀態操作,"
|
||||
f"當前狀態: {row.dispatch_status!r}"
|
||||
)
|
||||
|
||||
# Step 1: 標記舊 row 為 failed(審計痕跡)
|
||||
row.dispatch_status = "failed"
|
||||
row.last_error = error
|
||||
row.completed_at = taipei_now()
|
||||
await db.flush()
|
||||
|
||||
next_attempt = row.attempt_count + 1
|
||||
if next_attempt >= row.max_attempts:
|
||||
# 已達上限,不再重試
|
||||
logger.warning(
|
||||
"dispatch_failure_max_attempts_reached",
|
||||
dispatch_id=dispatch_id,
|
||||
attempt_count=row.attempt_count,
|
||||
max_attempts=row.max_attempts,
|
||||
)
|
||||
return None
|
||||
|
||||
# Step 2: INSERT 新 pending row(保留 FK 關聯)
|
||||
new_row = GovernanceRemediationDispatch(
|
||||
id=generate_uuid(),
|
||||
governance_event_id=row.governance_event_id,
|
||||
event_type=row.event_type,
|
||||
dispatch_status="pending",
|
||||
playbook_id=row.playbook_id,
|
||||
incident_id=row.incident_id,
|
||||
approval_id=row.approval_id,
|
||||
decision_context=row.decision_context,
|
||||
executor_type=row.executor_type,
|
||||
attempt_count=next_attempt,
|
||||
max_attempts=row.max_attempts,
|
||||
dispatched_at=taipei_now(),
|
||||
created_by=row.created_by,
|
||||
)
|
||||
db.add(new_row)
|
||||
|
||||
try:
|
||||
await db.flush()
|
||||
await db.refresh(new_row)
|
||||
except IntegrityError as exc:
|
||||
await db.rollback()
|
||||
if "ux_grd_one_active_per_event" in str(exc.orig):
|
||||
raise DispatchAlreadyActive(
|
||||
f"retry INSERT 失敗:event_id={row.governance_event_id} 已有活躍 dispatch"
|
||||
) from exc
|
||||
raise
|
||||
|
||||
logger.info(
|
||||
"dispatch_retry_inserted",
|
||||
old_dispatch_id=dispatch_id,
|
||||
new_dispatch_id=new_row.id,
|
||||
attempt_count=next_attempt,
|
||||
)
|
||||
return new_row
|
||||
|
||||
|
||||
async def list_pending(
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> list[GovernanceRemediationDispatch]:
|
||||
"""列出所有 pending dispatch(按 dispatched_at DESC)。
|
||||
|
||||
用於 /governance Queue tab 顯示待處理隊列。
|
||||
|
||||
Args:
|
||||
limit: 每頁筆數(預設 50)
|
||||
offset: 分頁偏移
|
||||
|
||||
Returns:
|
||||
按 dispatched_at 倒序排列的 pending dispatch 列表
|
||||
"""
|
||||
async with get_db_context() as db:
|
||||
result = await db.execute(
|
||||
select(GovernanceRemediationDispatch)
|
||||
.where(GovernanceRemediationDispatch.dispatch_status == "pending")
|
||||
.order_by(GovernanceRemediationDispatch.dispatched_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def list_by_event(
|
||||
event_id: str,
|
||||
) -> list[GovernanceRemediationDispatch]:
|
||||
"""取得指定事件的所有 dispatch 記錄(含歷史失敗)。
|
||||
|
||||
用於 /governance Events tab 展開行顯示完整歷史。
|
||||
按 dispatched_at DESC 排序(最新的在前)。
|
||||
|
||||
Args:
|
||||
event_id: ai_governance_events.id
|
||||
|
||||
Returns:
|
||||
該事件的所有 dispatch rows,含歷史失敗(audit trail)
|
||||
"""
|
||||
async with get_db_context() as db:
|
||||
result = await db.execute(
|
||||
select(GovernanceRemediationDispatch)
|
||||
.where(GovernanceRemediationDispatch.governance_event_id == event_id)
|
||||
.order_by(GovernanceRemediationDispatch.dispatched_at.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Singleton(對齊 approval_repository.py 模式)
|
||||
# =============================================================================
|
||||
# 本模組以 module-level 函數提供介面,不使用 class 封裝。
|
||||
# 若需要 DI 注入,直接 import 函數即可。
|
||||
Reference in New Issue
Block a user