# 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=pending(dispatch 建立) 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 失敗 fallback:fusion 拋 Exception → skip + log,不寫 dispatch 6. _build_decision_context 完整三維欄位驗證 測試策略:mock DB / adapter / repo,不依賴真實 Postgres。 """ from __future__ import annotations from datetime import datetime, timezone, timedelta 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 → 留下 skipped dispatch trail,返回 None。""" event = _make_governance_event() decision = _make_fused_decision(confidence=0.40) mock_dispatch_row = MagicMock() mock_dispatch_row.id = "dispatch-skipped-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, patch( "src.services.governance_dispatcher.transition_status", new=AsyncMock(), ) as mock_transition, ): 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_awaited_once() mock_transition.assert_awaited_once_with( "dispatch-skipped-001", "pending", "skipped", ) @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_knowledge_degradation_creates_kb_healthcheck_intake(self): """knowledge_degradation 應先建立 non-executing Hermes KB healthcheck work item。""" event = _make_governance_event(event_type="knowledge_degradation") event.details = { "impact": {"stale_count": 1451, "total_count": 1870, "stale_ratio": 0.776}, "remediation": {"next_action": "run_kb_growth_healthcheck"}, "ownership": {"lead_agent": "Hermes"}, } mock_dispatch_row = MagicMock() mock_dispatch_row.id = "dispatch-kb-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, ): from src.services.governance_dispatcher import dispatch_governance_event result = await dispatch_governance_event(event) assert result == "dispatch-kb-001" mock_adapter_factory.assert_not_called() mock_create.assert_awaited_once() dispatch_kwargs = mock_create.call_args.kwargs assert dispatch_kwargs["executor_type"] == "hermes_kb_growth_healthcheck" assert dispatch_kwargs["created_by"] == "governance_dispatcher_intake" assert dispatch_kwargs["decision_context"]["next_action"] == "run_kb_growth_healthcheck" assert dispatch_kwargs["decision_context"]["workflow"]["current_stage"] == "queued_kb_healthcheck" @pytest.mark.asyncio async def test_knowledge_degradation_intake_ignores_legacy_skip_cooldown(self): """舊 skip cooldown 不可阻止 KM healthcheck 補建 work item。""" event = _make_governance_event(event_type="knowledge_degradation") event.details = { "remediation": {"next_action": "run_kb_growth_healthcheck"}, "ownership": {"lead_agent": "Hermes"}, } mock_dispatch_row = MagicMock() mock_dispatch_row.id = "dispatch-kb-cooldown" with ( patch( "src.services.governance_dispatcher.get_active_for_event", new=AsyncMock(return_value=None), ), patch( "src.services.governance_dispatcher._is_skip_cooldown", new=AsyncMock(return_value=True), ) as mock_skip_cooldown, patch( "src.services.governance_dispatcher.create_dispatch", new=AsyncMock(return_value=mock_dispatch_row), ) as mock_create, ): from src.services.governance_dispatcher import dispatch_governance_event result = await dispatch_governance_event(event) assert result == "dispatch-kb-cooldown" mock_skip_cooldown.assert_not_awaited() mock_create.assert_awaited_once() @pytest.mark.asyncio async def test_knowledge_degradation_does_not_requeue_after_review_draft(self): """同一 event 已有 Hermes review draft 後,不可反覆補 pending dispatch。""" event = _make_governance_event(event_type="knowledge_degradation") event.details = { "remediation": {"next_action": "run_kb_growth_healthcheck"}, "ownership": {"lead_agent": "Hermes"}, } succeeded_dispatch = MagicMock() succeeded_dispatch.executor_type = "hermes_kb_growth_healthcheck" succeeded_dispatch.dispatch_status = "succeeded" succeeded_dispatch.decision_context = { "workflow": {"current_stage": "waiting_owner_review"}, "worker_result": {"status": "draft_created"}, } with ( patch( "src.services.governance_dispatcher.get_active_for_event", new=AsyncMock(return_value=None), ), patch( "src.services.governance_dispatcher.list_by_event", new=AsyncMock(return_value=[succeeded_dispatch]), ) as mock_history, 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_history.assert_awaited_once_with("evt-001") 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) def test_knowledge_degradation_context_exposes_kb_work_item(self): """knowledge_degradation 的 next_action / owner / workflow 必須進 dispatch context.""" from src.services.governance_dispatcher import _build_decision_context event = _make_governance_event(event_type="knowledge_degradation") event.details = { "impact": { "stale_count": 1450, "total_count": 1867, "stale_ratio": 0.777, "threshold": 0.2, }, "remediation": { "next_action": "run_kb_growth_healthcheck", }, "ownership": { "lead_agent": "Hermes", "support_agents": [ "OpenClaw:提供告警分類、規則匹配與 PlayBook 脈絡摘要,不直接批量改寫 KM。", "ElephantAlpha:read-only 稽核高影響 KM 草稿與風險,不執行寫入或通知。", ], "human_owner": "KM owner / SRE owner", }, } decision = _make_fused_decision(confidence=0.75) ctx = _build_decision_context(event, decision) assert ctx["next_action"] == "run_kb_growth_healthcheck" assert ctx["ownership"]["lead_agent"] == "Hermes" assert ctx["workflow"]["work_kind"] == "kb_growth_healthcheck" assert ctx["workflow"]["current_stage"] == "queued_kb_healthcheck" assert ctx["workflow"]["stage_by_dispatch_status"]["skipped"] == "waiting_owner_review" assert ctx["workflow"]["writes_km_without_approval"] is False # ============================================================================= # 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_loop(loop 邏輯) # ============================================================================= class TestRunGovernanceDispatcherLoop: """run_governance_dispatcher_loop 排程迴圈行為測試。""" def test_poll_unresolved_events_excludes_active_dispatch_rows(self): """poll SQL 必須跳過已有 active dispatch 的事件,避免 backlog 餓死。""" import inspect from src.services import governance_dispatcher source = inspect.getsource(governance_dispatcher._poll_unresolved_events) assert "GovernanceRemediationDispatch" in source assert "ACTIVE_STATUSES" in source assert ".where(~active_dispatch_exists)" in source @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