diff --git a/apps/api/src/api/v1/ai_governance.py b/apps/api/src/api/v1/ai_governance.py index cfa8fe481..3218d27b3 100644 --- a/apps/api/src/api/v1/ai_governance.py +++ b/apps/api/src/api/v1/ai_governance.py @@ -93,8 +93,9 @@ async def get_governance_events( async def get_governance_queue( dispatch_status: Annotated[ str, - Query(pattern="^(pending|dispatched|succeeded|failed)$"), + Query(pattern="^(all|pending|dispatched|executing|succeeded|failed|skipped|cancelled)$"), ] = "pending", + event_type: Annotated[list[str] | None, Query(alias="event_type")] = None, page: Annotated[int, Query(ge=1)] = 1, size: Annotated[int, Query(ge=10, le=100)] = 20, ) -> GovernanceQueueResponse: @@ -104,17 +105,20 @@ async def get_governance_queue( governance_remediation_dispatch 表由 Track D 建立,尚未完成時 本 endpoint 回傳 { table_pending: true, items: [], total: 0 },不拋 500。 - - dispatch_status: pending(default)/ dispatched / succeeded / failed + - dispatch_status: pending(default)/ dispatched / executing / succeeded / failed / skipped / cancelled / all + - event_type: 多值過濾(可重複傳) - page / size: 分頁 """ logger.debug( "governance_queue_request", dispatch_status=dispatch_status, + event_type=event_type, page=page, size=size, ) return await query_governance_queue( dispatch_status=dispatch_status, + event_types=event_type, page=page, size=size, ) diff --git a/apps/api/src/models/governance.py b/apps/api/src/models/governance.py index 07abaaef3..0a51ece39 100644 --- a/apps/api/src/models/governance.py +++ b/apps/api/src/models/governance.py @@ -87,13 +87,22 @@ class DispatchItem(BaseModel): governance_event_id: str event_type: str dispatch_status: str + executor_type: str | None = None proposed_action: str = Field(description="≤120 字動作摘要") playbook_id: str | None = None playbook_trust: float | None = Field(default=None, ge=0.0, le=1.0) created_at: datetime dispatched_at: datetime | None = None + started_at: datetime | None = None completed_at: datetime | None = None operator_note: str | None = None + decision_path: str | None = None + workflow_stage: str | None = None + workflow_steps: list[str] = Field(default_factory=list) + next_action: str | None = None + lead_agent: str | None = None + support_agents: list[str] = Field(default_factory=list) + human_owner: str | None = None class GovernanceQueueResponse(BaseModel): diff --git a/apps/api/src/services/governance_dispatcher.py b/apps/api/src/services/governance_dispatcher.py index aa552b3c1..41b54a575 100644 --- a/apps/api/src/services/governance_dispatcher.py +++ b/apps/api/src/services/governance_dispatcher.py @@ -36,6 +36,7 @@ from src.repositories.governance_remediation_dispatch_repo import ( DispatchAlreadyActive, create_dispatch, get_active_for_event, + transition_status, ) from src.services.decision_fusion_adapter import FusedDecision, get_decision_fusion_adapter @@ -199,6 +200,8 @@ async def dispatch_governance_event(event: AiGovernanceEvent) -> str | None: stale=event_age_sec > _STALE_EVENT_SEC, ) + await _record_skipped_dispatch(event, decision) + if event_age_sec > _STALE_EVENT_SEC: await _mark_event_resolved(event_id, reason=f"skip_stale_{int(event_age_sec)}s") @@ -209,10 +212,8 @@ async def dispatch_governance_event(event: AiGovernanceEvent) -> str | None: # pending_approval → pending(等人工審核) if decision.decision_path == "auto_dispatch": executor_type = "playbook_executor" - initial_status_note = "auto_dispatch" else: # pending_approval executor_type = "manual" - initial_status_note = "pending_approval" # Step 5: 建構 decision_context JSONB(完整三維快照) decision_context = _build_decision_context(event, decision) @@ -258,6 +259,38 @@ async def dispatch_governance_event(event: AiGovernanceEvent) -> str | None: return dispatch_row.id +async def _record_skipped_dispatch(event: AiGovernanceEvent, decision: FusedDecision) -> None: + """留下 skipped 派遣 trail,讓治理事件的 AI 判斷不再只存在 log 裡。 + + skip 不代表「事情解決」,而是 DecisionFusion 判斷目前不能自動派遣。 + 這筆 terminal dispatch row 讓 AwoooP / Work Items 可顯示: + AI 已判斷、為何沒有自動處理、下一步需要誰接手。 + """ + try: + row = await create_dispatch( + event_id=event.id, + event_type=event.event_type, + executor_type="manual", + playbook_id=decision.matched_playbook_id, + decision_context=_build_decision_context(event, decision), + created_by="governance_dispatcher_skip", + ) + await transition_status(row.id, "pending", "skipped") + except DispatchAlreadyActive: + logger.info( + "governance_skip_dispatch_race_condition", + event_id=event.id, + event_type=event.event_type, + ) + except Exception as exc: + logger.warning( + "governance_skip_dispatch_record_failed", + event_id=event.id, + event_type=event.event_type, + error=str(exc), + ) + + async def _poll_unresolved_events() -> list[AiGovernanceEvent]: """查詢 unresolved 且 event_type 在 dispatchable 範圍內的治理事件。 @@ -296,6 +329,11 @@ def _build_decision_context( decision_path: 決策分支 confidence: 最終融合信心度 """ + details = event.details if isinstance(event.details, dict) else {} + next_action = _extract_event_next_action(details, decision.recommended_action) + workflow = _build_event_workflow(event, details, decision, next_action) + ownership = details.get("ownership") if isinstance(details.get("ownership"), dict) else {} + return { "version": "v1", "trigger_source": "governance_dispatcher", @@ -303,6 +341,9 @@ def _build_decision_context( "metric_value": decision.confidence, "threshold": 0.85, # TODO: 移到 settings "suggested_action": decision.recommended_action, + "next_action": next_action, + "workflow": workflow, + "ownership": ownership, "fusion_scores": { "llm_score": round(decision.llm_score, 4), "playbook_score": round(decision.playbook_score, 4), @@ -318,11 +359,86 @@ def _build_decision_context( "affected_resources": [event.event_type], "extra": { "event_id": event.id, - "event_details_keys": list((event.details or {}).keys()), + "event_details_keys": list(details.keys()), + "ownership": ownership, }, } +def _extract_event_next_action(details: dict[str, Any], fallback: str) -> str: + remediation = details.get("remediation") + if isinstance(remediation, dict): + next_action = remediation.get("next_action") + if isinstance(next_action, str) and next_action: + return next_action[:160] + next_action = details.get("next_action") + if isinstance(next_action, str) and next_action: + return next_action[:160] + return fallback[:160] + + +def _build_event_workflow( + event: AiGovernanceEvent, + details: dict[str, Any], + decision: FusedDecision, + next_action: str, +) -> dict[str, Any]: + """把治理事件轉成 AwoooP 可讀 workflow metadata。 + + 這不是自動修復決策;最終是否執行仍由 DecisionFusion 與 dispatch state 決定。 + """ + base_steps = ["detected", "ai_analyzed"] + if next_action == "run_kb_growth_healthcheck": + steps = [ + *base_steps, + "queued_kb_healthcheck", + "draft_km_updates", + "waiting_owner_review", + "km_writeback_after_approval", + "stale_ratio_recheck", + ] + stage_by_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", + } + current_stage = ( + "waiting_owner_review" + if decision.decision_path == "skip" + else "queued_kb_healthcheck" + ) + work_kind = "kb_growth_healthcheck" + else: + steps = [*base_steps, "dispatch_decision", "operator_review"] + stage_by_status = { + "pending": "queued_for_review", + "dispatched": "dispatched", + "executing": "executing", + "succeeded": "completed", + "failed": "failed", + "skipped": "skipped", + "cancelled": "cancelled", + } + current_stage = stage_by_status.get("pending", "queued_for_review") + work_kind = "governance_remediation" + + return { + "work_item_id": f"governance:{event.event_type}:{event.id}", + "work_kind": work_kind, + "current_stage": current_stage, + "steps": steps, + "stage_by_dispatch_status": stage_by_status, + "next_action": next_action, + "needs_human_review": decision.decision_path != "auto_dispatch", + "writes_km_without_approval": False, + "impact": details.get("impact") if isinstance(details.get("impact"), dict) else {}, + } + + # ============================================================================= # 排程迴圈(仿 run_governance_loop 模式) # ============================================================================= diff --git a/apps/api/src/services/governance_query_service.py b/apps/api/src/services/governance_query_service.py index ae05e2c6a..320081fcb 100644 --- a/apps/api/src/services/governance_query_service.py +++ b/apps/api/src/services/governance_query_service.py @@ -20,9 +20,10 @@ Graceful fallback 規則: from __future__ import annotations from datetime import datetime, timedelta, timezone +from typing import Any import structlog -from sqlalchemy import func, select, text +from sqlalchemy import bindparam, func, select, text from sqlalchemy.exc import ProgrammingError from src.db.base import get_db_context @@ -209,6 +210,7 @@ async def query_governance_events( async def query_governance_queue( *, dispatch_status: str = "pending", + event_types: list[str] | None = None, page: int = 1, size: int = 20, ) -> GovernanceQueueResponse: @@ -223,6 +225,7 @@ async def query_governance_queue( try: return await _query_dispatch_table( dispatch_status=dispatch_status, + event_types=event_types, page=page, size=size, ) @@ -255,43 +258,67 @@ async def query_governance_queue( async def _query_dispatch_table( *, dispatch_status: str, + event_types: list[str] | None, page: int, size: int, ) -> GovernanceQueueResponse: """實際查詢 governance_remediation_dispatch 表(不含 graceful fallback).""" # 動態 import:Track D 完成前 ORM class 可能不存在 # 使用 raw SQL 降低 ORM 模型缺失的耦合風險 - sql = text(""" + status_filter = ( + "TRUE" + if dispatch_status == "all" + else "d.dispatch_status = CAST(:dispatch_status AS governance_dispatch_status)" + ) + event_type_filter = ( + "TRUE" + if not event_types + else "e.event_type::text IN :event_types" + ) + params: dict[str, Any] = {} + if dispatch_status != "all": + params["dispatch_status"] = dispatch_status + if event_types: + params["event_types"] = event_types + + sql = text(f""" SELECT d.id, d.governance_event_id, e.event_type, d.dispatch_status, + d.executor_type, d.decision_context, d.playbook_id, d.dispatched_at AS created_at, d.dispatched_at, + d.started_at, d.completed_at, NULL::text AS operator_note FROM governance_remediation_dispatch d JOIN ai_governance_events e ON e.id = d.governance_event_id - WHERE d.dispatch_status = CAST(:dispatch_status AS governance_dispatch_status) + WHERE {status_filter} + AND {event_type_filter} ORDER BY d.dispatched_at DESC """) - count_sql = text(""" + count_sql = text(f""" SELECT count(*) AS cnt - FROM governance_remediation_dispatch - WHERE dispatch_status = CAST(:dispatch_status AS governance_dispatch_status) + FROM governance_remediation_dispatch d + JOIN ai_governance_events e ON e.id = d.governance_event_id + WHERE {status_filter} + AND {event_type_filter} """) + if event_types: + sql = sql.bindparams(bindparam("event_types", expanding=True)) + count_sql = count_sql.bindparams(bindparam("event_types", expanding=True)) + async with get_db_context() as db: - count_row = await db.execute(count_sql, {"dispatch_status": dispatch_status}) + count_row = await db.execute(count_sql, params) total = int(count_row.scalar_one_or_none() or 0) - rows = await db.execute( - sql.bindparams(dispatch_status=dispatch_status), - ) + rows = await db.execute(sql, params) all_rows = rows.fetchall() offset = (page - 1) * size @@ -315,13 +342,22 @@ async def _query_dispatch_table( governance_event_id=str(row.governance_event_id), event_type=str(row.event_type), dispatch_status=str(row.dispatch_status), + executor_type=str(row.executor_type) if row.executor_type else None, proposed_action=proposed_action, playbook_id=str(row.playbook_id) if row.playbook_id else None, playbook_trust=playbook_trust, created_at=row.created_at, dispatched_at=row.dispatched_at, + started_at=row.started_at, completed_at=row.completed_at, operator_note=row.operator_note, + decision_path=_extract_decision_path(decision_ctx), + workflow_stage=_extract_workflow_stage(decision_ctx, str(row.dispatch_status)), + workflow_steps=_extract_workflow_steps(decision_ctx), + next_action=_extract_next_action(decision_ctx), + lead_agent=_extract_lead_agent(decision_ctx), + support_agents=_extract_support_agents(decision_ctx), + human_owner=_extract_human_owner(decision_ctx), )) return GovernanceQueueResponse( @@ -339,13 +375,99 @@ def _extract_proposed_action(decision_ctx: dict) -> str: Track D 完成後此函式可改為從真實欄位讀取。 """ - for key in ("proposed_action", "action", "suggestion", "description", "summary"): + for key in ( + "proposed_action", + "suggested_action", + "next_action", + "action", + "suggestion", + "description", + "summary", + ): val = decision_ctx.get(key) if isinstance(val, str) and val: return val[:120] return "(待補充)" +def _extract_decision_path(decision_ctx: dict) -> str | None: + val = decision_ctx.get("decision_path") + return val[:80] if isinstance(val, str) and val else None + + +def _extract_next_action(decision_ctx: dict) -> str | None: + for key in ("next_action", "suggested_action", "proposed_action"): + val = decision_ctx.get(key) + if isinstance(val, str) and val: + return val[:120] + workflow = decision_ctx.get("workflow") + if isinstance(workflow, dict): + val = workflow.get("next_action") + if isinstance(val, str) and val: + return val[:120] + return None + + +def _extract_workflow_stage(decision_ctx: dict, dispatch_status: str) -> str | None: + workflow = decision_ctx.get("workflow") + if isinstance(workflow, dict): + stages = workflow.get("stage_by_dispatch_status") + if isinstance(stages, dict): + stage = stages.get(dispatch_status) + if isinstance(stage, str) and stage: + return stage[:120] + current = workflow.get("current_stage") + if isinstance(current, str) and current: + return current[:120] + + return { + "pending": "queued_for_review", + "dispatched": "dispatched", + "executing": "executing", + "succeeded": "completed", + "failed": "failed", + "skipped": "skipped", + "cancelled": "cancelled", + }.get(dispatch_status) + + +def _extract_workflow_steps(decision_ctx: dict) -> list[str]: + workflow = decision_ctx.get("workflow") + if not isinstance(workflow, dict): + return [] + steps = workflow.get("steps") + if not isinstance(steps, list): + return [] + return [str(step)[:120] for step in steps if step is not None][:8] + + +def _extract_ownership(decision_ctx: dict) -> dict: + ownership = decision_ctx.get("ownership") + if isinstance(ownership, dict): + return ownership + extra = decision_ctx.get("extra") + if isinstance(extra, dict) and isinstance(extra.get("ownership"), dict): + return extra["ownership"] + return {} + + +def _extract_lead_agent(decision_ctx: dict) -> str | None: + val = _extract_ownership(decision_ctx).get("lead_agent") + return val[:80] if isinstance(val, str) and val else None + + +def _extract_support_agents(decision_ctx: dict) -> list[str]: + raw = _extract_ownership(decision_ctx).get("support_agents") + if not isinstance(raw, list): + return [] + return [str(item)[:160] for item in raw if item is not None][:6] + + +def _extract_human_owner(decision_ctx: dict) -> str | None: + val = _extract_ownership(decision_ctx).get("human_owner") + return val[:120] if isinstance(val, str) and val else None + + # ============================================================================= # Endpoint 3: summary # ============================================================================= diff --git a/apps/api/tests/test_ai_governance_endpoints.py b/apps/api/tests/test_ai_governance_endpoints.py index fab2558c4..33a8e7c93 100644 --- a/apps/api/tests/test_ai_governance_endpoints.py +++ b/apps/api/tests/test_ai_governance_endpoints.py @@ -279,6 +279,28 @@ class TestQueueEndpoint: r = client.get("/api/v1/ai/governance/queue?dispatch_status=unknown") assert r.status_code == 422 + def test_all_status_and_event_type_filter_passed(self, client): + """Work Items 需要 all + event_type 查 KM healthcheck 全狀態鏈。""" + normal = GovernanceQueueResponse( + items=[], total=0, page=1, size=20, table_pending=False, + ) + captured: dict = {} + + async def mock_query(**kwargs): + captured.update(kwargs) + return normal + + with patch("src.api.v1.ai_governance.query_governance_queue", new=mock_query): + r = client.get( + "/api/v1/ai/governance/queue?dispatch_status=all" + "&event_type=knowledge_degradation&size=20" + ) + + assert r.status_code == 200 + assert captured["dispatch_status"] == "all" + assert captured["event_types"] == ["knowledge_degradation"] + assert captured["size"] == 20 + def test_queue_query_uses_production_dispatch_schema(self): """queue 查詢必須對齊 migration schema:使用 dispatched_at,不讀不存在的 created_at/operator_note.""" import inspect @@ -289,6 +311,7 @@ class TestQueueEndpoint: assert "ORDER BY d.dispatched_at DESC" in source assert "NULL::text AS operator_note" in source assert "CAST(:dispatch_status AS governance_dispatch_status)" in source + assert "e.event_type::text IN :event_types" in source assert "d.created_at" not in source assert "d.operator_note" not in source diff --git a/apps/api/tests/test_governance_dispatcher.py b/apps/api/tests/test_governance_dispatcher.py index ffc75b02f..ed26a6fea 100644 --- a/apps/api/tests/test_governance_dispatcher.py +++ b/apps/api/tests/test_governance_dispatcher.py @@ -16,7 +16,6 @@ Unit Tests — GovernanceDispatcher (Wave 2E) from __future__ import annotations from datetime import datetime, timezone, timedelta -from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -153,9 +152,11 @@ class TestDispatchGovernanceEvent: @pytest.mark.asyncio async def test_low_confidence_skips_dispatch(self): - """confidence < 0.65 → decision_path=skip → 不寫 dispatch,返回 None。""" + """confidence < 0.65 → decision_path=skip → 留下 skipped dispatch trail,返回 None。""" event = _make_governance_event() decision = _make_fused_decision(confidence=0.40) + mock_dispatch_row = MagicMock() + mock_dispatch_row.id = "dispatch-skipped-001" with ( patch( @@ -167,8 +168,12 @@ class TestDispatchGovernanceEvent: ) as mock_adapter_factory, patch( "src.services.governance_dispatcher.create_dispatch", - new=AsyncMock(), + new=AsyncMock(return_value=mock_dispatch_row), ) as mock_create, + patch( + "src.services.governance_dispatcher.transition_status", + new=AsyncMock(), + ) as mock_transition, ): mock_adapter = MagicMock() mock_adapter.fuse_decision = AsyncMock(return_value=decision) @@ -178,7 +183,12 @@ class TestDispatchGovernanceEvent: result = await dispatch_governance_event(event) assert result is None - mock_create.assert_not_awaited() + mock_create.assert_awaited_once() + mock_transition.assert_awaited_once_with( + "dispatch-skipped-001", + "pending", + "skipped", + ) @pytest.mark.asyncio async def test_duplicate_event_does_not_dispatch(self): @@ -322,6 +332,41 @@ class TestBuildDecisionContext: assert ctx["decision_path"] == decision.decision_path assert ctx["fusion_scores"]["confidence"] == round(decision.confidence, 4) + def test_knowledge_degradation_context_exposes_kb_work_item(self): + """knowledge_degradation 的 next_action / owner / workflow 必須進 dispatch context.""" + from src.services.governance_dispatcher import _build_decision_context + + event = _make_governance_event(event_type="knowledge_degradation") + event.details = { + "impact": { + "stale_count": 1450, + "total_count": 1867, + "stale_ratio": 0.777, + "threshold": 0.2, + }, + "remediation": { + "next_action": "run_kb_growth_healthcheck", + }, + "ownership": { + "lead_agent": "Hermes", + "support_agents": [ + "OpenClaw:提供告警分類、規則匹配與 PlayBook 脈絡摘要,不直接批量改寫 KM。", + "ElephantAlpha:read-only 稽核高影響 KM 草稿與風險,不執行寫入或通知。", + ], + "human_owner": "KM owner / SRE owner", + }, + } + decision = _make_fused_decision(confidence=0.75) + + ctx = _build_decision_context(event, decision) + + assert ctx["next_action"] == "run_kb_growth_healthcheck" + assert ctx["ownership"]["lead_agent"] == "Hermes" + assert ctx["workflow"]["work_kind"] == "kb_growth_healthcheck" + assert ctx["workflow"]["current_stage"] == "queued_kb_healthcheck" + assert ctx["workflow"]["stage_by_dispatch_status"]["skipped"] == "waiting_owner_review" + assert ctx["workflow"]["writes_km_without_approval"] is False + # ============================================================================= # Tests — DecisionFusionAdapter._build_decision_context (adapter 本身單元) diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index d2056bd80..3bbca1993 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -1809,6 +1809,9 @@ "governanceDispatch": { "title": "Governance alert dispatch and dedupe" }, + "knowledgeHealthcheck": { + "title": "KM healthcheck dispatch state" + }, "frontendConsole": { "title": "AwoooP Operator Console productization" }, @@ -1828,6 +1831,7 @@ "telegramCallbacks": "Detail and history buttons cannot depend only on Redis TTL or stale snapshots", "ciSecretHygiene": "Workflows must not mount secrets in step env / action inputs; historical logs still need rotation and retention governance", "governanceDispatch": "Governance alerts must enter dispatch and expose skipped / pending / repaired", + "knowledgeHealthcheck": "knowledge_degradation must show Hermes / OpenClaw / ElephantAlpha ownership, current stage, and owner review point", "frontendConsole": "Completed and in-progress work must be trackable from the frontend", "mcpGateway": "MCP usage must show agent, tool, scope, and blocked reason", "timelineContract": "Incident, Approval, Evidence, KM, and Timeline must not contradict each other" @@ -1853,6 +1857,10 @@ "governance": "Unresolved governance alerts: {unresolved}; pending dispatch: {queued}", "governanceUnavailable": "Governance events API is not responding; pending dispatch: {queued}", "governanceQueueMissing": "Governance dispatch table is not ready; unresolved governance alerts: {unresolved}", + "knowledgeHealthcheck": "KM healthcheck dispatches: {total}; current stage: {stage}", + "knowledgeOwner": "Lead: {lead}; human review: {human}", + "knowledgeNext": "Next action: {action}", + "knowledgeEmpty": "No recent knowledge_degradation dispatch trail", "frontendConsole": "This page now reads production APIs instead of a static list", "mcpReady": "MCP Gateway gate is not currently a top gap", "mcpMissing": "Quality summary still reports an MCP Gateway observation gap", @@ -1879,6 +1887,49 @@ "evaluatedUnknown": "Evaluated --", "gateFailuresUnknown": "Gaps --" }, + "knowledgeGovernance": { + "title": "KM Healthcheck Dispatch", + "subtitle": "Tracks knowledge_degradation from detection, Hermes lookup, draft updates, owner review, and stale-ratio recheck", + "total": "Total {count}", + "active": "Active {count}", + "review": "Review {count}", + "unavailable": "The governance queue API has not responded, so KM healthcheck dispatch state cannot be claimed.", + "tablePending": "governance_remediation_dispatch is not ready, so no KM healthcheck dispatch rows are trackable yet.", + "empty": "No knowledge_degradation dispatch record is currently present; the next Telegram alert should produce a dispatch trail.", + "stage": "Stage: {stage}", + "next": "Next: {action}", + "lead": "Lead: {agent}", + "human": "Human review: {owner}", + "support": "Support: {agents}", + "statuses": { + "pending": "Pending", + "dispatched": "Dispatched", + "executing": "Executing", + "succeeded": "Completed", + "failed": "Failed", + "skipped": "Skipped", + "cancelled": "Cancelled", + "unknown": "Unknown" + }, + "stages": { + "detected": "Detected", + "ai_analyzed": "AI analyzed", + "queued_kb_healthcheck": "Queued for KM healthcheck", + "draft_km_updates": "Drafting KM updates", + "waiting_owner_review": "Waiting owner review", + "km_writeback_after_approval": "KM writeback after approval", + "stale_ratio_recheck": "Stale-ratio recheck", + "needs_manual_km_triage": "Manual KM triage needed", + "cancelled": "Cancelled", + "queued_for_review": "Queued for governance review", + "dispatched": "Dispatched", + "executing": "Executing", + "completed": "Completed", + "failed": "Failed", + "skipped": "Skipped", + "unknown": "Unknown stage" + } + }, "driftFingerprint": { "title": "Config Drift Fingerprint State", "subtitle": "Collapses hourly drift reports into one state chain with PR, zero diff, P0 dedupe, and human handoff evidence", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 69582579b..dddfb2cf9 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -1810,6 +1810,9 @@ "governanceDispatch": { "title": "治理告警 dispatch 與去重" }, + "knowledgeHealthcheck": { + "title": "KM 健康檢查派工狀態" + }, "frontendConsole": { "title": "AwoooP Operator Console 產品化" }, @@ -1829,6 +1832,7 @@ "telegramCallbacks": "按下詳情與歷史不能再只依賴 Redis TTL 或舊快照", "ciSecretHygiene": "workflow 不可再把 secrets 掛在 step env / action input;歷史 log 需另做輪換與保留期治理", "governanceDispatch": "治理告警必須進 dispatch,並標示 skipped / pending / repaired", + "knowledgeHealthcheck": "knowledge_degradation 必須顯示 Hermes / OpenClaw / ElephantAlpha 分工、目前階段與 owner 審核點", "frontendConsole": "已完成與推進中的工作必須能從前端直接追蹤", "mcpGateway": "MCP 使用必須看得到 agent、tool、scope 與 blocked 原因", "timelineContract": "Incident、Approval、Evidence、KM、Timeline 不得互相矛盾" @@ -1854,6 +1858,10 @@ "governance": "未解治理告警:{unresolved};pending dispatch:{queued}", "governanceUnavailable": "治理事件 API 目前無法回應;pending dispatch:{queued}", "governanceQueueMissing": "治理 dispatch 表尚未就緒;未解治理告警:{unresolved}", + "knowledgeHealthcheck": "KM healthcheck 派工:{total};目前階段:{stage}", + "knowledgeOwner": "主責:{lead};人工覆核:{human}", + "knowledgeNext": "下一步:{action}", + "knowledgeEmpty": "近期沒有 knowledge_degradation dispatch trail", "frontendConsole": "本頁已改讀 production API,而非靜態清單", "mcpReady": "MCP Gateway gate 目前未列為主要缺口", "mcpMissing": "品質總覽仍指出 MCP Gateway 觀測缺口", @@ -1880,6 +1888,49 @@ "evaluatedUnknown": "已評估 --", "gateFailuresUnknown": "缺口 --" }, + "knowledgeGovernance": { + "title": "KM 健康檢查派工", + "subtitle": "追蹤 knowledge_degradation 從偵測、Hermes 反查、草稿、owner 審核到 stale ratio 回測的狀態", + "total": "總數 {count}", + "active": "執行中 {count}", + "review": "需審核 {count}", + "unavailable": "governance queue API 尚未回應,不能判定 KM healthcheck 是否已派工。", + "tablePending": "governance_remediation_dispatch 表尚未就緒,KM healthcheck 尚無可追蹤派工列。", + "empty": "目前沒有 knowledge_degradation 派工紀錄;若 Telegram 又告警,下一輪應產生 dispatch trail。", + "stage": "階段:{stage}", + "next": "下一步:{action}", + "lead": "主責:{agent}", + "human": "人工覆核:{owner}", + "support": "支援:{agents}", + "statuses": { + "pending": "等待處理", + "dispatched": "已派遣", + "executing": "執行中", + "succeeded": "已完成", + "failed": "失敗", + "skipped": "已跳過", + "cancelled": "已取消", + "unknown": "未知" + }, + "stages": { + "detected": "已偵測", + "ai_analyzed": "AI 已分析", + "queued_kb_healthcheck": "已排入 KM healthcheck", + "draft_km_updates": "產生 KM 更新草稿", + "waiting_owner_review": "等待 owner 審核", + "km_writeback_after_approval": "審核後寫回 KM", + "stale_ratio_recheck": "回測 stale ratio", + "needs_manual_km_triage": "需要人工整理 KM", + "cancelled": "已取消", + "queued_for_review": "等待治理審核", + "dispatched": "已派遣", + "executing": "執行中", + "completed": "已完成", + "failed": "失敗", + "skipped": "已跳過", + "unknown": "未知階段" + } + }, "driftFingerprint": { "title": "Config Drift fingerprint 狀態", "subtitle": "把每小時 drift report 收斂成同一狀態鏈,顯示 PR、零 diff、P0 去重與人工交接", diff --git a/apps/web/src/app/[locale]/awooop/work-items/page.tsx b/apps/web/src/app/[locale]/awooop/work-items/page.tsx index cfcca891f..bfe80ce71 100644 --- a/apps/web/src/app/[locale]/awooop/work-items/page.tsx +++ b/apps/web/src/app/[locale]/awooop/work-items/page.tsx @@ -50,9 +50,32 @@ type GovernanceEventsResponse = { items?: Array<{ id: string; event_type: string; resolved: boolean }>; }; +type GovernanceQueueItem = { + id: string; + governance_event_id: string; + event_type: string; + dispatch_status: string; + executor_type?: string | null; + proposed_action?: string | null; + playbook_id?: string | null; + playbook_trust?: number | null; + created_at?: string | null; + dispatched_at?: string | null; + started_at?: string | null; + completed_at?: string | null; + decision_path?: string | null; + workflow_stage?: string | null; + workflow_steps?: string[]; + next_action?: string | null; + lead_agent?: string | null; + support_agents?: string[]; + human_owner?: string | null; +}; + type GovernanceQueueResponse = { total: number; table_pending?: boolean; + items?: GovernanceQueueItem[]; }; type RecentEventsResponse = { @@ -277,6 +300,7 @@ type Telemetry = { quality: AutomationQualitySummary | null; governanceEvents: GovernanceEventsResponse | null; governanceQueue: GovernanceQueueResponse | null; + governanceKnowledgeQueue: GovernanceQueueResponse | null; channelEvents: RecentEventsResponse | null; eventRecurrence: RecurrenceResponse | null; slo: SloResponse | null; @@ -543,6 +567,59 @@ function driftStatusForWorkItem(state: DriftFingerprintState | null): WorkStatus return "blocked"; } +function governanceKmDispatchStatusKey(status?: string | null) { + if ( + status === "pending" || + status === "dispatched" || + status === "executing" || + status === "succeeded" || + status === "failed" || + status === "skipped" || + status === "cancelled" + ) { + return status; + } + return "unknown"; +} + +function governanceKmStageKey(stage?: string | null) { + if ( + stage === "queued_kb_healthcheck" || + stage === "detected" || + stage === "ai_analyzed" || + stage === "draft_km_updates" || + stage === "waiting_owner_review" || + stage === "km_writeback_after_approval" || + stage === "stale_ratio_recheck" || + stage === "needs_manual_km_triage" || + stage === "cancelled" || + stage === "queued_for_review" || + stage === "dispatched" || + stage === "executing" || + stage === "completed" || + stage === "failed" || + stage === "skipped" + ) { + return stage; + } + return "unknown"; +} + +function governanceKmStatusForWorkItem(queue: GovernanceQueueResponse | null): WorkStatus { + if (queue === null || queue.table_pending) return "blocked"; + const items = queue.items ?? []; + if (items.some((item) => ["pending", "dispatched", "executing"].includes(item.dispatch_status))) { + return "in_progress"; + } + if (items.some((item) => item.dispatch_status === "skipped" || item.dispatch_status === "failed")) { + return "blocked"; + } + if (items.some((item) => item.dispatch_status === "succeeded")) { + return "live"; + } + return "watching"; +} + function buildWorkItems( telemetry: Telemetry, t: ReturnType @@ -552,6 +629,8 @@ function buildWorkItems( const recentChannelEvents = telemetry.channelEvents?.total ?? 0; const governanceUnresolved = telemetry.governanceEvents?.total ?? 0; const governanceQueuePending = telemetry.governanceQueue?.total ?? 0; + const knowledgeDispatches = telemetry.governanceKnowledgeQueue?.items ?? []; + const latestKnowledgeDispatch = knowledgeDispatches[0] ?? null; const remediationQueue = telemetry.slo?.adr100?.verification_coverage?.remediation_queue; const remediationTotal = remediationQueue?.total ?? 0; const remediationReadyForAi = remediationQueue?.ready_for_ai ?? 0; @@ -751,6 +830,32 @@ function buildWorkItems( }), href: "/governance", }, + { + id: "knowledgeHealthcheck", + phase: "T88", + status: governanceKmStatusForWorkItem(telemetry.governanceKnowledgeQueue), + surfaceKey: "workItems", + source: "/api/v1/ai/governance/queue?event_type=knowledge_degradation", + gateKey: "knowledgeHealthcheck", + evidence: t("evidence.knowledgeHealthcheck", { + total: telemetry.governanceKnowledgeQueue?.total ?? 0, + stage: latestKnowledgeDispatch + ? t(`knowledgeGovernance.stages.${governanceKmStageKey(latestKnowledgeDispatch.workflow_stage)}` as never) + : "--", + }), + evidenceDetails: latestKnowledgeDispatch + ? [ + t("evidence.knowledgeOwner", { + lead: latestKnowledgeDispatch.lead_agent ?? "--", + human: latestKnowledgeDispatch.human_owner ?? "--", + }), + t("evidence.knowledgeNext", { + action: latestKnowledgeDispatch.next_action ?? latestKnowledgeDispatch.proposed_action ?? "--", + }), + ] + : [t("evidence.knowledgeEmpty")], + href: "/awooop/work-items", + }, { id: "frontendConsole", phase: "T17", @@ -1183,6 +1288,117 @@ function RecurrenceWorkQueuePanel({ ); } +function KnowledgeGovernancePanel({ + queue, +}: { + queue: GovernanceQueueResponse | null; +}) { + const t = useTranslations("awooop.workItems.knowledgeGovernance"); + const items = queue?.items ?? []; + const activeCount = items.filter((item) => + ["pending", "dispatched", "executing"].includes(item.dispatch_status) + ).length; + const reviewCount = items.filter((item) => + item.dispatch_status === "skipped" || item.workflow_stage === "waiting_owner_review" + ).length; + + return ( +
+
+
+
+
+ + {t("total", { count: queue?.total ?? 0 })} + + + {t("active", { count: activeCount })} + + + {t("review", { count: reviewCount })} + +
+
+ + {queue === null ? ( +
+ {t("unavailable")} +
+ ) : queue.table_pending ? ( +
+ {t("tablePending")} +
+ ) : items.length === 0 ? ( +
+ {t("empty")} +
+ ) : ( +
+ {items.slice(0, 6).map((item) => { + const statusKey = governanceKmDispatchStatusKey(item.dispatch_status); + const stageKey = governanceKmStageKey(item.workflow_stage); + const steps = item.workflow_steps ?? []; + + return ( +
+
+
+

+ {item.governance_event_id} +

+

+ {item.executor_type ?? "--"} / {item.decision_path ?? "--"} +

+
+ + {t(`statuses.${statusKey}` as never)} + +
+ +
+

+ {t("stage", { + stage: t(`stages.${stageKey}` as never), + })} +

+

{t("next", { action: item.next_action ?? item.proposed_action ?? "--" })}

+

{t("lead", { agent: item.lead_agent ?? "--" })}

+

{t("human", { owner: item.human_owner ?? "--" })}

+

+ {t("support", { + agents: item.support_agents?.length + ? item.support_agents.join(" / ") + : "--", + })} +

+
+ + {steps.length > 0 ? ( +
+ {steps.slice(0, 6).map((step) => ( + + {t(`stages.${governanceKmStageKey(step)}` as never)} + + ))} +
+ ) : null} +
+ ); + })} +
+ )} +
+ ); +} + function DriftFingerprintPanel({ state, onRecorded, @@ -1409,6 +1625,7 @@ export default function AwoooPWorkItemsPage() { quality: null, governanceEvents: null, governanceQueue: null, + governanceKnowledgeQueue: null, channelEvents: null, eventRecurrence: null, slo: null, @@ -1425,6 +1642,7 @@ export default function AwoooPWorkItemsPage() { const qualityUrl = `${API_BASE}/api/v1/platform/truth-chain/quality/summary?project_id=${encodedProjectId}&hours=24&limit=30`; const governanceEventsUrl = `${API_BASE}/api/v1/ai/governance/events?event_type=knowledge_degradation&event_type=governance_slo_data_gap&status=unresolved&size=10`; const governanceQueueUrl = `${API_BASE}/api/v1/ai/governance/queue?dispatch_status=pending&size=10`; + const governanceKnowledgeQueueUrl = `${API_BASE}/api/v1/ai/governance/queue?dispatch_status=all&event_type=knowledge_degradation&size=20`; const channelEventsUrl = `${API_BASE}/api/v1/platform/events/recent?project_id=${encodedProjectId}&provider_prefix=alertmanager&limit=20`; const recurrenceUrl = `${API_BASE}/api/v1/platform/events/dossier/recurrence?project_id=${encodedProjectId}&limit=100`; const sloUrl = `${API_BASE}/api/v1/ai/slo`; @@ -1435,6 +1653,7 @@ export default function AwoooPWorkItemsPage() { quality, governanceEvents, governanceQueue, + governanceKnowledgeQueue, channelEvents, eventRecurrence, slo, @@ -1444,6 +1663,7 @@ export default function AwoooPWorkItemsPage() { fetchJson(qualityUrl, 15000), fetchJson(governanceEventsUrl), fetchJson(governanceQueueUrl), + fetchJson(governanceKnowledgeQueueUrl), fetchJson(channelEventsUrl), fetchJson(recurrenceUrl), fetchJson(sloUrl), @@ -1470,6 +1690,7 @@ export default function AwoooPWorkItemsPage() { quality, governanceEvents, governanceQueue, + governanceKnowledgeQueue, channelEvents, eventRecurrence, slo, @@ -1590,6 +1811,8 @@ export default function AwoooPWorkItemsPage() { projectId={projectId} /> + + ok +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 +ruff F/E9 selected files + -> All checks passed +pnpm --filter @awoooi/web typecheck + -> pass +pnpm exec next lint --file 'src/app/[locale]/awooop/work-items/page.tsx' + -> No ESLint warnings or errors +git diff --check + -> pass +``` + +**已知技術債**: + +- 全專案 `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 後推進狀態。 + +**目前整體進度**: + +- AwoooP 告警可觀測鏈:約 98.5%。 +- 低風險自動修復閉環:約 95%。 +- 前端 AI 自動化管理介面同步:約 94.5%。 +- 治理告警可讀性 / 可處置性:約 94%。 +- AI Agent ownership 可追溯性:約 92%。 +- KM healthcheck 派工可追蹤性:約 72%。 +- 完整 AI 自動化管理產品化:約 91.8%。 + ## 2026-05-19|T87 KM 劣化治理告警 Agent ownership 可視化 **背景**: 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 51bb34bb0..82841dbd1 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 @@ -2292,6 +2292,15 @@ Phase 6 完成後 - 邊界:T71 仍是 read model / product visibility,不代表所有中低風險告警已全自動修復。宣稱 AI 自動修復完成仍需 execution record、verification、closure 與 KM/PlayBook evidence 同時成立。 - 目前進度更新:AwoooP truth-chain 可回看約 99.8%;來源告警 / Sentry / SignOz / 修復結果可見性約 99.7%;Operator Console status-chain 可見性約 99.5%;Work Items / Approvals 操作面產品化約 99.4%;AI 自動修復閉環約 98.1%;完整 AI 自動化管理產品化約 99.0%。 +**T88 knowledge_degradation KM healthcheck dispatch trail(2026-05-19 台北)**: +- 觸發:`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 可顯示人工接手點。 +- 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。 +- 目前進度更新:治理告警可讀性 / 可處置性約 94%;AI Agent ownership 可追溯性約 92%;KM healthcheck 派工可追蹤性約 72%;完整 AI 自動化管理產品化約 91.8%。 + --- ### 2026-04-20 晚 (台北) — C1-C4 全流程串接 — Playbook 鏈路保護(commit de2d34d)