From 0cd6301d0e14bb1fc2d178373c966e123762612b Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 19 May 2026 23:56:03 +0800 Subject: [PATCH] feat(governance): expose km draft dedupe plan --- apps/api/src/api/v1/ai_governance.py | 23 +++ apps/api/src/models/governance.py | 28 +++ .../src/services/governance_query_service.py | 182 +++++++++++++++++- .../api/tests/test_ai_governance_endpoints.py | 88 +++++++++ apps/web/messages/en.json | 8 + apps/web/messages/zh-TW.json | 8 + .../app/[locale]/awooop/work-items/page.tsx | 127 +++++++++++- docs/LOGBOOK.md | 51 +++++ 8 files changed, 507 insertions(+), 8 deletions(-) diff --git a/apps/api/src/api/v1/ai_governance.py b/apps/api/src/api/v1/ai_governance.py index 3218d27b3..7e2167067 100644 --- a/apps/api/src/api/v1/ai_governance.py +++ b/apps/api/src/api/v1/ai_governance.py @@ -28,8 +28,10 @@ from src.models.governance import ( GovernanceEventsResponse, GovernanceQueueResponse, GovernanceSummaryResponse, + KnowledgeReviewDraftDedupeResponse, ) from src.services.governance_query_service import ( + query_km_review_draft_dedupe, query_governance_events, query_governance_queue, query_governance_summary, @@ -124,6 +126,27 @@ async def get_governance_queue( ) +# ============================================================================= +# GET /api/v1/ai/governance/km-review-drafts/dedupe +# ============================================================================= + +@router.get( + "/ai/governance/km-review-drafts/dedupe", + response_model=KnowledgeReviewDraftDedupeResponse, +) +async def get_km_review_draft_dedupe( + limit: Annotated[int, Query(ge=10, le=200)] = 100, +) -> KnowledgeReviewDraftDedupeResponse: + """ + 查詢 Hermes KM healthcheck review drafts 的去重 read model。 + + 這是 read-only owner review surface:只回傳 canonical / duplicate / + owner_action,不自動 archive、不自動 approve/publish KM。 + """ + logger.debug("km_review_draft_dedupe_request", limit=limit) + return await query_km_review_draft_dedupe(limit=limit) + + # ============================================================================= # GET /api/v1/ai/governance/summary # ============================================================================= diff --git a/apps/api/src/models/governance.py b/apps/api/src/models/governance.py index 0b332b746..6967135d6 100644 --- a/apps/api/src/models/governance.py +++ b/apps/api/src/models/governance.py @@ -118,6 +118,34 @@ class GovernanceQueueResponse(BaseModel): ) +# ============================================================================= +# Endpoint 2B: KM review draft dedupe +# ============================================================================= + +class KnowledgeReviewDraftDedupeGroup(BaseModel): + governance_event_id: str + canonical_entry_id: str + canonical_title: str + canonical_updated_at: datetime | None = None + preferred_source: Literal["dispatch_context", "latest_review_draft"] + duplicate_entry_ids: list[str] = Field(default_factory=list) + duplicate_count: int + total_entries: int + suggested_action: str + owner_action: str + writes_on_read: bool = False + can_archive_without_owner_approval: bool = False + + +class KnowledgeReviewDraftDedupeResponse(BaseModel): + schema_version: str = "km_review_draft_dedupe_v1" + total_review_drafts: int + event_group_total: int + duplicate_draft_total: int + groups: list[KnowledgeReviewDraftDedupeGroup] + generated_at: datetime + + # ============================================================================= # Endpoint 3: summary # ============================================================================= diff --git a/apps/api/src/services/governance_query_service.py b/apps/api/src/services/governance_query_service.py index edfcb2e74..16b810845 100644 --- a/apps/api/src/services/governance_query_service.py +++ b/apps/api/src/services/governance_query_service.py @@ -23,11 +23,11 @@ from datetime import datetime, timedelta, timezone from typing import Any import structlog -from sqlalchemy import bindparam, func, select, text +from sqlalchemy import String, bindparam, func, or_, select, text from sqlalchemy.exc import ProgrammingError from src.db.base import get_db_context -from src.db.models import AiGovernanceEvent +from src.db.models import AiGovernanceEvent, KnowledgeEntryRecord from src.models.governance import ( DailyCount, DispatchItem, @@ -35,8 +35,11 @@ from src.models.governance import ( GovernanceEventsResponse, GovernanceQueueResponse, GovernanceSummaryResponse, + KnowledgeReviewDraftDedupeGroup, + KnowledgeReviewDraftDedupeResponse, map_severity, ) +from src.models.knowledge import EntryStatus, EntryType from src.utils.timezone import now_taipei logger = structlog.get_logger(__name__) @@ -559,6 +562,181 @@ def _extract_worker_status(decision_ctx: dict) -> str | None: return val[:80] if isinstance(val, str) and val else None +# ============================================================================= +# Endpoint 2B: KM review draft dedupe +# ============================================================================= + +async def query_km_review_draft_dedupe( + *, + limit: int = 100, +) -> KnowledgeReviewDraftDedupeResponse: + """產生 Hermes KM healthcheck review drafts 的 read-only 去重計畫。""" + rows = await _load_km_healthcheck_review_drafts(limit=limit) + preferred = await _load_preferred_km_draft_ids_by_event([ + event_id + for row in rows + if (event_id := _extract_governance_event_id_from_tags(row.get("tags"))) + ]) + groups = _build_km_review_draft_dedupe_groups(rows, preferred) + + return KnowledgeReviewDraftDedupeResponse( + total_review_drafts=len(rows), + event_group_total=len(groups), + duplicate_draft_total=sum(group.duplicate_count for group in groups), + groups=groups, + generated_at=now_taipei(), + ) + + +async def _load_km_healthcheck_review_drafts(limit: int) -> list[dict[str, Any]]: + """讀取 Hermes 產生、等待 owner review 的 KM healthcheck 草稿。""" + async with get_db_context() as db: + stmt = ( + select(KnowledgeEntryRecord) + .where( + KnowledgeEntryRecord.entry_type == EntryType.AUTO_RUNBOOK, + KnowledgeEntryRecord.status == EntryStatus.REVIEW, + or_( + KnowledgeEntryRecord.title.ilike("%KM healthcheck%"), + KnowledgeEntryRecord.content.ilike("%KM healthcheck%"), + KnowledgeEntryRecord.tags.cast(String).ilike("%workflow:kb_growth_healthcheck%"), + ), + ) + .order_by(KnowledgeEntryRecord.updated_at.desc()) + .limit(limit) + ) + result = await db.execute(stmt) + records = result.scalars().all() + + return [ + { + "id": str(record.id), + "title": str(record.title), + "status": str(record.status.value if hasattr(record.status, "value") else record.status), + "tags": list(record.tags or []), + "created_by": record.created_by, + "created_at": record.created_at, + "updated_at": record.updated_at, + } + for record in records + ] + + +async def _load_preferred_km_draft_ids_by_event( + event_ids: list[str], +) -> dict[str, str]: + """從 dispatch worker_result 讀取 event 對應的 canonical KM draft id。""" + if not event_ids: + return {} + + unique_event_ids = list(dict.fromkeys(event_ids)) + sql = text(""" + SELECT + d.governance_event_id, + d.decision_context + FROM governance_remediation_dispatch d + WHERE d.governance_event_id IN :event_ids + AND d.executor_type = 'hermes_kb_growth_healthcheck' + AND d.dispatch_status::text = 'succeeded' + ORDER BY d.completed_at DESC NULLS LAST, d.dispatched_at DESC + """).bindparams(bindparam("event_ids", expanding=True)) + + try: + async with get_db_context() as db: + result = await db.execute(sql, {"event_ids": unique_event_ids}) + rows = result.fetchall() + except ProgrammingError as exc: + logger.warning( + "km_review_dedupe_dispatch_table_not_ready", + error=str(exc), + ) + return {} + + preferred: dict[str, str] = {} + for row in rows: + event_id = str(row.governance_event_id) + if event_id in preferred: + continue + decision_ctx = row.decision_context or {} + if not isinstance(decision_ctx, dict): + continue + draft_id = _extract_kb_draft_entry_id(decision_ctx) + if draft_id: + preferred[event_id] = draft_id + return preferred + + +def _extract_governance_event_id_from_tags(tags: Any) -> str | None: + if not isinstance(tags, list): + return None + for raw in tags: + tag = str(raw) + if tag.startswith("governance_event:"): + event_id = tag.replace("governance_event:", "", 1).strip() + return event_id or None + return None + + +def _build_km_review_draft_dedupe_groups( + rows: list[dict[str, Any]], + preferred_draft_ids_by_event: dict[str, str] | None = None, +) -> list[KnowledgeReviewDraftDedupeGroup]: + """把 KM review drafts 依 governance_event tag 分組並產生 owner action。""" + preferred_draft_ids_by_event = preferred_draft_ids_by_event or {} + grouped: dict[str, list[dict[str, Any]]] = {} + for row in rows: + event_id = _extract_governance_event_id_from_tags(row.get("tags")) + if not event_id: + continue + grouped.setdefault(event_id, []).append(row) + + groups: list[KnowledgeReviewDraftDedupeGroup] = [] + for event_id, entries in grouped.items(): + sorted_entries = sorted( + entries, + key=lambda item: str(item.get("updated_at") or item.get("created_at") or ""), + reverse=True, + ) + preferred_id = preferred_draft_ids_by_event.get(event_id) + canonical = next( + (entry for entry in sorted_entries if entry.get("id") == preferred_id), + sorted_entries[0], + ) + duplicate_ids = [ + str(entry["id"]) + for entry in sorted_entries + if entry.get("id") != canonical.get("id") + ] + preferred_source = ( + "dispatch_context" + if preferred_id and canonical.get("id") == preferred_id + else "latest_review_draft" + ) + groups.append(KnowledgeReviewDraftDedupeGroup( + governance_event_id=event_id, + canonical_entry_id=str(canonical["id"]), + canonical_title=str(canonical.get("title") or ""), + canonical_updated_at=canonical.get("updated_at"), + preferred_source=preferred_source, + duplicate_entry_ids=duplicate_ids, + duplicate_count=len(duplicate_ids), + total_entries=len(sorted_entries), + suggested_action="owner_review_canonical_then_archive_duplicates", + owner_action="review_canonical_and_archive_duplicate_drafts", + writes_on_read=False, + can_archive_without_owner_approval=False, + )) + + return sorted( + groups, + key=lambda group: ( + group.duplicate_count, + group.canonical_updated_at.isoformat() if group.canonical_updated_at else "", + ), + reverse=True, + ) + + # ============================================================================= # Endpoint 3: summary # ============================================================================= diff --git a/apps/api/tests/test_ai_governance_endpoints.py b/apps/api/tests/test_ai_governance_endpoints.py index 780127e82..0315b5737 100644 --- a/apps/api/tests/test_ai_governance_endpoints.py +++ b/apps/api/tests/test_ai_governance_endpoints.py @@ -29,10 +29,14 @@ from src.models.governance import ( GovernanceEventsResponse, GovernanceQueueResponse, GovernanceSummaryResponse, + KnowledgeReviewDraftDedupeGroup, + KnowledgeReviewDraftDedupeResponse, map_severity, ) from src.services.governance_query_service import ( + _build_km_review_draft_dedupe_groups, _extract_kb_draft_entry_id, + _extract_governance_event_id_from_tags, _extract_remediation, _extract_worker_status, _merge_dispatch_ids, @@ -402,6 +406,90 @@ class TestQueueEndpoint: assert "d.operator_note" not in source +class TestKmReviewDraftDedupe: + def test_endpoint_passes_limit_and_returns_owner_review_plan(self, client): + """Work Items 需要 read-only canonical/duplicate plan,不直接封存 KM。""" + fake = KnowledgeReviewDraftDedupeResponse( + total_review_drafts=3, + event_group_total=1, + duplicate_draft_total=2, + generated_at=NOW, + groups=[ + KnowledgeReviewDraftDedupeGroup( + governance_event_id="event-001", + canonical_entry_id="km-canonical", + canonical_title="KM healthcheck review draft - event", + preferred_source="dispatch_context", + duplicate_entry_ids=["km-dup-1", "km-dup-2"], + duplicate_count=2, + total_entries=3, + suggested_action="owner_review_canonical_then_archive_duplicates", + owner_action="review_canonical_and_archive_duplicate_drafts", + writes_on_read=False, + can_archive_without_owner_approval=False, + ) + ], + ) + captured: dict = {} + + async def mock_query(**kwargs): + captured.update(kwargs) + return fake + + with patch("src.api.v1.ai_governance.query_km_review_draft_dedupe", new=mock_query): + r = client.get("/api/v1/ai/governance/km-review-drafts/dedupe?limit=50") + + assert r.status_code == 200 + assert captured["limit"] == 50 + data = r.json() + assert data["duplicate_draft_total"] == 2 + assert data["groups"][0]["canonical_entry_id"] == "km-canonical" + assert data["groups"][0]["writes_on_read"] is False + assert data["groups"][0]["can_archive_without_owner_approval"] is False + + def test_governance_event_tag_extraction(self): + assert _extract_governance_event_id_from_tags([ + "agent:Hermes", + "governance_event:event-001", + ]) == "event-001" + assert _extract_governance_event_id_from_tags(["agent:Hermes"]) is None + + def test_build_dedupe_groups_prefers_dispatch_context_draft(self): + """若 dispatch context 指出 canonical draft,dedupe plan 應以它為準。""" + rows = [ + { + "id": "km-latest", + "title": "latest draft", + "tags": ["governance_event:event-001"], + "updated_at": datetime(2026, 5, 2, 14, 0, tzinfo=TAIPEI), + }, + { + "id": "km-canonical", + "title": "canonical draft", + "tags": ["governance_event:event-001"], + "updated_at": datetime(2026, 5, 2, 13, 0, tzinfo=TAIPEI), + }, + { + "id": "km-other", + "title": "other event draft", + "tags": ["governance_event:event-002"], + "updated_at": datetime(2026, 5, 2, 12, 0, tzinfo=TAIPEI), + }, + ] + + groups = _build_km_review_draft_dedupe_groups( + rows, + {"event-001": "km-canonical"}, + ) + + first = groups[0] + assert first.governance_event_id == "event-001" + assert first.canonical_entry_id == "km-canonical" + assert first.preferred_source == "dispatch_context" + assert first.duplicate_entry_ids == ["km-latest"] + assert first.writes_on_read is False + + # ============================================================================= # 4. summary endpoint compliance_rate # ============================================================================= diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index eabd5ac94..9841efe4d 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -1911,6 +1911,14 @@ "draftsEmpty": "No Hermes KM healthcheck review draft is currently present.", "draftSectionTitle": "KM draft dedupe view", "draftGroup": "Drafts for this event: {count}; duplicates: {duplicates}", + "archiveProposal": "Archive candidates: {count} duplicate drafts", + "ownerAction": "Owner action: {action}", + "readOnlyPlan": "Writes on read: {writes}; archive blocked before review: {blocked}", + "ownerActions": { + "owner_review_canonical_then_archive_duplicates": "Review the canonical draft, then archive duplicates", + "review_canonical_and_archive_duplicate_drafts": "Review canonical and archive duplicate drafts", + "unknown": "Owner action pending" + }, "statuses": { "pending": "Pending", "dispatched": "Dispatched", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 239860bf9..ab3d08dd7 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -1912,6 +1912,14 @@ "draftsEmpty": "目前沒有 Hermes KM healthcheck review 草稿。", "draftSectionTitle": "KM 草稿去重視圖", "draftGroup": "同事件草稿 {count} 份;重複 {duplicates} 份", + "archiveProposal": "封存候選:{count} 份重複草稿", + "ownerAction": "Owner 動作:{action}", + "readOnlyPlan": "讀取不寫入:{writes};未審核不封存:{blocked}", + "ownerActions": { + "owner_review_canonical_then_archive_duplicates": "審核 canonical 草稿後封存 duplicates", + "review_canonical_and_archive_duplicate_drafts": "審核 canonical 並封存重複草稿", + "unknown": "待補 owner 動作" + }, "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 1b9ae0010..3458e630d 100644 --- a/apps/web/src/app/[locale]/awooop/work-items/page.tsx +++ b/apps/web/src/app/[locale]/awooop/work-items/page.tsx @@ -250,6 +250,30 @@ type KnowledgeReviewDraftGroup = { duplicateCount: number; }; +type KnowledgeReviewDraftDedupeGroup = { + governance_event_id: string; + canonical_entry_id: string; + canonical_title: string; + canonical_updated_at?: string | null; + preferred_source: "dispatch_context" | "latest_review_draft"; + duplicate_entry_ids: string[]; + duplicate_count: number; + total_entries: number; + suggested_action: string; + owner_action: string; + writes_on_read: boolean; + can_archive_without_owner_approval: boolean; +}; + +type KnowledgeReviewDraftDedupeResponse = { + schema_version?: string; + total_review_drafts: number; + event_group_total: number; + duplicate_draft_total: number; + groups: KnowledgeReviewDraftDedupeGroup[]; + generated_at?: string | null; +}; + type DriftFingerprintState = { schema_version?: string; namespace?: string; @@ -331,6 +355,7 @@ type Telemetry = { governanceQueue: GovernanceQueueResponse | null; governanceKnowledgeQueue: GovernanceQueueResponse | null; knowledgeReviewDrafts: KnowledgeListResponse | null; + knowledgeReviewDedupe: KnowledgeReviewDraftDedupeResponse | null; channelEvents: RecentEventsResponse | null; eventRecurrence: RecurrenceResponse | null; slo: SloResponse | null; @@ -698,6 +723,23 @@ function draftGroupForQueueItem( return groups.find((group) => group.eventId === item.governance_event_id) ?? null; } +function draftDedupeGroupForQueueItem( + item: GovernanceQueueItem, + groups: KnowledgeReviewDraftDedupeGroup[] +) { + return groups.find((group) => group.governance_event_id === item.governance_event_id) ?? null; +} + +function kmDedupeActionKey(action?: string | null) { + if ( + action === "owner_review_canonical_then_archive_duplicates" || + action === "review_canonical_and_archive_duplicate_drafts" + ) { + return action; + } + return "unknown"; +} + function buildWorkItems( telemetry: Telemetry, t: ReturnType @@ -717,6 +759,8 @@ function buildWorkItems( (sum, group) => sum + group.duplicateCount, 0 ); + const knowledgeDedupeDuplicates = + telemetry.knowledgeReviewDedupe?.duplicate_draft_total ?? knowledgeDuplicateDrafts; const remediationQueue = telemetry.slo?.adr100?.verification_coverage?.remediation_queue; const remediationTotal = remediationQueue?.total ?? 0; const remediationReadyForAi = remediationQueue?.ready_for_ai ?? 0; @@ -939,8 +983,10 @@ function buildWorkItems( action: latestKnowledgeDispatch.next_action ?? latestKnowledgeDispatch.proposed_action ?? "--", }), t("evidence.knowledgeDrafts", { - drafts: telemetry.knowledgeReviewDrafts?.total ?? 0, - duplicates: knowledgeDuplicateDrafts, + drafts: telemetry.knowledgeReviewDedupe?.total_review_drafts + ?? telemetry.knowledgeReviewDrafts?.total + ?? 0, + duplicates: knowledgeDedupeDuplicates, }), ] : [t("evidence.knowledgeEmpty")], @@ -1381,14 +1427,21 @@ function RecurrenceWorkQueuePanel({ function KnowledgeGovernancePanel({ queue, reviewDrafts, + dedupe, }: { queue: GovernanceQueueResponse | null; reviewDrafts: KnowledgeListResponse | null; + dedupe: KnowledgeReviewDraftDedupeResponse | 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 dedupeGroups = dedupe?.groups ?? []; + const duplicateDraftCount = ( + dedupe?.duplicate_draft_total + ?? draftGroups.reduce((sum, group) => sum + group.duplicateCount, 0) + ); + const draftTotal = dedupe?.total_review_drafts ?? reviewDrafts?.total ?? 0; const activeCount = items.filter((item) => ["pending", "dispatched", "executing"].includes(item.dispatch_status) ).length; @@ -1417,7 +1470,7 @@ function KnowledgeGovernancePanel({ {t("review", { count: reviewCount })} - {t("drafts", { count: reviewDrafts?.total ?? 0 })} + {t("drafts", { count: draftTotal })} {t("duplicates", { count: duplicateDraftCount })} @@ -1444,9 +1497,10 @@ function KnowledgeGovernancePanel({ const stageKey = governanceKmStageKey(item.workflow_stage); const steps = item.workflow_steps ?? []; const draftGroup = draftGroupForQueueItem(item, draftGroups); + const dedupeGroup = draftDedupeGroupForQueueItem(item, dedupeGroups); const draftId = item.kb_draft_entry_id ?? draftGroup?.canonical.id ?? null; const workerStatus = item.worker_status ?? "--"; - const duplicateCount = draftGroup?.duplicateCount ?? 0; + const duplicateCount = dedupeGroup?.duplicate_count ?? draftGroup?.duplicateCount ?? 0; return (
@@ -1507,10 +1561,65 @@ function KnowledgeGovernancePanel({ )} - {reviewDrafts === null ? ( + {dedupe === null && reviewDrafts === null ? (
{t("draftsUnavailable")}
+ ) : dedupeGroups.length > 0 ? ( +
+
+
+
+ {dedupeGroups.slice(0, 4).map((group) => ( +
+
+
+

+ {group.governance_event_id} +

+

{group.canonical_title}

+
+ + {group.canonical_entry_id} + +
+
+

+ {t("draftGroup", { + count: group.total_entries, + duplicates: group.duplicate_count, + })} +

+

+ {t("archiveProposal", { + count: group.duplicate_count, + })} +

+

+ {t("ownerAction", { + action: t( + `ownerActions.${kmDedupeActionKey(group.owner_action)}` as never + ), + })} +

+

+ {t("readOnlyPlan", { + writes: String(group.writes_on_read), + blocked: String(!group.can_archive_without_owner_approval), + })} +

+
+
+ ))} +
+
) : draftGroups.length > 0 ? (
@@ -1783,6 +1892,7 @@ export default function AwoooPWorkItemsPage() { governanceQueue: null, governanceKnowledgeQueue: null, knowledgeReviewDrafts: null, + knowledgeReviewDedupe: null, channelEvents: null, eventRecurrence: null, slo: null, @@ -1801,6 +1911,7 @@ export default function AwoooPWorkItemsPage() { 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 knowledgeReviewDedupeUrl = `${API_BASE}/api/v1/ai/governance/km-review-drafts/dedupe?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`; @@ -1813,6 +1924,7 @@ export default function AwoooPWorkItemsPage() { governanceQueue, governanceKnowledgeQueue, knowledgeReviewDrafts, + knowledgeReviewDedupe, channelEvents, eventRecurrence, slo, @@ -1824,6 +1936,7 @@ export default function AwoooPWorkItemsPage() { fetchJson(governanceQueueUrl), fetchJson(governanceKnowledgeQueueUrl), fetchJson(knowledgeReviewDraftsUrl), + fetchJson(knowledgeReviewDedupeUrl), fetchJson(channelEventsUrl), fetchJson(recurrenceUrl), fetchJson(sloUrl), @@ -1852,6 +1965,7 @@ export default function AwoooPWorkItemsPage() { governanceQueue, governanceKnowledgeQueue, knowledgeReviewDrafts, + knowledgeReviewDedupe, channelEvents, eventRecurrence, slo, @@ -1975,6 +2089,7 @@ export default function AwoooPWorkItemsPage() { ` tag 分組。 + - 優先使用 dispatch `decision_context.workflow.kb_draft_entry_id` / `worker_result.km_draft_entry_id` 作 canonical draft;沒有 dispatch 指標時才 fallback 到最新 review draft。 + - 回傳 `duplicate_entry_ids`、`duplicate_count`、`owner_action`、`suggested_action`。 + - 明確標示 `writes_on_read=false`、`can_archive_without_owner_approval=false`。 +- `/awooop/work-items` 的 KM 草稿去重視圖改優先使用後端 dedupe plan: + - header 的 draft / duplicate count 由 dedupe endpoint 提供。 + - group card 顯示 canonical entry、封存候選數、owner action、read-only 安全邊界。 + - knowledge list API 仍保留作 fallback。 + +**Local verification**: + +```text +python3 -m py_compile apps/api/src/models/governance.py apps/api/src/api/v1/ai_governance.py apps/api/src/services/governance_query_service.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 + -> 37 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 +git diff --check + -> pass +``` + +**待 production deploy / smoke**: + +- 推 Gitea main 後,確認 dedupe endpoint 回 `schema_version=km_review_draft_dedupe_v1`、`duplicate_draft_total=67` 左右、`writes_on_read=false`。 +- 確認 Work Items 顯示封存候選 / owner action / read-only 安全邊界,且仍無 console/page error。 + +**目前整體進度**: + +- AwoooP 告警可觀測鏈:約 99.1%。 +- 低風險自動修復閉環:約 95%。 +- 前端 AI 自動化管理介面同步:約 96.8%。 +- 治理告警可讀性 / 可處置性:約 97.3%。 +- AI Agent ownership 可追溯性:約 95.8%。 +- KM healthcheck 派工可追蹤性:約 99%。 +- Hermes KB growth 草稿 / owner review 閉環:約 97%。 +- 完整 AI 自動化管理產品化:約 94.9%。 + ## 2026-05-19|T91 KM review drafts 去重視圖與 Work Items 接線 **觸發**: