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 ? ( +
+
+
+
+ {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 閉環 **觸發**: