Files
awoooi/apps/api/tests/test_trust_drift_watchdog.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

353 lines
16 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.
"""
Trust Drift Watchdog 整合測試
==============================
P3.1-T2 by Claude 2026-04-27 — Tier-2 三服務感知強化
2026-05-02 ogt + Claude Sonnet 4.6(亞太): 整併雙寫路徑
W-6 改呼叫 governance_agent.check_trust_drift()(唯一 source-of-truth
TrustDriftDetector 降為 lib onlyrun() 不再自動寫 PG
驗證:
1. ai_slo_watchdog_job W-6 呼叫 governance_agent.check_trust_drift()
2. drift 偵測到時 violation 被加入 violations list
3. 無 drift 時不加入 violations list
4. get_trust_drift_detector() singleton 可正常取得
5. TrustDriftDetector.run() 方法存在且可呼叫lib only不寫 PG
注意:不依賴真實 DB — 全 mock 測試
"""
from __future__ import annotations
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
# ─────────────────────────────────────────────────────────────────────────────
# Stubs
# ─────────────────────────────────────────────────────────────────────────────
def _make_dist(drift_detected: bool, drift_type: str | None = None, high_ratio: float = 0.0, low_ratio: float = 0.0, total: int = 20) -> object:
"""建立 TrustDistribution stub"""
dist = MagicMock()
dist.drift_detected = drift_detected
dist.drift_type = drift_type
dist.high_ratio = high_ratio
dist.low_ratio = low_ratio
dist.total = total
return dist
# ─────────────────────────────────────────────────────────────────────────────
# Test: get_trust_drift_detector singleton
# ─────────────────────────────────────────────────────────────────────────────
class TestGetTrustDriftDetectorSingleton:
def test_singleton_returns_same_instance(self):
"""同一 process 內兩次呼叫回傳相同 instance"""
import importlib
import src.services.trust_drift_detector as m
# 重設 singleton
original = m._detector
m._detector = None
try:
a = m.get_trust_drift_detector()
b = m.get_trust_drift_detector()
assert a is b
finally:
m._detector = original
def test_singleton_has_run_method(self):
"""TrustDriftDetector 必須有 run() 方法"""
from src.services.trust_drift_detector import get_trust_drift_detector
detector = get_trust_drift_detector()
assert hasattr(detector, "run")
assert callable(detector.run)
def test_singleton_has_detect_method(self):
"""TrustDriftDetector 必須有 detect() 方法"""
from src.services.trust_drift_detector import get_trust_drift_detector
detector = get_trust_drift_detector()
assert hasattr(detector, "detect")
# ─────────────────────────────────────────────────────────────────────────────
# Test: W-6 watchdog 呼叫 trust_drift_detector.run()
# ─────────────────────────────────────────────────────────────────────────────
class TestWatchdogW6TrustDrift:
"""W-6 改呼叫 governance_agent.check_trust_drift() — 2026-05-02 整併雙寫路徑"""
@pytest.mark.asyncio
async def test_w6_drift_detected_adds_violation(self):
"""drifted > 0 時 W-6 應在 violations list 加入字串"""
mock_agent = AsyncMock()
mock_agent.check_trust_drift = AsyncMock(return_value={
"checked": 25,
"drifted": 3,
"auto_deprecated": 1,
"kept": 2,
})
violations: list[str] = []
with patch(
"src.services.governance_agent.get_governance_agent",
return_value=mock_agent,
):
from src.services.governance_agent import get_governance_agent
trust_result = await get_governance_agent().check_trust_drift()
if trust_result.get("drifted", 0) > 0:
drifted = trust_result["drifted"]
auto_deprecated = trust_result.get("auto_deprecated", 0)
kept = trust_result.get("kept", 0)
violations.append(
f"Trust Drift 偵測到 {drifted} 個 Playbook 信任度低落"
f"auto-deprecated: {auto_deprecated},待人工審核: {kept}"
)
assert len(violations) == 1
assert "Trust Drift" in violations[0]
assert "3 個 Playbook 信任度低落" in violations[0]
assert "auto-deprecated: 1" in violations[0]
@pytest.mark.asyncio
async def test_w6_no_drift_no_violation(self):
"""drifted == 0 時 W-6 不應加入 violation"""
mock_agent = AsyncMock()
mock_agent.check_trust_drift = AsyncMock(return_value={
"checked": 15,
"drifted": 0,
"auto_deprecated": 0,
"kept": 0,
})
violations: list[str] = []
with patch(
"src.services.governance_agent.get_governance_agent",
return_value=mock_agent,
):
from src.services.governance_agent import get_governance_agent
trust_result = await get_governance_agent().check_trust_drift()
if trust_result.get("drifted", 0) > 0:
violations.append("Trust Drift violation")
assert len(violations) == 0
@pytest.mark.asyncio
async def test_w6_exception_isolated(self):
"""W-6 呼叫失敗時不應 raiseviolations list 保持空"""
mock_agent = MagicMock()
mock_agent.check_trust_drift = AsyncMock(side_effect=Exception("DB connection failed"))
violations: list[str] = []
try:
with patch(
"src.services.governance_agent.get_governance_agent",
return_value=mock_agent,
):
from src.services.governance_agent import get_governance_agent
await get_governance_agent().check_trust_drift()
except Exception:
pass # 外層 watchdog catch此處模擬 try/except 隔離
assert len(violations) == 0
@pytest.mark.asyncio
async def test_w6_auto_deprecated_reflected_in_violation(self):
"""auto_deprecated 數量應正確反映在 violation 訊息中"""
mock_agent = AsyncMock()
mock_agent.check_trust_drift = AsyncMock(return_value={
"checked": 30,
"drifted": 5,
"auto_deprecated": 4,
"kept": 1,
})
violations: list[str] = []
with patch(
"src.services.governance_agent.get_governance_agent",
return_value=mock_agent,
):
from src.services.governance_agent import get_governance_agent
trust_result = await get_governance_agent().check_trust_drift()
if trust_result.get("drifted", 0) > 0:
drifted = trust_result["drifted"]
auto_deprecated = trust_result.get("auto_deprecated", 0)
kept = trust_result.get("kept", 0)
violations.append(
f"Trust Drift 偵測到 {drifted} 個 Playbook 信任度低落"
f"auto-deprecated: {auto_deprecated},待人工審核: {kept}"
)
assert "auto-deprecated: 4" in violations[0]
assert "待人工審核: 1" in violations[0]
# ─────────────────────────────────────────────────────────────────────────────
# Test: watchdog W-6 已在 ai_slo_watchdog_job._check_once() 中存在
# ─────────────────────────────────────────────────────────────────────────────
class TestWatchdogW6Wiring:
def test_w6_code_calls_governance_agent_check_trust_drift(self):
"""確認 ai_slo_watchdog_job.py W-6 改呼叫 governance_agent.check_trust_drift()
2026-05-02 ogt + Claude Sonnet 4.6(亞太): 整併雙寫路徑
原先驗證 trust_drift_detector 被呼叫,整併後改為驗證 governance_agent 被呼叫。
detector 降為 lib onlywatchdog 不再直接呼叫 detector。
"""
import inspect
from src.jobs import ai_slo_watchdog_job
source = inspect.getsource(ai_slo_watchdog_job)
assert "governance_agent" in source, "W-6 應改為呼叫 governance_agent"
assert "check_trust_drift" in source, "W-6 應呼叫 governance_agent.check_trust_drift()"
# 確認舊路徑已移除
assert "get_trust_drift_detector" not in source, (
"W-6 不應再直接呼叫 get_trust_drift_detector()(雙寫路徑已整併)"
)
def test_watchdog_loop_imported_in_watchdog_module(self):
"""run_ai_slo_watchdog_loop 函式必須可正常 import"""
from src.jobs.ai_slo_watchdog_job import run_ai_slo_watchdog_loop
assert callable(run_ai_slo_watchdog_loop)
def test_detector_run_does_not_call_save_drift_event(self):
"""TrustDriftDetector.run() 整併後不應自動呼叫 save_drift_event()
2026-05-02 ogt + Claude Sonnet 4.6(亞太): 驗收標準 — lib only
AST 分析run() 的 body 中不應出現 save_drift_event 呼叫。
原實作run() 會 if dist.drift_detected: await self.save_drift_event(dist)
整併後run() 只回傳 detect() 的結果,不寫 PG。
"""
import ast
from pathlib import Path
src_path = (
Path(__file__).resolve().parents[1]
/ "src" / "services" / "trust_drift_detector.py"
)
tree = ast.parse(src_path.read_text())
run_func = None
for node in ast.walk(tree):
if isinstance(node, ast.AsyncFunctionDef) and node.name == "run":
run_func = node
break
assert run_func is not None, "找不到 TrustDriftDetector.run()"
for sub in ast.walk(run_func):
if (
isinstance(sub, ast.Call)
and isinstance(sub.func, ast.Attribute)
and sub.func.attr == "save_drift_event"
):
raise AssertionError(
"BUGTrustDriftDetector.run() 不應呼叫 save_drift_event()。"
"整併後 run() 為 lib onlyPG 寫入由 governance_agent 統一負責。"
)
# ─────────────────────────────────────────────────────────────────────────────
# Test: 同一 drift 場景只觸發一次 PG 寫入(驗收標準 #4
# ─────────────────────────────────────────────────────────────────────────────
class TestSinglePgWritePerDriftScenario:
"""驗收標準 #4同一 drift 場景只觸發一次 PG 寫入
2026-05-02 ogt + Claude Sonnet 4.6(亞太): 整併雙寫路徑驗收
整併前watchdog W-6 呼叫 detector.run() 寫 PG + governance_agent 每 1h 再寫 PG
→ 同一場景最多 2 筆 event_type=trust_drift 到 ai_governance_events
整併後:唯一寫入點 = governance_agent._alert("trust_drift", ...)
→ 同一場景只有 1 次 PG 寫入
"""
@pytest.mark.asyncio
async def test_watchdog_w6_delegates_to_governance_agent_no_direct_pg_write(self):
"""W-6 只透過 governance_agent.check_trust_drift(),不直接呼叫 AiGovernanceEvent insert
驗證watchdog W-6 觸發時,底層的 PG 寫入由 governance_agent 負責,
TrustDriftDetector.save_drift_event() 不被呼叫。
"""
from unittest.mock import AsyncMock, patch, MagicMock
trust_result = {
"checked": 20,
"drifted": 3,
"auto_deprecated": 1,
"kept": 2,
}
mock_agent = AsyncMock()
mock_agent.check_trust_drift = AsyncMock(return_value=trust_result)
save_drift_event_calls: list = []
async def _mock_save(dist):
save_drift_event_calls.append(dist)
violations: list[str] = []
with patch("src.services.governance_agent.get_governance_agent", return_value=mock_agent):
from src.services.governance_agent import get_governance_agent
result = await get_governance_agent().check_trust_drift()
if result.get("drifted", 0) > 0:
drifted = result["drifted"]
auto_deprecated = result.get("auto_deprecated", 0)
kept = result.get("kept", 0)
violations.append(
f"Trust Drift 偵測到 {drifted} 個 Playbook 信任度低落"
f"auto-deprecated: {auto_deprecated},待人工審核: {kept}"
)
# W-6 透過 governance_agent — violations 有 1 筆
assert len(violations) == 1
# save_drift_event 未被直接呼叫PG 寫入由 governance_agent._alert 統一負責)
assert len(save_drift_event_calls) == 0, (
f"save_drift_event 被呼叫了 {len(save_drift_event_calls)} 次,"
"應為 0整併後 W-6 不直接寫 PG"
)
def test_detector_run_is_lib_only_no_pg_import_path(self):
"""TrustDriftDetector.run() 整併後不呼叫 save_drift_eventAST 靜態驗證)
2026-05-02 ogt + Claude Sonnet 4.6(亞太): 使用 AST 驗證實際呼叫,避免 docstring 誤判。
"""
import ast
from pathlib import Path
src_path = (
Path(__file__).resolve().parents[1]
/ "src" / "services" / "trust_drift_detector.py"
)
tree = ast.parse(src_path.read_text())
run_func = None
for node in ast.walk(tree):
if isinstance(node, ast.AsyncFunctionDef) and node.name == "run":
run_func = node
break
assert run_func is not None, "找不到 TrustDriftDetector.run()"
# AST 驗證run() 內不應有 save_drift_event 呼叫docstring 不算)
for sub in ast.walk(run_func):
if (
isinstance(sub, ast.Call)
and isinstance(sub.func, ast.Attribute)
and sub.func.attr == "save_drift_event"
):
raise AssertionError(
"BUGTrustDriftDetector.run() 不應呼叫 save_drift_event()。"
"整併後 run() 為 lib onlyPG 寫入由 governance_agent 統一負責。"
)
# 確認 run() 有呼叫 detect()(核心統計仍保留)
found_detect = any(
isinstance(sub, ast.Call)
and isinstance(sub.func, ast.Attribute)
and sub.func.attr == "detect"
for sub in ast.walk(run_func)
)
assert found_detect, "run() 應呼叫 detect() 並回傳結果"