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>
353 lines
16 KiB
Python
353 lines
16 KiB
Python
"""
|
||
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 only,run() 不再自動寫 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 呼叫失敗時不應 raise,violations 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 only,watchdog 不再直接呼叫 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(
|
||
"BUG:TrustDriftDetector.run() 不應呼叫 save_drift_event()。"
|
||
"整併後 run() 為 lib only,PG 寫入由 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_event(AST 靜態驗證)
|
||
|
||
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(
|
||
"BUG:TrustDriftDetector.run() 不應呼叫 save_drift_event()。"
|
||
"整併後 run() 為 lib only,PG 寫入由 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() 並回傳結果"
|