Files
awoooi/apps/api/tests/test_governance_dispatcher.py
Your Name e45b055e0e
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
feat(governance): AI 治理事件處理鏈四軌交付(C/D/B/A)
【十二人專家團隊全景掃描 + 並行四軌實施】

統帥質疑「有讓 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>
2026-05-03 12:42:40 +08:00

446 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# apps/api/tests/test_governance_dispatcher.py | 2026-05-03 @ Asia/Taipei
"""
Unit Tests — GovernanceDispatcher (Wave 2E)
覆蓋範圍:
1. high confidence (>= 0.85) → decision_path=auto_dispatch → status=pendingdispatch 建立)
2. mid confidence (0.65-0.85) → decision_path=pending_approval → dispatch 建立executor=manual
3. low confidence (< 0.65) → decision_path=skip → 不寫 dispatch返回 None
4. 重複事件get_active_for_event 有值 → 不重複 dispatch返回 None
5. LLM 失敗 fallbackfusion 拋 Exception → skip + log不寫 dispatch
6. _build_decision_context 完整三維欄位驗證
測試策略mock DB / adapter / repo不依賴真實 Postgres。
"""
from __future__ import annotations
from datetime import datetime, timezone, timedelta
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
# ─── 模擬 AiGovernanceEvent避免 DB 連線)────────────────────────────────
TAIPEI = timezone(timedelta(hours=8))
NOW = datetime(2026, 5, 3, 12, 0, tzinfo=TAIPEI)
def _make_governance_event(
event_id: str = "evt-001",
event_type: str = "trust_drift",
) -> MagicMock:
"""建立 mock AiGovernanceEvent。"""
evt = MagicMock()
evt.id = event_id
evt.event_type = event_type
evt.triggered_at = NOW
evt.resolved = False
evt.details = {
"status": "warning",
"impact": {"drifted_count": 3, "total_playbooks": 10},
"remediation": {"next_action": "review_playbooks"},
}
return evt
# ─── FusedDecision factory ──────────────────────────────────────────────────
def _make_fused_decision(
confidence: float = 0.9,
playbook_id: str | None = "pb-001",
) -> MagicMock:
"""建立 mock FusedDecision。"""
from src.services.decision_fusion_adapter import FusedDecision
if confidence >= 0.85:
path = "auto_dispatch"
elif confidence >= 0.65:
path = "pending_approval"
else:
path = "skip"
return FusedDecision(
confidence=confidence,
recommended_action="啟動 Playbook 信任度修復流程",
matched_playbook_id=playbook_id,
playbook_trust=0.7 if playbook_id else None,
llm_reasoning={"parsed_confidence": confidence, "parsed_action": "review"},
mcp_snapshot={"autonomy_rate": 0.82, "_meta": {"success_count": 2, "total_queries": 2}},
decision_path=path,
llm_score=confidence,
playbook_score=0.7 if playbook_id else 0.3,
mcp_score=0.8,
)
# =============================================================================
# Tests — dispatch_governance_event
# =============================================================================
class TestDispatchGovernanceEvent:
"""dispatch_governance_event 核心邏輯測試。"""
@pytest.mark.asyncio
async def test_high_confidence_creates_auto_dispatch(self):
"""confidence >= 0.85 → decision_path=auto_dispatch → dispatch 建立executor=playbook_executor。"""
event = _make_governance_event()
decision = _make_fused_decision(confidence=0.90)
mock_dispatch_row = MagicMock()
mock_dispatch_row.id = "dispatch-001"
with (
patch(
"src.services.governance_dispatcher.get_active_for_event",
new=AsyncMock(return_value=None),
),
patch(
"src.services.governance_dispatcher.get_decision_fusion_adapter",
) as mock_adapter_factory,
patch(
"src.services.governance_dispatcher.create_dispatch",
new=AsyncMock(return_value=mock_dispatch_row),
) as mock_create,
):
mock_adapter = MagicMock()
mock_adapter.fuse_decision = AsyncMock(return_value=decision)
mock_adapter_factory.return_value = mock_adapter
from src.services.governance_dispatcher import dispatch_governance_event
result = await dispatch_governance_event(event)
assert result == "dispatch-001"
mock_create.assert_awaited_once()
call_kwargs = mock_create.call_args
assert call_kwargs.kwargs["executor_type"] == "playbook_executor"
assert call_kwargs.kwargs["event_id"] == "evt-001"
assert call_kwargs.kwargs["event_type"] == "trust_drift"
@pytest.mark.asyncio
async def test_mid_confidence_creates_pending_approval(self):
"""0.65 <= confidence < 0.85 → decision_path=pending_approval → executor=manual。"""
event = _make_governance_event()
decision = _make_fused_decision(confidence=0.75)
mock_dispatch_row = MagicMock()
mock_dispatch_row.id = "dispatch-002"
with (
patch(
"src.services.governance_dispatcher.get_active_for_event",
new=AsyncMock(return_value=None),
),
patch(
"src.services.governance_dispatcher.get_decision_fusion_adapter",
) as mock_adapter_factory,
patch(
"src.services.governance_dispatcher.create_dispatch",
new=AsyncMock(return_value=mock_dispatch_row),
) as mock_create,
):
mock_adapter = MagicMock()
mock_adapter.fuse_decision = AsyncMock(return_value=decision)
mock_adapter_factory.return_value = mock_adapter
from src.services.governance_dispatcher import dispatch_governance_event
result = await dispatch_governance_event(event)
assert result == "dispatch-002"
call_kwargs = mock_create.call_args
assert call_kwargs.kwargs["executor_type"] == "manual"
@pytest.mark.asyncio
async def test_low_confidence_skips_dispatch(self):
"""confidence < 0.65 → decision_path=skip → 不寫 dispatch返回 None。"""
event = _make_governance_event()
decision = _make_fused_decision(confidence=0.40)
with (
patch(
"src.services.governance_dispatcher.get_active_for_event",
new=AsyncMock(return_value=None),
),
patch(
"src.services.governance_dispatcher.get_decision_fusion_adapter",
) as mock_adapter_factory,
patch(
"src.services.governance_dispatcher.create_dispatch",
new=AsyncMock(),
) as mock_create,
):
mock_adapter = MagicMock()
mock_adapter.fuse_decision = AsyncMock(return_value=decision)
mock_adapter_factory.return_value = mock_adapter
from src.services.governance_dispatcher import dispatch_governance_event
result = await dispatch_governance_event(event)
assert result is None
mock_create.assert_not_awaited()
@pytest.mark.asyncio
async def test_duplicate_event_does_not_dispatch(self):
"""同一事件已有 active dispatch → 返回 None不重複 dispatch。"""
event = _make_governance_event()
# 模擬已有活躍 dispatch
existing_dispatch = MagicMock()
existing_dispatch.id = "existing-dispatch-001"
existing_dispatch.dispatch_status = "pending"
with (
patch(
"src.services.governance_dispatcher.get_active_for_event",
new=AsyncMock(return_value=existing_dispatch),
),
patch(
"src.services.governance_dispatcher.create_dispatch",
new=AsyncMock(),
) as mock_create,
):
from src.services.governance_dispatcher import dispatch_governance_event
result = await dispatch_governance_event(event)
assert result is None
mock_create.assert_not_awaited()
@pytest.mark.asyncio
async def test_llm_failure_fallback_to_skip(self):
"""fusion adapter 拋 Exception → fallback skip不寫 dispatch返回 None。"""
event = _make_governance_event()
with (
patch(
"src.services.governance_dispatcher.get_active_for_event",
new=AsyncMock(return_value=None),
),
patch(
"src.services.governance_dispatcher.get_decision_fusion_adapter",
) as mock_adapter_factory,
patch(
"src.services.governance_dispatcher.create_dispatch",
new=AsyncMock(),
) as mock_create,
):
mock_adapter = MagicMock()
mock_adapter.fuse_decision = AsyncMock(
side_effect=RuntimeError("Ollama 連線失敗")
)
mock_adapter_factory.return_value = mock_adapter
from src.services.governance_dispatcher import dispatch_governance_event
result = await dispatch_governance_event(event)
assert result is None
mock_create.assert_not_awaited()
@pytest.mark.asyncio
async def test_dispatch_already_active_race_condition(self):
"""並行建立時 DispatchAlreadyActive → 靜默返回 None冪等"""
event = _make_governance_event()
decision = _make_fused_decision(confidence=0.90)
from src.repositories.governance_remediation_dispatch_repo import DispatchAlreadyActive
with (
patch(
"src.services.governance_dispatcher.get_active_for_event",
new=AsyncMock(return_value=None),
),
patch(
"src.services.governance_dispatcher.get_decision_fusion_adapter",
) as mock_adapter_factory,
patch(
"src.services.governance_dispatcher.create_dispatch",
new=AsyncMock(side_effect=DispatchAlreadyActive("race")),
),
):
mock_adapter = MagicMock()
mock_adapter.fuse_decision = AsyncMock(return_value=decision)
mock_adapter_factory.return_value = mock_adapter
from src.services.governance_dispatcher import dispatch_governance_event
result = await dispatch_governance_event(event)
assert result is None
# =============================================================================
# Tests — _build_decision_context
# =============================================================================
class TestBuildDecisionContext:
"""_build_decision_context 完整三維欄位驗證。"""
def test_decision_context_has_all_required_fields(self):
"""decision_context 必須包含完整三維輸入快照。"""
from src.services.governance_dispatcher import _build_decision_context
event = _make_governance_event()
decision = _make_fused_decision(confidence=0.90)
ctx = _build_decision_context(event, decision)
# 版本化
assert ctx["version"] == "v1"
# 觸發來源
assert ctx["trigger_source"] == "governance_dispatcher"
assert ctx["triggered_metric"] == "trust_drift"
# 三維分數均記錄
fusion = ctx["fusion_scores"]
assert "llm_score" in fusion
assert "playbook_score" in fusion
assert "mcp_score" in fusion
assert "confidence" in fusion
assert "weights" in fusion
# LLM 推理摘要
assert "llm_reasoning" in ctx
assert isinstance(ctx["llm_reasoning"], dict)
# MCP 快照
assert "mcp_snapshot" in ctx
assert isinstance(ctx["mcp_snapshot"], dict)
# 決策路徑
assert ctx["decision_path"] in ("auto_dispatch", "pending_approval", "skip")
def test_decision_context_no_hardcoded_event_type_rules(self):
"""decision_context 不得含 hardcode event_type → playbook 對應規則。"""
from src.services.governance_dispatcher import _build_decision_context
for event_type in ("trust_drift", "knowledge_degradation", "llm_hallucination"):
event = _make_governance_event(event_type=event_type)
decision = _make_fused_decision(confidence=0.90)
ctx = _build_decision_context(event, decision)
# 驗證 decision 基於信心度,不是 hardcode event_type 規則
assert ctx["decision_path"] == decision.decision_path
assert ctx["fusion_scores"]["confidence"] == round(decision.confidence, 4)
# =============================================================================
# Tests — DecisionFusionAdapter._build_decision_context (adapter 本身單元)
# =============================================================================
class TestDecisionFusionAdapterHelpers:
"""DecisionFusionAdapter 靜態輔助方法測試。"""
def test_summarize_details_with_impact(self):
"""summarize_details 應提取 impact / status 等關鍵欄位。"""
from src.services.decision_fusion_adapter import DecisionFusionAdapter
details = {
"status": "warning",
"impact": {"drifted_count": 3, "threshold": 0.2},
"remediation": {"next_action": "run_playbook"},
}
summary = DecisionFusionAdapter._summarize_details(details)
assert "status" in summary
assert "warning" in summary
assert len(summary) <= 300
def test_summarize_details_empty(self):
"""空 details → 返回預設提示,不崩潰。"""
from src.services.decision_fusion_adapter import DecisionFusionAdapter
summary = DecisionFusionAdapter._summarize_details({})
assert summary == "(無詳細資訊)"
def test_get_mcp_queries_returns_base_for_all_types(self):
"""所有 event_type 都應包含基礎指標查詢。"""
from src.services.decision_fusion_adapter import DecisionFusionAdapter
for event_type in ("trust_drift", "knowledge_degradation", "llm_hallucination",
"execution_blast_radius", "governance_slo_data_gap"):
queries = DecisionFusionAdapter._get_mcp_queries(event_type)
assert "autonomy_rate" in queries
assert "decision_accuracy" in queries
assert len(queries) >= 2
def test_extract_keywords_from_details(self):
"""_extract_keywords 應從 remediation/actionable/impact 中提取關鍵字。"""
from src.services.decision_fusion_adapter import DecisionFusionAdapter
details = {
"remediation": {
"next_action": "run_kb_growth_healthcheck",
"items": ["check_index", "rebuild_embeddings"],
},
}
keywords = DecisionFusionAdapter._extract_keywords(details)
assert len(keywords) <= 5
assert "run_kb_growth_healthcheck" in keywords
# =============================================================================
# Tests — run_governance_dispatcher_looploop 邏輯)
# =============================================================================
class TestRunGovernanceDispatcherLoop:
"""run_governance_dispatcher_loop 排程迴圈行為測試。"""
@pytest.mark.asyncio
async def test_loop_processes_events_and_sleeps(self):
"""loop 一次 cycle 應處理 events 並 sleep。"""
event = _make_governance_event()
call_count = 0
async def mock_sleep(seconds):
nonlocal call_count
call_count += 1
if call_count >= 2:
raise asyncio.CancelledError()
with (
patch(
"src.services.governance_dispatcher._poll_unresolved_events",
new=AsyncMock(return_value=[event]),
),
patch(
"src.services.governance_dispatcher.dispatch_governance_event",
new=AsyncMock(return_value="dispatch-new"),
),
patch("asyncio.sleep", side_effect=mock_sleep),
):
import asyncio
from src.services.governance_dispatcher import run_governance_dispatcher_loop
with pytest.raises(asyncio.CancelledError):
await run_governance_dispatcher_loop(interval_seconds=1)
# sleep 被呼叫至少一次
assert call_count >= 1
@pytest.mark.asyncio
async def test_loop_no_events_does_not_crash(self):
"""無事件時 loop 應平穩 sleep不報錯。"""
call_count = 0
async def mock_sleep(seconds):
nonlocal call_count
call_count += 1
if call_count >= 1:
raise asyncio.CancelledError()
with (
patch(
"src.services.governance_dispatcher._poll_unresolved_events",
new=AsyncMock(return_value=[]),
),
patch("asyncio.sleep", side_effect=mock_sleep),
):
import asyncio
from src.services.governance_dispatcher import run_governance_dispatcher_loop
with pytest.raises(asyncio.CancelledError):
await run_governance_dispatcher_loop(interval_seconds=1)
assert call_count >= 1