From 855716b5b85d05a8c0acdb19a89e152260bad941 Mon Sep 17 00:00:00 2001
From: Your Name
Date: Tue, 19 May 2026 23:27:33 +0800
Subject: [PATCH] feat(awooop): surface km review draft dedupe
---
apps/api/src/models/governance.py | 2 +
.../src/services/governance_query_service.py | 27 +++
.../api/tests/test_ai_governance_endpoints.py | 49 ++++++
apps/web/messages/en.json | 10 ++
apps/web/messages/zh-TW.json | 10 ++
.../app/[locale]/awooop/work-items/page.tsx | 166 +++++++++++++++++-
docs/LOGBOOK.md | 50 ++++++
7 files changed, 313 insertions(+), 1 deletion(-)
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();
+ for (const item of queueItems) {
+ if (item.governance_event_id && item.kb_draft_entry_id) {
+ preferredDraftByEvent.set(item.governance_event_id, item.kb_draft_entry_id);
+ }
+ }
+
+ const groups = new Map();
+ for (const entry of entries) {
+ const eventId = governanceEventIdForDraft(entry);
+ if (!eventId) continue;
+ groups.set(eventId, [...(groups.get(eventId) ?? []), entry]);
+ }
+
+ return Array.from(groups.entries()).map(([eventId, draftEntries]) => {
+ const sorted = [...draftEntries].sort((a, b) =>
+ String(b.updated_at ?? b.created_at ?? "").localeCompare(
+ String(a.updated_at ?? a.created_at ?? "")
+ )
+ );
+ const preferredId = preferredDraftByEvent.get(eventId);
+ const canonical = sorted.find((entry) => entry.id === preferredId) ?? sorted[0];
+ return {
+ eventId,
+ canonical,
+ entries: sorted,
+ duplicateCount: Math.max(sorted.length - 1, 0),
+ };
+ });
+}
+
+function draftGroupForQueueItem(
+ item: GovernanceQueueItem,
+ groups: KnowledgeReviewDraftGroup[]
+) {
+ return groups.find((group) => group.eventId === item.governance_event_id) ?? null;
+}
+
function buildWorkItems(
telemetry: Telemetry,
t: ReturnType
@@ -631,6 +709,14 @@ function buildWorkItems(
const governanceQueuePending = telemetry.governanceQueue?.total ?? 0;
const knowledgeDispatches = telemetry.governanceKnowledgeQueue?.items ?? [];
const latestKnowledgeDispatch = knowledgeDispatches[0] ?? null;
+ const knowledgeReviewDraftGroups = groupKnowledgeReviewDrafts(
+ telemetry.knowledgeReviewDrafts,
+ knowledgeDispatches
+ );
+ const knowledgeDuplicateDrafts = knowledgeReviewDraftGroups.reduce(
+ (sum, group) => sum + group.duplicateCount,
+ 0
+ );
const remediationQueue = telemetry.slo?.adr100?.verification_coverage?.remediation_queue;
const remediationTotal = remediationQueue?.total ?? 0;
const remediationReadyForAi = remediationQueue?.ready_for_ai ?? 0;
@@ -852,6 +938,10 @@ function buildWorkItems(
t("evidence.knowledgeNext", {
action: latestKnowledgeDispatch.next_action ?? latestKnowledgeDispatch.proposed_action ?? "--",
}),
+ t("evidence.knowledgeDrafts", {
+ drafts: telemetry.knowledgeReviewDrafts?.total ?? 0,
+ duplicates: knowledgeDuplicateDrafts,
+ }),
]
: [t("evidence.knowledgeEmpty")],
href: "/awooop/work-items",
@@ -1290,11 +1380,15 @@ function RecurrenceWorkQueuePanel({
function KnowledgeGovernancePanel({
queue,
+ reviewDrafts,
}: {
queue: GovernanceQueueResponse | null;
+ reviewDrafts: KnowledgeListResponse | null;
}) {
const t = useTranslations("awooop.workItems.knowledgeGovernance");
const items = queue?.items ?? [];
+ const draftGroups = groupKnowledgeReviewDrafts(reviewDrafts, items);
+ const duplicateDraftCount = draftGroups.reduce((sum, group) => sum + group.duplicateCount, 0);
const activeCount = items.filter((item) =>
["pending", "dispatched", "executing"].includes(item.dispatch_status)
).length;
@@ -1322,6 +1416,12 @@ function KnowledgeGovernancePanel({
{t("review", { count: reviewCount })}
+
+ {t("drafts", { count: reviewDrafts?.total ?? 0 })}
+
+
+ {t("duplicates", { count: duplicateDraftCount })}
+
@@ -1343,6 +1443,10 @@ function KnowledgeGovernancePanel({
const statusKey = governanceKmDispatchStatusKey(item.dispatch_status);
const stageKey = governanceKmStageKey(item.workflow_stage);
const steps = item.workflow_steps ?? [];
+ const draftGroup = draftGroupForQueueItem(item, draftGroups);
+ const draftId = item.kb_draft_entry_id ?? draftGroup?.canonical.id ?? null;
+ const workerStatus = item.worker_status ?? "--";
+ const duplicateCount = draftGroup?.duplicateCount ?? 0;
return (
@@ -1376,6 +1480,13 @@ function KnowledgeGovernancePanel({
: "--",
})}
+ {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 ? (
+
+ {t("draftsUnavailable")}
+
+ ) : draftGroups.length > 0 ? (
+
+
+
+
+ {t("draftSectionTitle")}
+
+
+
+ {draftGroups.slice(0, 4).map((group) => (
+
+
+
+
+ {group.eventId}
+
+
{group.canonical.title}
+
+
+ {group.canonical.id}
+
+
+
+ {t("draftGroup", {
+ count: group.entries.length,
+ duplicates: group.duplicateCount,
+ })}
+
+
+ ))}
+
+
+ ) : (
+
+ {t("draftsEmpty")}
+
+ )}
);
}
@@ -1626,6 +1782,7 @@ export default function AwoooPWorkItemsPage() {
governanceEvents: null,
governanceQueue: null,
governanceKnowledgeQueue: null,
+ knowledgeReviewDrafts: null,
channelEvents: null,
eventRecurrence: null,
slo: null,
@@ -1643,6 +1800,7 @@ export default function AwoooPWorkItemsPage() {
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 knowledgeReviewDraftsUrl = `${API_BASE}/api/v1/knowledge?entry_type=auto_runbook&status=review&q=${encodeURIComponent("KM healthcheck")}&limit=100`;
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`;
@@ -1654,6 +1812,7 @@ export default function AwoooPWorkItemsPage() {
governanceEvents,
governanceQueue,
governanceKnowledgeQueue,
+ knowledgeReviewDrafts,
channelEvents,
eventRecurrence,
slo,
@@ -1664,6 +1823,7 @@ export default function AwoooPWorkItemsPage() {
fetchJson(governanceEventsUrl),
fetchJson(governanceQueueUrl),
fetchJson(governanceKnowledgeQueueUrl),
+ fetchJson(knowledgeReviewDraftsUrl),
fetchJson(channelEventsUrl),
fetchJson(recurrenceUrl),
fetchJson(sloUrl),
@@ -1691,6 +1851,7 @@ export default function AwoooPWorkItemsPage() {
governanceEvents,
governanceQueue,
governanceKnowledgeQueue,
+ knowledgeReviewDrafts,
channelEvents,
eventRecurrence,
slo,
@@ -1811,7 +1972,10 @@ export default function AwoooPWorkItemsPage() {
projectId={projectId}
/>
-
+
` tag grouping KM 草稿:
+ - dispatch 卡顯示 worker status、KM draft id、owner review next action。
+ - 面板 header 顯示 draft total / duplicate count。
+ - 下方新增「KM 草稿去重視圖」,列出 event id、canonical draft id/title、同事件草稿數與 duplicate 數。
+- 這段只做 read-side 可視化與去重提示;不自動 archive 舊重複草稿,也不自動 approve/publish KM。
+
+**Local verification**:
+
+```text
+python3 -m py_compile apps/api/src/models/governance.py apps/api/src/services/governance_query_service.py apps/api/src/jobs/hermes_kb_growth_worker.py
+ -> ok
+DATABASE_URL=postgresql+asyncpg://test:test@localhost:5432/test /Users/ogt/awoooi/apps/api/.venv/bin/python -m pytest apps/api/tests/test_ai_governance_endpoints.py apps/api/tests/test_hermes_kb_growth_worker.py -q
+ -> 34 passed
+pnpm --dir apps/web exec next lint --file 'src/app/[locale]/awooop/work-items/page.tsx'
+ -> No ESLint warnings or errors
+pnpm --dir apps/web exec tsc --noEmit --pretty false
+ -> ok
+```
+
+**待 production deploy / smoke**:
+
+- 推 Gitea main 後,確認 `/api/v1/ai/governance/queue?...event_type=knowledge_degradation` 回傳 `kb_draft_entry_id / worker_status`。
+- 確認 production Work Items 顯示 KM draft total、duplicate count、canonical draft group,且導航列仍存在。
+
+**目前整體進度**:
+
+- AwoooP 告警可觀測鏈:約 99.1%。
+- 低風險自動修復閉環:約 95%。
+- 前端 AI 自動化管理介面同步:約 96.2%。
+- 治理告警可讀性 / 可處置性:約 97%。
+- AI Agent ownership 可追溯性:約 95.5%。
+- KM healthcheck 派工可追蹤性:約 99%。
+- Hermes KB growth 草稿 / owner review 閉環:約 95%。
+- 完整 AI 自動化管理產品化:約 94.3%。
+
## 2026-05-19|T90 Hermes KB healthcheck worker 與 owner review 閉環
**觸發**: