fix(governance): intake km healthcheck dispatches
This commit is contained in:
@@ -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. 寫 PG(ADR-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 + 排程迴圈
|
||||
# =============================================================================
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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。"""
|
||||
|
||||
Reference in New Issue
Block a user