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,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 / skippedterminal禁止任何轉換
失敗重試INSERT 新 rowattempt_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 / skippedterminal無合法後繼
}
TERMINAL_STATUSES: frozenset[str] = frozenset({"succeeded", "cancelled", "skipped"})
ACTIVE_STATUSES: frozenset[str] = frozenset({"pending", "dispatched", "executing"})
# =============================================================================
# 自訂例外
# =============================================================================
class DispatchAlreadyActive(Exception):
"""同一 governance_event_id 已有活躍 dispatchpartial 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} 已有活躍 dispatchpending/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:
"""取得指定事件當前活躍的 dispatchpending / 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 標記為 failedcompleted_at 填入last_error 填入)
2. 若 attempt_count + 1 < max_attemptsINSERT 新 pending rowattempt_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 函數即可。