fix(governance): intake km healthcheck dispatches
All checks were successful
Code Review / ai-code-review (push) Successful in 22s
CD Pipeline / tests (push) Successful in 6m3s
CD Pipeline / build-and-deploy (push) Successful in 4m26s
CD Pipeline / post-deploy-checks (push) Successful in 1m21s

This commit is contained in:
Your Name
2026-05-19 21:27:25 +08:00
parent aee0a70021
commit b85ab70c45
6 changed files with 256 additions and 3 deletions

View File

@@ -29,8 +29,13 @@ from src.db.models import (
IncidentEvidence,
KnowledgeEntryRecord,
PlaybookRecord,
generate_uuid,
)
from src.models.knowledge import EntryStatus
from src.repositories.governance_remediation_dispatch_repo import (
DispatchAlreadyActive,
create_dispatch,
)
from src.utils.timezone import now_taipei
logger = structlog.get_logger(__name__)
@@ -713,19 +718,26 @@ class GovernanceAgent:
ADR-085 鐵律AI 學習成果不可存 Cache必須落地 PG
"""
# 1. 寫 PGADR-085 鐵律 — 失敗不阻斷主流程)
event_id = generate_uuid()
pg_written = False
try:
from sqlalchemy import insert as _sa_insert
async with get_db_context() as db:
await db.execute(
_sa_insert(AiGovernanceEvent).values(
id=event_id,
event_type=event_type,
details=payload,
)
)
await db.commit()
pg_written = True
except Exception as _pg_err:
logger.warning("governance_pg_write_failed", error=str(_pg_err))
if pg_written:
await _maybe_create_intake_dispatch(event_id, event_type, payload)
# 2. structlog保留既有行為
logger.warning("governance_alert", event_type=event_type, **payload)
@@ -745,6 +757,102 @@ class GovernanceAgent:
logger.warning("governance_telegram_alert_failed", error=str(e))
async def _maybe_create_intake_dispatch(
event_id: str,
event_type: str,
payload: dict[str, Any],
) -> None:
"""把可行動治理告警同步轉成 non-executing dispatch work item。
這層只建立可追蹤派工,不執行修復、不寫 KM、不發額外通知。
後續 Hermes KB growth worker / GovernanceDispatcher 可以接續推進狀態。
"""
if event_type != "knowledge_degradation":
return
try:
await create_dispatch(
event_id=event_id,
event_type=event_type,
executor_type="hermes_kb_growth_healthcheck",
decision_context=_build_knowledge_degradation_dispatch_context(event_id, payload),
max_attempts=1,
created_by="governance_agent_intake",
)
except DispatchAlreadyActive:
logger.info(
"governance_intake_dispatch_already_active",
event_id=event_id,
event_type=event_type,
)
except Exception as exc:
logger.warning(
"governance_intake_dispatch_failed",
event_id=event_id,
event_type=event_type,
error=str(exc),
)
def _build_knowledge_degradation_dispatch_context(
event_id: str,
payload: dict[str, Any],
) -> dict[str, Any]:
impact = payload.get("impact") if isinstance(payload.get("impact"), dict) else {}
remediation = payload.get("remediation") if isinstance(payload.get("remediation"), dict) else {}
ownership = payload.get("ownership") if isinstance(payload.get("ownership"), dict) else {}
next_action = remediation.get("next_action")
if not isinstance(next_action, str) or not next_action:
next_action = "run_kb_growth_healthcheck"
return {
"version": "v1",
"trigger_source": "governance_agent",
"triggered_metric": "knowledge_degradation",
"metric_value": impact.get("stale_ratio"),
"threshold": impact.get("threshold"),
"suggested_action": next_action,
"next_action": next_action,
"decision_path": "pending_owner_review",
"ownership": ownership,
"affected_resources": ["knowledge_entries"],
"workflow": {
"work_item_id": f"governance:knowledge_degradation:{event_id}",
"work_kind": "kb_growth_healthcheck",
"current_stage": "queued_kb_healthcheck",
"steps": [
"detected",
"ai_analyzed",
"queued_kb_healthcheck",
"draft_km_updates",
"waiting_owner_review",
"km_writeback_after_approval",
"stale_ratio_recheck",
],
"stage_by_dispatch_status": {
"pending": "queued_kb_healthcheck",
"dispatched": "queued_kb_healthcheck",
"executing": "draft_km_updates",
"succeeded": "stale_ratio_recheck",
"failed": "needs_manual_km_triage",
"skipped": "waiting_owner_review",
"cancelled": "cancelled",
},
"next_action": next_action,
"needs_human_review": True,
"writes_km_without_approval": False,
"impact": impact,
},
"extra": {
"event_id": event_id,
"stale_count": impact.get("stale_count"),
"total_count": impact.get("total_count"),
"stale_days": impact.get("stale_days"),
"ownership": ownership,
},
}
# =============================================================================
# Singleton + 排程迴圈
# =============================================================================

View File

@@ -157,6 +157,11 @@ async def dispatch_governance_event(event: AiGovernanceEvent) -> str | None:
)
return None
# knowledge_degradation 的 run_kb_growth_healthcheck 是治理工作項 intake
# 不是自動修復執行;先落 pending dispatch讓既有 unresolved 事件也能在 AwoooP 追蹤。
if _is_kb_growth_healthcheck_event(event):
return await _record_kb_growth_healthcheck_dispatch(event)
# Step 2: 決策融合三維LLM × Playbook × MCP
adapter = get_decision_fusion_adapter()
try:
@@ -259,6 +264,49 @@ async def dispatch_governance_event(event: AiGovernanceEvent) -> str | None:
return dispatch_row.id
async def _record_kb_growth_healthcheck_dispatch(event: AiGovernanceEvent) -> str | None:
"""將 knowledge_degradation intake 成 Hermes KB healthcheck work item."""
try:
from src.services.governance_agent import (
_build_knowledge_degradation_dispatch_context,
)
dispatch_row = await create_dispatch(
event_id=event.id,
event_type=event.event_type,
executor_type="hermes_kb_growth_healthcheck",
decision_context=_build_knowledge_degradation_dispatch_context(
event.id,
event.details if isinstance(event.details, dict) else {},
),
max_attempts=1,
created_by="governance_dispatcher_intake",
)
except DispatchAlreadyActive:
logger.info(
"governance_kb_healthcheck_dispatch_race_condition",
event_id=event.id,
event_type=event.event_type,
)
return None
except Exception as exc:
logger.warning(
"governance_kb_healthcheck_dispatch_failed",
event_id=event.id,
event_type=event.event_type,
error=str(exc),
)
return None
logger.info(
"governance_kb_healthcheck_dispatched",
dispatch_id=dispatch_row.id,
event_id=event.id,
event_type=event.event_type,
)
return dispatch_row.id
async def _record_skipped_dispatch(event: AiGovernanceEvent, decision: FusedDecision) -> None:
"""留下 skipped 派遣 trail讓治理事件的 AI 判斷不再只存在 log 裡。
@@ -365,6 +413,16 @@ def _build_decision_context(
}
def _is_kb_growth_healthcheck_event(event: AiGovernanceEvent) -> bool:
if event.event_type != "knowledge_degradation":
return False
details = event.details if isinstance(event.details, dict) else {}
remediation = details.get("remediation")
if isinstance(remediation, dict) and remediation.get("next_action") == "run_kb_growth_healthcheck":
return True
return details.get("next_action") == "run_kb_growth_healthcheck"
def _extract_event_next_action(details: dict[str, Any], fallback: str) -> str:
remediation = details.get("remediation")
if isinstance(remediation, dict):

