From c8a995aff2a56caceff653f4c87e97aa3e48348a Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 20 May 2026 00:30:17 +0800 Subject: [PATCH] feat(governance): archive duplicate km review drafts --- apps/api/src/api/v1/ai_governance.py | 46 ++- apps/api/src/models/governance.py | 27 ++ .../services/governance_km_review_service.py | 367 ++++++++++++++++++ .../api/tests/test_ai_governance_endpoints.py | 103 ++++- apps/web/messages/en.json | 13 + apps/web/messages/zh-TW.json | 13 + .../app/[locale]/awooop/work-items/page.tsx | 189 +++++++-- docs/LOGBOOK.md | 58 +++ ...-04-15-MASTER-ai-autonomous-flywheel-v2.md | 9 + 9 files changed, 779 insertions(+), 46 deletions(-) create mode 100644 apps/api/src/services/governance_km_review_service.py diff --git a/apps/api/src/api/v1/ai_governance.py b/apps/api/src/api/v1/ai_governance.py index 7e216706..80068027 100644 --- a/apps/api/src/api/v1/ai_governance.py +++ b/apps/api/src/api/v1/ai_governance.py @@ -22,19 +22,25 @@ from datetime import datetime from typing import Annotated import structlog -from fastapi import APIRouter, Query +from fastapi import APIRouter, HTTPException, Query from src.models.governance import ( GovernanceEventsResponse, GovernanceQueueResponse, GovernanceSummaryResponse, + KnowledgeReviewDraftArchiveRequest, + KnowledgeReviewDraftArchiveResponse, KnowledgeReviewDraftDedupeResponse, ) +from src.services.governance_km_review_service import ( + KmReviewDraftArchiveError, + archive_km_review_draft_duplicates, +) from src.services.governance_query_service import ( - query_km_review_draft_dedupe, query_governance_events, query_governance_queue, query_governance_summary, + query_km_review_draft_dedupe, ) logger = structlog.get_logger(__name__) @@ -147,6 +153,42 @@ async def get_km_review_draft_dedupe( return await query_km_review_draft_dedupe(limit=limit) +# ============================================================================= +# POST /api/v1/ai/governance/km-review-drafts/dedupe/{event_id}/archive-duplicates +# ============================================================================= + +@router.post( + "/ai/governance/km-review-drafts/dedupe/{governance_event_id}/archive-duplicates", + response_model=KnowledgeReviewDraftArchiveResponse, +) +async def post_km_review_draft_archive_duplicates( + governance_event_id: str, + request: KnowledgeReviewDraftArchiveRequest, +) -> KnowledgeReviewDraftArchiveResponse: + """ + Owner 審核後封存 Hermes KM healthcheck duplicate review drafts。 + + 這不是 read endpoint:必須明確傳 owner_approved=true,且後端會重新比對 + 最新 dedupe plan。封存為 KnowledgeEntry.status=archived,不刪除資料。 + """ + logger.info( + "km_review_draft_archive_request", + governance_event_id=governance_event_id, + canonical_entry_id=request.canonical_entry_id, + duplicate_count=len(request.duplicate_entry_ids), + owner=request.owner, + dry_run=request.dry_run, + owner_approved=request.owner_approved, + ) + try: + return await archive_km_review_draft_duplicates( + governance_event_id=governance_event_id, + request=request, + ) + except KmReviewDraftArchiveError as exc: + raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc + + # ============================================================================= # GET /api/v1/ai/governance/summary # ============================================================================= diff --git a/apps/api/src/models/governance.py b/apps/api/src/models/governance.py index 6967135d..1370fcbd 100644 --- a/apps/api/src/models/governance.py +++ b/apps/api/src/models/governance.py @@ -146,6 +146,33 @@ class KnowledgeReviewDraftDedupeResponse(BaseModel): generated_at: datetime +class KnowledgeReviewDraftArchiveRequest(BaseModel): + canonical_entry_id: str = Field(min_length=1, max_length=120) + duplicate_entry_ids: list[str] = Field(min_length=1, max_length=100) + owner: str = Field(default="operator_console", min_length=1, max_length=100) + owner_approved: bool = False + dry_run: bool = False + + +class KnowledgeReviewDraftArchiveResponse(BaseModel): + schema_version: str = "km_review_draft_archive_v1" + governance_event_id: str + canonical_entry_id: str + requested_duplicate_entry_ids: list[str] + archived_entry_ids: list[str] = Field(default_factory=list) + skipped_entry_ids: list[str] = Field(default_factory=list) + would_archive_entry_ids: list[str] = Field(default_factory=list) + status: Literal["dry_run", "archived", "noop_already_archived"] + owner: str + owner_approved: bool + dry_run: bool + writes_km: bool + writes_governance_audit: bool + audit_dispatch_id: str | None = None + next_action: str = "stale_ratio_recheck" + generated_at: datetime + + # ============================================================================= # Endpoint 3: summary # ============================================================================= diff --git a/apps/api/src/services/governance_km_review_service.py b/apps/api/src/services/governance_km_review_service.py new file mode 100644 index 00000000..3fd4be67 --- /dev/null +++ b/apps/api/src/services/governance_km_review_service.py @@ -0,0 +1,367 @@ +""" +Governance KM Review Service +============================ + +Owner-approved operations for Hermes KM healthcheck review drafts. + +設計原則: +- read model 仍在 governance_query_service;本檔只處理 owner 審核後的寫入。 +- 封存採 KnowledgeEntry.status=archived,不刪除資料。 +- 寫入前重新比對最新 dedupe plan,避免前端 stale click 封存錯資料。 +- 每次成功封存都寫 governance_remediation_dispatch terminal row 作 audit trail。 +""" + +from __future__ import annotations + +from typing import Any, Literal + +import structlog +from sqlalchemy import select + +from src.db.base import get_db_context +from src.db.models import ( + GovernanceRemediationDispatch, + KnowledgeEntryRecord, + generate_uuid, + taipei_now, +) +from src.models.governance import ( + KnowledgeReviewDraftArchiveRequest, + KnowledgeReviewDraftArchiveResponse, + KnowledgeReviewDraftDedupeGroup, +) +from src.models.knowledge import EntryStatus, EntryType +from src.services.governance_query_service import query_km_review_draft_dedupe +from src.utils.timezone import now_taipei + +logger = structlog.get_logger(__name__) + + +class KmReviewDraftArchiveError(Exception): + """KM review draft archive request failed validation.""" + + def __init__(self, status_code: int, detail: str) -> None: + super().__init__(detail) + self.status_code = status_code + self.detail = detail + + +async def archive_km_review_draft_duplicates( + *, + governance_event_id: str, + request: KnowledgeReviewDraftArchiveRequest, +) -> KnowledgeReviewDraftArchiveResponse: + """Archive duplicate Hermes KM review drafts after explicit owner approval.""" + duplicate_ids = _unique_ids(request.duplicate_entry_ids) + if not duplicate_ids: + raise KmReviewDraftArchiveError(422, "duplicate_entry_ids is required") + if request.canonical_entry_id in duplicate_ids: + raise KmReviewDraftArchiveError(409, "canonical_entry_id cannot be archived") + if not request.dry_run and not request.owner_approved: + raise KmReviewDraftArchiveError( + 403, + "owner_approved=true is required before archiving duplicate KM drafts", + ) + + plan = await query_km_review_draft_dedupe(limit=200) + group = next( + ( + item + for item in plan.groups + if item.governance_event_id == governance_event_id + ), + None, + ) + + if group is None: + already_archived = await _load_already_archived_duplicate_ids( + duplicate_ids, + canonical_entry_id=request.canonical_entry_id, + governance_event_id=governance_event_id, + ) + if set(already_archived) == set(duplicate_ids): + return _build_archive_response( + governance_event_id=governance_event_id, + request=request, + duplicate_ids=duplicate_ids, + status="noop_already_archived", + skipped_entry_ids=duplicate_ids, + writes_km=False, + writes_governance_audit=False, + ) + raise KmReviewDraftArchiveError( + 409, + "latest dedupe plan no longer contains this governance_event_id", + ) + + _validate_archive_request_against_plan(group, request, duplicate_ids) + + if request.dry_run: + return _build_archive_response( + governance_event_id=governance_event_id, + request=request, + duplicate_ids=duplicate_ids, + status="dry_run", + would_archive_entry_ids=duplicate_ids, + writes_km=False, + writes_governance_audit=False, + ) + + archived_ids, audit_dispatch_id = await _archive_duplicates_and_write_audit( + governance_event_id=governance_event_id, + request=request, + duplicate_ids=duplicate_ids, + ) + + return _build_archive_response( + governance_event_id=governance_event_id, + request=request, + duplicate_ids=duplicate_ids, + status="archived", + archived_entry_ids=archived_ids, + writes_km=bool(archived_ids), + writes_governance_audit=True, + audit_dispatch_id=audit_dispatch_id, + ) + + +def _unique_ids(ids: list[str]) -> list[str]: + """Normalize duplicate ids while preserving operator-visible order.""" + result: list[str] = [] + for raw in ids: + value = str(raw).strip() + if value and value not in result: + result.append(value[:120]) + return result + + +def _validate_archive_request_against_plan( + group: KnowledgeReviewDraftDedupeGroup, + request: KnowledgeReviewDraftArchiveRequest, + duplicate_ids: list[str], +) -> None: + """Ensure the owner action is based on the latest read model.""" + if group.canonical_entry_id != request.canonical_entry_id: + raise KmReviewDraftArchiveError( + 409, + "canonical_entry_id does not match the latest dedupe plan", + ) + + current_duplicate_ids = _unique_ids(group.duplicate_entry_ids) + if set(current_duplicate_ids) != set(duplicate_ids): + raise KmReviewDraftArchiveError( + 409, + "duplicate_entry_ids does not match the latest dedupe plan", + ) + + +async def _load_already_archived_duplicate_ids( + duplicate_ids: list[str], + *, + canonical_entry_id: str, + governance_event_id: str, +) -> list[str]: + """Treat double-clicks as idempotent only when prior archive tags prove it.""" + if not duplicate_ids: + return [] + + async with get_db_context() as db: + result = await db.execute( + select(KnowledgeEntryRecord).where( + KnowledgeEntryRecord.id.in_(duplicate_ids) + ) + ) + records = result.scalars().all() + + archived: list[str] = [] + for record in records: + tags = [str(tag) for tag in (record.tags or [])] + if ( + _enum_value(record.status) == EntryStatus.ARCHIVED.value + and f"dedupe_canonical:{canonical_entry_id}" in tags + and f"governance_event:{governance_event_id}" in tags + ): + archived.append(str(record.id)) + return archived + + +async def _archive_duplicates_and_write_audit( + *, + governance_event_id: str, + request: KnowledgeReviewDraftArchiveRequest, + duplicate_ids: list[str], +) -> tuple[list[str], str]: + """Soft-archive duplicate rows and append a terminal audit dispatch.""" + now = now_taipei() + async with get_db_context() as db: + result = await db.execute( + select(KnowledgeEntryRecord).where( + KnowledgeEntryRecord.id.in_(duplicate_ids) + ) + ) + records = result.scalars().all() + records_by_id = {str(record.id): record for record in records} + missing = [entry_id for entry_id in duplicate_ids if entry_id not in records_by_id] + if missing: + raise KmReviewDraftArchiveError( + 409, + f"duplicate KM drafts missing or no longer visible: {', '.join(missing[:3])}", + ) + + archived_ids: list[str] = [] + for entry_id in duplicate_ids: + record = records_by_id[entry_id] + tags = [str(tag) for tag in (record.tags or [])] + if not _is_archive_candidate( + record, + governance_event_id=governance_event_id, + ): + raise KmReviewDraftArchiveError( + 409, + f"KM draft {entry_id} is no longer an archive candidate", + ) + + record.status = EntryStatus.ARCHIVED + record.tags = _append_archive_tags( + tags, + governance_event_id=governance_event_id, + canonical_entry_id=request.canonical_entry_id, + owner=request.owner, + archived_at=now.isoformat(), + ) + record.updated_at = now + archived_ids.append(entry_id) + + audit = GovernanceRemediationDispatch( + id=generate_uuid(), + governance_event_id=governance_event_id, + event_type="knowledge_degradation", + dispatch_status="succeeded", + decision_context=_build_archive_audit_context( + governance_event_id=governance_event_id, + request=request, + archived_ids=archived_ids, + ), + executor_type="hermes_km_review_dedupe_owner_archive", + attempt_count=0, + max_attempts=1, + dispatched_at=taipei_now(), + started_at=taipei_now(), + completed_at=taipei_now(), + created_by=request.owner[:100], + ) + db.add(audit) + await db.flush() + + logger.info( + "km_review_draft_duplicates_archived", + governance_event_id=governance_event_id, + canonical_entry_id=request.canonical_entry_id, + duplicate_count=len(archived_ids), + audit_dispatch_id=audit.id, + ) + return archived_ids, str(audit.id) + + +def _is_archive_candidate( + record: KnowledgeEntryRecord, + *, + governance_event_id: str, +) -> bool: + tags = [str(tag) for tag in (record.tags or [])] + return ( + _enum_value(record.entry_type) == EntryType.AUTO_RUNBOOK.value + and _enum_value(record.status) == EntryStatus.REVIEW.value + and f"governance_event:{governance_event_id}" in tags + ) + + +def _append_archive_tags( + tags: list[str], + *, + governance_event_id: str, + canonical_entry_id: str, + owner: str, + archived_at: str, +) -> list[str]: + additions = [ + "archived_by:km_review_dedupe", + f"governance_event:{governance_event_id}", + f"dedupe_canonical:{canonical_entry_id}", + f"dedupe_owner:{owner[:80]}", + f"archived_at:{archived_at}", + ] + merged = list(tags) + for tag in additions: + if tag not in merged: + merged.append(tag) + return merged + + +def _build_archive_audit_context( + *, + governance_event_id: str, + request: KnowledgeReviewDraftArchiveRequest, + archived_ids: list[str], +) -> dict[str, Any]: + return { + "schema_version": "km_review_draft_archive_audit_v1", + "decision_path": "owner_approved_archive_duplicates", + "workflow": { + "current_stage": "km_duplicate_archive_after_owner_approval", + "steps": [ + "detected", + "queued_kb_healthcheck", + "draft_km_updates", + "waiting_owner_review", + "owner_approved_duplicate_archive", + "stale_ratio_recheck", + ], + "next_action": "stale_ratio_recheck", + "writes_km": True, + "writes_governance_audit": True, + }, + "owner_action": "review_canonical_and_archive_duplicate_drafts", + "owner": request.owner, + "governance_event_id": governance_event_id, + "canonical_entry_id": request.canonical_entry_id, + "archived_entry_ids": archived_ids, + "archived_count": len(archived_ids), + "dry_run": request.dry_run, + "owner_approved": request.owner_approved, + } + + +def _build_archive_response( + *, + governance_event_id: str, + request: KnowledgeReviewDraftArchiveRequest, + duplicate_ids: list[str], + status: Literal["dry_run", "archived", "noop_already_archived"], + archived_entry_ids: list[str] | None = None, + skipped_entry_ids: list[str] | None = None, + would_archive_entry_ids: list[str] | None = None, + writes_km: bool, + writes_governance_audit: bool, + audit_dispatch_id: str | None = None, +) -> KnowledgeReviewDraftArchiveResponse: + return KnowledgeReviewDraftArchiveResponse( + governance_event_id=governance_event_id, + canonical_entry_id=request.canonical_entry_id, + requested_duplicate_entry_ids=duplicate_ids, + archived_entry_ids=archived_entry_ids or [], + skipped_entry_ids=skipped_entry_ids or [], + would_archive_entry_ids=would_archive_entry_ids or [], + status=status, + owner=request.owner, + owner_approved=request.owner_approved, + dry_run=request.dry_run, + writes_km=writes_km, + writes_governance_audit=writes_governance_audit, + audit_dispatch_id=audit_dispatch_id, + generated_at=now_taipei(), + ) + + +def _enum_value(value: Any) -> str: + return str(value.value if hasattr(value, "value") else value) diff --git a/apps/api/tests/test_ai_governance_endpoints.py b/apps/api/tests/test_ai_governance_endpoints.py index 0315b573..1a75c0ab 100644 --- a/apps/api/tests/test_ai_governance_endpoints.py +++ b/apps/api/tests/test_ai_governance_endpoints.py @@ -14,7 +14,7 @@ Unit Tests — AI Governance Endpoints (PR 1) from __future__ import annotations -from datetime import datetime, timezone, timedelta +from datetime import datetime, timedelta, timezone from unittest.mock import AsyncMock, patch import pytest @@ -29,14 +29,20 @@ from src.models.governance import ( GovernanceEventsResponse, GovernanceQueueResponse, GovernanceSummaryResponse, + KnowledgeReviewDraftArchiveRequest, + KnowledgeReviewDraftArchiveResponse, KnowledgeReviewDraftDedupeGroup, KnowledgeReviewDraftDedupeResponse, map_severity, ) +from src.services.governance_km_review_service import ( + KmReviewDraftArchiveError, + _validate_archive_request_against_plan, +) 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_kb_draft_entry_id, _extract_remediation, _extract_worker_status, _merge_dispatch_ids, @@ -256,6 +262,7 @@ class TestEventsReadSideNormalization: def test_events_query_joins_dispatch_table_for_history_buttons(self): """events endpoint 不可只讀 details.dispatch_ids,必須查 dispatch table。""" import inspect + from src.services import governance_query_service source = inspect.getsource(governance_query_service._load_dispatch_ids_for_events) @@ -489,6 +496,98 @@ class TestKmReviewDraftDedupe: assert first.duplicate_entry_ids == ["km-latest"] assert first.writes_on_read is False + def test_archive_endpoint_requires_owner_shape_and_returns_audit_result(self, client): + """Owner 批准後的 archive endpoint 應回傳 KM write 與 audit write 結果。""" + fake = KnowledgeReviewDraftArchiveResponse( + governance_event_id="event-001", + canonical_entry_id="km-canonical", + requested_duplicate_entry_ids=["km-dup-1", "km-dup-2"], + archived_entry_ids=["km-dup-1", "km-dup-2"], + status="archived", + owner="operator_console", + owner_approved=True, + dry_run=False, + writes_km=True, + writes_governance_audit=True, + audit_dispatch_id="dispatch-audit-001", + generated_at=NOW, + ) + captured: dict = {} + + async def mock_archive(**kwargs): + captured.update(kwargs) + return fake + + with patch( + "src.api.v1.ai_governance.archive_km_review_draft_duplicates", + new=mock_archive, + ): + r = client.post( + "/api/v1/ai/governance/km-review-drafts/dedupe/event-001/archive-duplicates", + json={ + "canonical_entry_id": "km-canonical", + "duplicate_entry_ids": ["km-dup-1", "km-dup-2"], + "owner": "operator_console", + "owner_approved": True, + "dry_run": False, + }, + ) + + assert r.status_code == 200 + assert captured["governance_event_id"] == "event-001" + assert captured["request"].owner_approved is True + data = r.json() + assert data["status"] == "archived" + assert data["writes_km"] is True + assert data["writes_governance_audit"] is True + assert data["audit_dispatch_id"] == "dispatch-audit-001" + + def test_archive_endpoint_maps_validation_error_to_http(self, client): + async def mock_archive(**kwargs): + raise KmReviewDraftArchiveError(409, "stale plan") + + with patch( + "src.api.v1.ai_governance.archive_km_review_draft_duplicates", + new=mock_archive, + ): + r = client.post( + "/api/v1/ai/governance/km-review-drafts/dedupe/event-001/archive-duplicates", + json={ + "canonical_entry_id": "km-canonical", + "duplicate_entry_ids": ["km-dup-1"], + "owner_approved": True, + }, + ) + + assert r.status_code == 409 + assert r.json()["detail"] == "stale plan" + + def test_archive_plan_validation_rejects_stale_duplicates(self): + group = KnowledgeReviewDraftDedupeGroup( + governance_event_id="event-001", + canonical_entry_id="km-canonical", + canonical_title="canonical", + 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, + ) + request = KnowledgeReviewDraftArchiveRequest( + canonical_entry_id="km-canonical", + duplicate_entry_ids=["km-dup-1"], + owner_approved=True, + ) + + with pytest.raises(KmReviewDraftArchiveError) as exc: + _validate_archive_request_against_plan(group, request, ["km-dup-1"]) + + assert exc.value.status_code == 409 + assert "latest dedupe plan" in exc.value.detail + # ============================================================================= # 4. summary endpoint compliance_rate diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 9841efe4..75239cbd 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -1919,6 +1919,19 @@ "review_canonical_and_archive_duplicate_drafts": "Review canonical and archive duplicate drafts", "unknown": "Owner action pending" }, + "archiveActions": { + "archive": "Archive duplicate drafts", + "archiving": "Archiving", + "failed": "Archive action failed; refresh and verify the latest dedupe plan.", + "requiresOwner": "Owner review required; backend rechecks the latest plan.", + "result": "Archived {archived}; audit dispatch: {audit}", + "statuses": { + "dry_run": "Dry run complete", + "archived": "Archived", + "noop_already_archived": "Already archived", + "unknown": "Status pending" + } + }, "statuses": { "pending": "Pending", "dispatched": "Dispatched", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index ab3d08dd..5757a483 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -1920,6 +1920,19 @@ "review_canonical_and_archive_duplicate_drafts": "審核 canonical 並封存重複草稿", "unknown": "待補 owner 動作" }, + "archiveActions": { + "archive": "封存重複草稿", + "archiving": "封存中", + "failed": "封存動作失敗;請重新整理後確認最新 dedupe plan。", + "requiresOwner": "需要 owner 審核;後端會重新比對最新 plan。", + "result": "已封存 {archived} 份;稽核 dispatch:{audit}", + "statuses": { + "dry_run": "乾跑完成", + "archived": "封存完成", + "noop_already_archived": "已封存,無需重複處理", + "unknown": "狀態待確認" + } + }, "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 3458e630..08d282f3 100644 --- a/apps/web/src/app/[locale]/awooop/work-items/page.tsx +++ b/apps/web/src/app/[locale]/awooop/work-items/page.tsx @@ -10,6 +10,7 @@ import { useSearchParams } from "next/navigation"; import { useLocale, useTranslations } from "next-intl"; import { Activity, + Archive, ArrowRight, ClipboardList, Database, @@ -274,6 +275,25 @@ type KnowledgeReviewDraftDedupeResponse = { generated_at?: string | null; }; +type KnowledgeReviewDraftArchiveResponse = { + schema_version?: string; + governance_event_id: string; + canonical_entry_id: string; + requested_duplicate_entry_ids: string[]; + archived_entry_ids: string[]; + skipped_entry_ids: string[]; + would_archive_entry_ids: string[]; + status: "dry_run" | "archived" | "noop_already_archived"; + owner: string; + owner_approved: boolean; + dry_run: boolean; + writes_km: boolean; + writes_governance_audit: boolean; + audit_dispatch_id?: string | null; + next_action: string; + generated_at?: string | null; +}; + type DriftFingerprintState = { schema_version?: string; namespace?: string; @@ -740,6 +760,17 @@ function kmDedupeActionKey(action?: string | null) { return "unknown"; } +function groupArchiveStatusKey(status?: string | null) { + if ( + status === "dry_run" || + status === "archived" || + status === "noop_already_archived" + ) { + return status; + } + return "unknown"; +} + function buildWorkItems( telemetry: Telemetry, t: ReturnType @@ -1428,12 +1459,19 @@ function KnowledgeGovernancePanel({ queue, reviewDrafts, dedupe, + onArchived, }: { queue: GovernanceQueueResponse | null; reviewDrafts: KnowledgeListResponse | null; dedupe: KnowledgeReviewDraftDedupeResponse | null; + onArchived: () => void; }) { const t = useTranslations("awooop.workItems.knowledgeGovernance"); + const [archiveActions, setArchiveActions] = useState>({}); const items = queue?.items ?? []; const draftGroups = groupKnowledgeReviewDrafts(reviewDrafts, items); const dedupeGroups = dedupe?.groups ?? []; @@ -1448,6 +1486,34 @@ function KnowledgeGovernancePanel({ const reviewCount = items.filter((item) => item.dispatch_status === "skipped" || item.workflow_stage === "waiting_owner_review" ).length; + const archiveDuplicates = useCallback(async (group: KnowledgeReviewDraftDedupeGroup) => { + setArchiveActions((current) => ({ + ...current, + [group.governance_event_id]: { loading: true, result: null, error: null }, + })); + const result = await postJson( + `${API_BASE}/api/v1/ai/governance/km-review-drafts/dedupe/${encodeURIComponent(group.governance_event_id)}/archive-duplicates`, + { + canonical_entry_id: group.canonical_entry_id, + duplicate_entry_ids: group.duplicate_entry_ids, + owner: "operator_console", + owner_approved: true, + dry_run: false, + }, + 15000 + ); + setArchiveActions((current) => ({ + ...current, + [group.governance_event_id]: { + loading: false, + result, + error: result ? null : t("archiveActions.failed"), + }, + })); + if (result?.status === "archived" || result?.status === "noop_already_archived") { + onArchived(); + } + }, [onArchived, t]); return (
@@ -1574,50 +1640,88 @@ function KnowledgeGovernancePanel({
- {dedupeGroups.slice(0, 4).map((group) => ( -
-
-
-

- {group.governance_event_id} -

-

{group.canonical_title}

+ {dedupeGroups.slice(0, 4).map((group) => { + const archiveAction = archiveActions[group.governance_event_id]; + const resultKey = groupArchiveStatusKey(archiveAction?.result?.status); + return ( +
+
+
+

+ {group.governance_event_id} +

+

{group.canonical_title}

+
+ + {group.canonical_entry_id} +
- - {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), + })} +

+
+
+ + + {t("archiveActions.requiresOwner")} + +
+ {archiveAction?.error ? ( +
+ {archiveAction.error} +
+ ) : null} + {archiveAction?.result ? ( +
+

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

+

+ {t("archiveActions.result", { + archived: archiveAction.result.archived_entry_ids.length, + audit: archiveAction.result.audit_dispatch_id ?? "--", + })} +

+
+ ) : null}
-
-

- {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 ? ( @@ -2090,6 +2194,7 @@ export default function AwoooPWorkItemsPage() { queue={telemetry.governanceKnowledgeQueue} reviewDrafts={telemetry.knowledgeReviewDrafts} dedupe={telemetry.knowledgeReviewDedupe} + onArchived={fetchTelemetry} /> 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_governance_dispatcher.py apps/api/tests/test_hermes_kb_growth_worker.py -q + -> 59 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 +/Users/ogt/awoooi/apps/api/.venv/bin/python -m ruff check apps/api/src/api/v1/ai_governance.py apps/api/src/models/governance.py apps/api/src/services/governance_km_review_service.py apps/api/tests/test_ai_governance_endpoints.py + -> All checks passed +cd apps/api && /Users/ogt/awoooi/apps/api/.venv/bin/python ../../scripts/generate-schemas.py && cd ../../packages/shared-types && pnpm generate:types + -> packages/shared-types 無 diff +git diff --check + -> pass +``` + +**待 production deploy / smoke**: + +- 推 Gitea main 後確認 CD / Code Review / Type Sync / E2E Health 全綠。 +- `GET /api/v1/ai/governance/km-review-drafts/dedupe?limit=100` 應仍回目前 duplicate plan。 +- 對第一組 duplicate plan 呼叫 archive endpoint 的 `dry_run=true`,確認 `status=dry_run`、`would_archive_entry_ids` 不為空、`writes_km=false`。 +- Work Items production smoke 要看到「封存重複草稿」按鈕與 owner action 結果區,且無 console/page error。 + +**目前整體進度**: + +- AwoooP 告警可觀測鏈:約 99.1%。 +- 低風險自動修復閉環:約 95%。 +- 前端 AI 自動化管理介面同步:約 97.4%。 +- 治理告警可讀性 / 可處置性:約 97.8%。 +- AI Agent ownership 可追溯性:約 96.3%。 +- KM healthcheck 派工可追蹤性:約 99%。 +- Hermes KB growth 草稿 / owner review 閉環:約 98.2%。 +- 完整 AI 自動化管理產品化:約 95.6%。 + ## 2026-05-19|T92 KM review drafts owner dedupe plan **觸發**: 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 65bd0fce..4ee840c7 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 @@ -2313,6 +2313,15 @@ Phase 6 完成後 - 邊界:T89 補齊 detail/history read model 的 dispatch id 鏈路,不代表 Hermes KB growth worker 已執行草稿、owner review 或 KM writeback;下一段仍需實作 worker 消費 pending dispatch 並推進狀態。 - 目前進度更新:治理告警可讀性 / 可處置性約 96%;AI Agent ownership 可追溯性約 94.5%;KM healthcheck 派工可追蹤性約 92%;詳情 / 歷史 dispatch read model 約 93%;完整 AI 自動化管理產品化約 92.8%。 +**T93 KM duplicate drafts owner archive action(2026-05-20 台北)**: +- 觸發:T90-T92 已讓 `knowledge_degradation` → Hermes KB growth → KM review draft → dedupe plan 在 Work Items 可見,但 Operator 仍只能看到封存候選,不能在 AwoooP 內完成 owner 審核後的可稽核封存。 +- 修正:新增 owner-approved write API `POST /api/v1/ai/governance/km-review-drafts/dedupe/{governance_event_id}/archive-duplicates`。寫入前重新查最新 dedupe plan,canonical 或 duplicate list 不一致即 `409`;未傳 `owner_approved=true` 即拒絕;實際動作只將 duplicate `KnowledgeEntry.status` 改為 `archived`,不刪除 KM。 +- Audit:成功封存時會新增 terminal `governance_remediation_dispatch` row,`executor_type=hermes_km_review_dedupe_owner_archive`、`dispatch_status=succeeded`,`decision_context.workflow.current_stage=km_duplicate_archive_after_owner_approval`,並記錄 canonical / archived ids / owner / next_action=`stale_ratio_recheck`。 +- UI:`/awooop/work-items` 的 KM 草稿去重視圖新增 Lucide `Archive` owner action button,按下後送 canonical + duplicates + owner approval,成功後重新抓 production telemetry。UI copy 全部走 i18n。 +- 邊界:T93 打通 owner 審核後的 duplicate soft-archive 操作,不代表 AI 可自動封存高影響 KM;高影響知識仍需 owner review,AI 只能產生草稿、dedupe plan 與可稽核建議。 +- Local verification:`py_compile` ok;治理 endpoint / dispatcher / Hermes worker tests `59 passed`;Work Items Next lint ok;`tsc --noEmit` ok;ruff ok;shared-types regenerate 後無 diff;`git diff --check` pass。 +- 目前進度更新:治理告警可讀性 / 可處置性約 97.8%;AI Agent ownership 可追溯性約 96.3%;KM healthcheck 派工可追蹤性約 99%;Hermes KB growth 草稿 / owner review 閉環約 98.2%;完整 AI 自動化管理產品化約 95.6%。 + --- ### 2026-04-20 晚 (台北) — C1-C4 全流程串接 — Playbook 鏈路保護(commit de2d34d)