diff --git a/apps/api/src/services/governance_agent.py b/apps/api/src/services/governance_agent.py index aa87b6e7a..f3aa5658d 100644 --- a/apps/api/src/services/governance_agent.py +++ b/apps/api/src/services/governance_agent.py @@ -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 + 排程迴圈 # ============================================================================= diff --git a/apps/api/src/services/governance_dispatcher.py b/apps/api/src/services/governance_dispatcher.py index 41b54a575..acca3d8cf 100644 --- a/apps/api/src/services/governance_dispatcher.py +++ b/apps/api/src/services/governance_dispatcher.py @@ -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): diff --git a/apps/api/tests/test_governance_agent.py b/apps/api/tests/test_governance_agent.py index d0cb0219a..2d146e2c9 100644 --- a/apps/api/tests/test_governance_agent.py +++ b/apps/api/tests/test_governance_agent.py @@ -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 diff --git a/apps/api/tests/test_governance_dispatcher.py b/apps/api/tests/test_governance_dispatcher.py index ed26a6fea..3964793d3 100644 --- a/apps/api/tests/test_governance_dispatcher.py +++ b/apps/api/tests/test_governance_dispatcher.py @@ -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。""" diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index ec088387e..a1ab50b1e 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -10,6 +10,7 @@ - `governance_remediation_dispatch` read model 增加 lightweight context:`executor_type`、`decision_path`、`workflow_stage`、`workflow_steps`、`next_action`、`lead_agent`、`support_agents`、`human_owner`。 - `/api/v1/ai/governance/queue` 支援 `dispatch_status=all` 與 `event_type=knowledge_degradation` 過濾,讓 Work Items 可以查同一類治理派工的 pending / executing / succeeded / failed / skipped / cancelled 歷史。 - `GovernanceDispatcher` 對 `decision_path=skip` 的治理事件也會留下 terminal `skipped` dispatch trail;skip 仍不代表解決,只代表 AI 判斷目前不能自動派遣,前端可看見原因與人工接手點。 +- `GovernanceAgent` 在建立 `knowledge_degradation` event 時會同步建立 non-executing `hermes_kb_growth_healthcheck` pending dispatch;`GovernanceDispatcher` 看到既有 unresolved `run_kb_growth_healthcheck` 事件且沒有 active dispatch 時,也會補建 intake dispatch,避免告警已進 DB 但 Work Items 沒有工作項。 - `decision_context` 會從治理事件 payload 帶入 `run_kb_growth_healthcheck`、Hermes / OpenClaw / ElephantAlpha / KM owner 分工,以及 `detected → ai_analyzed → queued_kb_healthcheck → draft_km_updates → waiting_owner_review → km_writeback_after_approval → stale_ratio_recheck` 工作階段。 - `/awooop/work-items` 新增「KM 健康檢查派工」面板與 T88 工作項,直接顯示 total / active / review、dispatch status、stage、next action、lead/support/human owner 與 workflow chips。 @@ -21,7 +22,7 @@ node JSON parse zh-TW/en -> json ok pytest apps/api/tests/test_governance_dispatcher.py apps/api/tests/test_ai_governance_endpoints.py apps/api/tests/test_governance_agent.py -q - -> 62 passed + -> 64 passed ruff F/E9 selected files -> All checks passed pnpm --filter @awoooi/web typecheck @@ -36,6 +37,7 @@ git diff --check - 全專案 `next lint` 仍會被既有舊頁面攔住,包含 `src/app/[locale]/demo/page.tsx` 的 hook rule error 與多個 legacy i18n warning;T88 目標檔案的 targeted lint 已通過。 - T88 目前只建立「派工 / skipped / owner review」可追蹤鏈,不自動寫入 KM。真正 `draft_km_updates` / `km_writeback_after_approval` 仍需後續 Hermes KB growth worker 消費 dispatch row 後推進狀態。 +- 第一版 production smoke 抓到 9 筆 unresolved `knowledge_degradation` 事件但 dispatch total=0;本段已補 intake dispatch,下一次部署後 dispatcher 會對既有 unresolved 事件補建工作項。 **目前整體進度**: diff --git a/docs/superpowers/specs/2026-04-15-MASTER-ai-autonomous-flywheel-v2.md b/docs/superpowers/specs/2026-04-15-MASTER-ai-autonomous-flywheel-v2.md index 82841dbd1..d09dd238e 100644 --- a/docs/superpowers/specs/2026-04-15-MASTER-ai-autonomous-flywheel-v2.md +++ b/docs/superpowers/specs/2026-04-15-MASTER-ai-autonomous-flywheel-v2.md @@ -2296,6 +2296,7 @@ Phase 6 完成後 - 觸發:`knowledge_degradation` 告警已能說明 stale KM 與 Hermes / OpenClaw / ElephantAlpha ownership,但 `run_kb_growth_healthcheck` 仍缺 DB 派工狀態與前端可見階段,Operator 無法確認 AI 是否已判斷、為何跳過、是否等待 owner。 - 修正:`governance_remediation_dispatch` read model 顯示 lightweight decision context:`executor_type`、`decision_path`、`workflow_stage`、`workflow_steps`、`next_action`、`lead_agent`、`support_agents`、`human_owner`;`/ai/governance/queue` 支援 `dispatch_status=all` + `event_type=knowledge_degradation`。 - Dispatcher:`decision_path=skip` 不再只落在 log,會留下 terminal `skipped` dispatch trail;skip 不等於 resolved,只代表目前不能自動派遣,讓 AwoooP 可顯示人工接手點。 +- Intake:`GovernanceAgent` 建立 `knowledge_degradation` event 時同步建立 non-executing `hermes_kb_growth_healthcheck` pending dispatch;`GovernanceDispatcher` 也會對既有 unresolved `run_kb_growth_healthcheck` 事件補建 intake dispatch,避免告警進 DB 但 Work Items 沒有可追蹤工作項。 - KM workflow metadata:`run_kb_growth_healthcheck` 對齊 `detected → ai_analyzed → queued_kb_healthcheck → draft_km_updates → waiting_owner_review → km_writeback_after_approval → stale_ratio_recheck`。此鏈路明確標記 `writes_km_without_approval=false`,避免 AI 自動固化錯誤知識。 - UI:`/awooop/work-items` 新增 T88「KM 健康檢查派工」面板,顯示 dispatch status、stage、next action、Hermes 主責、OpenClaw / ElephantAlpha 支援與 KM owner 人工覆核。 - 邊界:T88 完成派工 / skipped / owner review 可追蹤性,不代表 Hermes KB growth worker 已能自動產生 KM 草稿,也不代表 stale ratio 已下降;後續需由 worker 消費 dispatch row 並推進 executing / succeeded / failed。