View File

@@ -235,14 +235,21 @@ class TestCheckKnowledgeDegradation:
total_mock.scalar.return_value = 10
stale_mock = MagicMock()
stale_mock.scalar.return_value = 3
insert_mock = MagicMock()
mock_db.execute = AsyncMock(side_effect=[total_mock, stale_mock])
mock_db.execute = AsyncMock(side_effect=[total_mock, stale_mock, insert_mock])
alerter = AsyncMock()
alerter.alert_governance = AsyncMock()
agent = _make_agent(alerter=alerter)
with patch("src.services.governance_agent.get_db_context") as mock_ctx:
with (
patch("src.services.governance_agent.get_db_context") as mock_ctx,
patch(
"src.services.governance_agent.create_dispatch",
new=AsyncMock(),
) as mock_create_dispatch,
):
mock_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
mock_ctx.return_value.__aexit__ = AsyncMock(return_value=False)
@@ -256,9 +263,49 @@ class TestCheckKnowledgeDegradation:
assert "OpenClaw" in payload["ownership"]["support_agents"][0]
assert "ElephantAlpha" in payload["ownership"]["support_agents"][1]
assert payload["ownership"]["human_owner"] == "KM owner / SRE owner"
mock_create_dispatch.assert_awaited_once()
dispatch_kwargs = mock_create_dispatch.call_args.kwargs
assert dispatch_kwargs["event_type"] == "knowledge_degradation"
assert dispatch_kwargs["executor_type"] == "hermes_kb_growth_healthcheck"
assert dispatch_kwargs["created_by"] == "governance_agent_intake"
assert dispatch_kwargs["decision_context"]["next_action"] == "run_kb_growth_healthcheck"
assert dispatch_kwargs["decision_context"]["ownership"]["lead_agent"] == "Hermes"
assert dispatch_kwargs["decision_context"]["workflow"]["writes_km_without_approval"] is False
assert result["stale"] == 3
assert result["ratio"] == 0.3
def test_knowledge_degradation_dispatch_context(self):
"""intake dispatch context 必須能被 Work Items 直接讀出 owner / stage / next_action."""
from src.services.governance_agent import _build_knowledge_degradation_dispatch_context
ctx = _build_knowledge_degradation_dispatch_context(
"evt-km-001",
{
"impact": {
"stale_count": 1451,
"total_count": 1870,
"stale_ratio": 0.776,
"threshold": 0.2,
"stale_days": 7,
},
"remediation": {"next_action": "run_kb_growth_healthcheck"},
"ownership": {
"lead_agent": "Hermes",
"support_agents": ["OpenClaw", "ElephantAlpha"],
"human_owner": "KM owner / SRE owner",
},
},
)
assert ctx["trigger_source"] == "governance_agent"
assert ctx["next_action"] == "run_kb_growth_healthcheck"
assert ctx["decision_path"] == "pending_owner_review"
assert ctx["ownership"]["lead_agent"] == "Hermes"
assert ctx["workflow"]["current_stage"] == "queued_kb_healthcheck"
assert ctx["workflow"]["stage_by_dispatch_status"]["executing"] == "draft_km_updates"
assert ctx["workflow"]["writes_km_without_approval"] is False
assert ctx["extra"]["event_id"] == "evt-km-001"
# =============================================================================
# check_llm_hallucination

View File

@@ -216,6 +216,43 @@ class TestDispatchGovernanceEvent:
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_llm_failure_fallback_to_skip(self):
"""fusion adapter 拋 Exception → fallback skip不寫 dispatch返回 None。"""