diff --git a/apps/api/src/models/governance.py b/apps/api/src/models/governance.py
index 0a51ece39..0b332b746 100644
--- a/apps/api/src/models/governance.py
+++ b/apps/api/src/models/governance.py
@@ -103,6 +103,8 @@ class DispatchItem(BaseModel):
lead_agent: str | None = None
support_agents: list[str] = Field(default_factory=list)
human_owner: str | None = None
+ kb_draft_entry_id: str | None = None
+ worker_status: str | None = None
class GovernanceQueueResponse(BaseModel):
diff --git a/apps/api/src/services/governance_query_service.py b/apps/api/src/services/governance_query_service.py
index 1d4a89e69..edfcb2e74 100644
--- a/apps/api/src/services/governance_query_service.py
+++ b/apps/api/src/services/governance_query_service.py
@@ -422,6 +422,8 @@ async def _query_dispatch_table(
lead_agent=_extract_lead_agent(decision_ctx),
support_agents=_extract_support_agents(decision_ctx),
human_owner=_extract_human_owner(decision_ctx),
+ kb_draft_entry_id=_extract_kb_draft_entry_id(decision_ctx),
+ worker_status=_extract_worker_status(decision_ctx),
))
return GovernanceQueueResponse(
@@ -532,6 +534,31 @@ def _extract_human_owner(decision_ctx: dict) -> str | None:
return val[:120] if isinstance(val, str) and val else None
+def _extract_kb_draft_entry_id(decision_ctx: dict) -> str | None:
+ """Expose Hermes KM review draft id for Work Items owner review."""
+ workflow = decision_ctx.get("workflow")
+ if isinstance(workflow, dict):
+ val = workflow.get("kb_draft_entry_id")
+ if isinstance(val, str) and val:
+ return val[:120]
+
+ worker_result = decision_ctx.get("worker_result")
+ if isinstance(worker_result, dict):
+ val = worker_result.get("km_draft_entry_id")
+ if isinstance(val, str) and val:
+ return val[:120]
+
+ return None
+
+
+def _extract_worker_status(decision_ctx: dict) -> str | None:
+ worker_result = decision_ctx.get("worker_result")
+ if not isinstance(worker_result, dict):
+ return None
+ val = worker_result.get("status")
+ return val[:80] 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 bb4afff15..780127e82 100644
--- a/apps/api/tests/test_ai_governance_endpoints.py
+++ b/apps/api/tests/test_ai_governance_endpoints.py
@@ -32,7 +32,9 @@ from src.models.governance import (
map_severity,
)
from src.services.governance_query_service import (
+ _extract_kb_draft_entry_id,
_extract_remediation,
+ _extract_worker_status,
_merge_dispatch_ids,
_query_dispatch_table,
_to_governance_event,
@@ -338,6 +340,53 @@ class TestQueueEndpoint:
assert captured["event_types"] == ["knowledge_degradation"]
assert captured["size"] == 20
+ def test_queue_item_exposes_kb_draft_review_context(self, client):
+ """Work Items 需要看到 Hermes 草稿 ID 與 worker 狀態,而不只 dispatch succeeded。"""
+ item = DispatchItem(
+ id="dispatch-001",
+ governance_event_id="event-001",
+ event_type="knowledge_degradation",
+ dispatch_status="succeeded",
+ executor_type="hermes_kb_growth_healthcheck",
+ proposed_action="Hermes 已建立 KM 更新草稿,等待 owner 審核",
+ created_at=NOW,
+ workflow_stage="waiting_owner_review",
+ next_action="owner_review_km_draft",
+ lead_agent="Hermes",
+ human_owner="KM owner / SRE owner",
+ kb_draft_entry_id="km-001",
+ worker_status="draft_created",
+ )
+ normal = GovernanceQueueResponse(
+ items=[item], total=1, page=1, size=20, table_pending=False,
+ )
+ with patch(
+ "src.api.v1.ai_governance.query_governance_queue",
+ new=AsyncMock(return_value=normal),
+ ):
+ r = client.get(
+ "/api/v1/ai/governance/queue?dispatch_status=all"
+ "&event_type=knowledge_degradation&size=20"
+ )
+
+ assert r.status_code == 200
+ data = r.json()
+ assert data["items"][0]["kb_draft_entry_id"] == "km-001"
+ assert data["items"][0]["worker_status"] == "draft_created"
+
+ def test_extract_kb_draft_review_context_from_decision_context(self):
+ """queue read model 從 workflow / worker_result 補出 KM 草稿追蹤欄位。"""
+ context = {
+ "workflow": {"kb_draft_entry_id": "km-workflow"},
+ "worker_result": {
+ "km_draft_entry_id": "km-worker",
+ "status": "draft_created",
+ },
+ }
+
+ assert _extract_kb_draft_entry_id(context) == "km-workflow"
+ assert _extract_worker_status(context) == "draft_created"
+
def test_queue_query_uses_production_dispatch_schema(self):
"""queue 查詢必須對齊 migration schema:使用 dispatched_at,不讀不存在的 created_at/operator_note."""
import inspect
diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json
index 3bbca1993..eabd5ac94 100644
--- a/apps/web/messages/en.json
+++ b/apps/web/messages/en.json
@@ -1860,6 +1860,7 @@
"knowledgeHealthcheck": "KM healthcheck dispatches: {total}; current stage: {stage}",
"knowledgeOwner": "Lead: {lead}; human review: {human}",
"knowledgeNext": "Next action: {action}",
+ "knowledgeDrafts": "KM review drafts: {drafts}; duplicate drafts: {duplicates}",
"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",
@@ -1893,6 +1894,8 @@
"total": "Total {count}",
"active": "Active {count}",
"review": "Review {count}",
+ "drafts": "Drafts {count}",
+ "duplicates": "Duplicates {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.",
@@ -1901,6 +1904,13 @@
"lead": "Lead: {agent}",
"human": "Human review: {owner}",
"support": "Support: {agents}",
+ "worker": "Worker status: {status}",
+ "draft": "KM draft: {id}",
+ "duplicateWarning": "{count} duplicate drafts exist for the same event; the new worker dedupes by governance_event, and old rows need owner merge or archive.",
+ "draftsUnavailable": "The knowledge API has not responded, so KM drafts and duplicate counts cannot be confirmed yet.",
+ "draftsEmpty": "No Hermes KM healthcheck review draft is currently present.",
+ "draftSectionTitle": "KM draft dedupe view",
+ "draftGroup": "Drafts for this event: {count}; duplicates: {duplicates}",
"statuses": {
"pending": "Pending",
"dispatched": "Dispatched",
diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json
index dddfb2cf9..239860bf9 100644
--- a/apps/web/messages/zh-TW.json
+++ b/apps/web/messages/zh-TW.json
@@ -1861,6 +1861,7 @@
"knowledgeHealthcheck": "KM healthcheck 派工:{total};目前階段:{stage}",
"knowledgeOwner": "主責:{lead};人工覆核:{human}",
"knowledgeNext": "下一步:{action}",
+ "knowledgeDrafts": "KM 審核草稿:{drafts};重複草稿:{duplicates}",
"knowledgeEmpty": "近期沒有 knowledge_degradation dispatch trail",
"frontendConsole": "本頁已改讀 production API,而非靜態清單",
"mcpReady": "MCP Gateway gate 目前未列為主要缺口",
@@ -1894,6 +1895,8 @@
"total": "總數 {count}",
"active": "執行中 {count}",
"review": "需審核 {count}",
+ "drafts": "草稿 {count}",
+ "duplicates": "重複 {count}",
"unavailable": "governance queue API 尚未回應,不能判定 KM healthcheck 是否已派工。",
"tablePending": "governance_remediation_dispatch 表尚未就緒,KM healthcheck 尚無可追蹤派工列。",
"empty": "目前沒有 knowledge_degradation 派工紀錄;若 Telegram 又告警,下一輪應產生 dispatch trail。",
@@ -1902,6 +1905,13 @@
"lead": "主責:{agent}",
"human": "人工覆核:{owner}",
"support": "支援:{agents}",
+ "worker": "Worker 狀態:{status}",
+ "draft": "KM 草稿:{id}",
+ "duplicateWarning": "同事件另有 {count} 份重複草稿;新 worker 已改用 governance_event 去重,舊資料需 owner 合併或封存。",
+ "draftsUnavailable": "knowledge API 尚未回應,暫時無法確認 KM 草稿與重複草稿數。",
+ "draftsEmpty": "目前沒有 Hermes KM healthcheck review 草稿。",
+ "draftSectionTitle": "KM 草稿去重視圖",
+ "draftGroup": "同事件草稿 {count} 份;重複 {duplicates} 份",
"statuses": {
"pending": "等待處理",
"dispatched": "已派遣",
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 bfe80ce71..1b9ae0010 100644
--- a/apps/web/src/app/[locale]/awooop/work-items/page.tsx
+++ b/apps/web/src/app/[locale]/awooop/work-items/page.tsx
@@ -14,6 +14,7 @@ import {
ClipboardList,
Database,
Fingerprint,
+ FileText,
Gauge,
GitBranch,
GitPullRequest,
@@ -70,6 +71,8 @@ type GovernanceQueueItem = {
lead_agent?: string | null;
support_agents?: string[];
human_owner?: string | null;
+ kb_draft_entry_id?: string | null;
+ worker_status?: string | null;
};
type GovernanceQueueResponse = {
@@ -221,6 +224,32 @@ type RemediationHistoryResponse = {
items?: RemediationHistoryItem[];
};
+type KnowledgeEntry = {
+ id: string;
+ title: string;
+ content?: string | null;
+ entry_type: string;
+ category?: string | null;
+ tags: string[];
+ source?: string | null;
+ status: string;
+ created_by?: string | null;
+ created_at?: string | null;
+ updated_at?: string | null;
+};
+
+type KnowledgeListResponse = {
+ total: number;
+ items?: KnowledgeEntry[];
+};
+
+type KnowledgeReviewDraftGroup = {
+ eventId: string;
+ canonical: KnowledgeEntry;
+ entries: KnowledgeEntry[];
+ duplicateCount: number;
+};
+
type DriftFingerprintState = {
schema_version?: string;
namespace?: string;
@@ -301,6 +330,7 @@ type Telemetry = {
governanceEvents: GovernanceEventsResponse | null;
governanceQueue: GovernanceQueueResponse | null;
governanceKnowledgeQueue: GovernanceQueueResponse | null;
+ knowledgeReviewDrafts: KnowledgeListResponse | null;
channelEvents: RecentEventsResponse | null;
eventRecurrence: RecurrenceResponse | null;
slo: SloResponse | null;
@@ -620,6 +650,54 @@ function governanceKmStatusForWorkItem(queue: GovernanceQueueResponse | null): W
return "watching";
}
+function governanceEventIdForDraft(entry: KnowledgeEntry) {
+ const tag = entry.tags.find((item) => item.startsWith("governance_event:"));
+ return tag?.replace("governance_event:", "") || null;
+}
+
+function groupKnowledgeReviewDrafts(
+ reviewDrafts: KnowledgeListResponse | null,
+ queueItems: GovernanceQueueItem[] = []
+): KnowledgeReviewDraftGroup[] {
+ const entries = reviewDrafts?.items ?? [];
+ const preferredDraftByEvent = new Map
{t("worker", { status: workerStatus })}
+{t("draft", { id: draftId ?? "--" })}
+ {duplicateCount > 0 ? ( ++ {t("duplicateWarning", { count: duplicateCount })} +
+ ) : null} {steps.length > 0 ? ( @@ -1395,6 +1506,51 @@ function KnowledgeGovernancePanel({ })} )} + + {reviewDrafts === null ? ( ++ {group.eventId} +
+{group.canonical.title}
++ {t("draftGroup", { + count: group.entries.length, + duplicates: group.duplicateCount, + })} +
+