feat(governance): archive duplicate km review drafts
All checks were successful
Code Review / ai-code-review (push) Successful in 10s
Type Sync Check / check-type-sync (push) Successful in 33s
CD Pipeline / tests (push) Successful in 3m31s
CD Pipeline / build-and-deploy (push) Successful in 4m41s
CD Pipeline / post-deploy-checks (push) Successful in 1m53s

This commit is contained in:
Your Name
2026-05-20 00:30:17 +08:00
parent 101cd42974
commit c8a995aff2
9 changed files with 779 additions and 46 deletions

View File

@@ -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
# =============================================================================

View File

@@ -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
# =============================================================================

View File

@@ -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)

View File

@@ -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

View File

@@ -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",

View File

@@ -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": "已派遣",

View File

@@ -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<typeof useTranslations>
@@ -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<Record<string, {
loading: boolean;
result: KnowledgeReviewDraftArchiveResponse | null;
error: string | null;
}>>({});
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<KnowledgeReviewDraftArchiveResponse>(
`${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 (
<section className="border border-[#e0ddd4] bg-white">
@@ -1574,50 +1640,88 @@ function KnowledgeGovernancePanel({
</h4>
</div>
<div className="grid gap-2 md:grid-cols-2">
{dedupeGroups.slice(0, 4).map((group) => (
<div
key={group.governance_event_id}
className="border border-[#e0ddd4] bg-white px-3 py-2 text-xs text-[#5f5b52]"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate font-mono font-semibold text-[#141413]">
{group.governance_event_id}
</p>
<p className="mt-1 truncate text-[#77736a]">{group.canonical_title}</p>
{dedupeGroups.slice(0, 4).map((group) => {
const archiveAction = archiveActions[group.governance_event_id];
const resultKey = groupArchiveStatusKey(archiveAction?.result?.status);
return (
<div
key={group.governance_event_id}
className="border border-[#e0ddd4] bg-white px-3 py-2 text-xs text-[#5f5b52]"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate font-mono font-semibold text-[#141413]">
{group.governance_event_id}
</p>
<p className="mt-1 truncate text-[#77736a]">{group.canonical_title}</p>
</div>
<span className="shrink-0 border border-[#d8d3c7] bg-[#faf9f3] px-2 py-0.5 font-mono">
{group.canonical_entry_id}
</span>
</div>
<span className="shrink-0 border border-[#d8d3c7] bg-[#faf9f3] px-2 py-0.5 font-mono">
{group.canonical_entry_id}
</span>
<div className="mt-2 grid gap-1 leading-5">
<p>
{t("draftGroup", {
count: group.total_entries,
duplicates: group.duplicate_count,
})}
</p>
<p>
{t("archiveProposal", {
count: group.duplicate_count,
})}
</p>
<p>
{t("ownerAction", {
action: t(
`ownerActions.${kmDedupeActionKey(group.owner_action)}` as never
),
})}
</p>
<p>
{t("readOnlyPlan", {
writes: String(group.writes_on_read),
blocked: String(!group.can_archive_without_owner_approval),
})}
</p>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => archiveDuplicates(group)}
disabled={group.duplicate_count === 0 || archiveAction?.loading}
className="inline-flex items-center gap-1.5 border border-[#d8d3c7] bg-white px-2 py-1 text-xs font-semibold text-[#2e2b26] hover:border-[#9bc7a4] hover:bg-[#f0faf2] hover:text-[#17602a] disabled:cursor-not-allowed disabled:opacity-60"
>
<Archive className="h-3.5 w-3.5" aria-hidden="true" />
{archiveAction?.loading
? t("archiveActions.archiving")
: t("archiveActions.archive")}
</button>
<span className="text-[11px] leading-5 text-[#77736a]">
{t("archiveActions.requiresOwner")}
</span>
</div>
{archiveAction?.error ? (
<div className="mt-2 border border-[#e2a29b] bg-[#fff0ef] px-2 py-1.5 text-[#9f2f25]">
{archiveAction.error}
</div>
) : null}
{archiveAction?.result ? (
<div className="mt-2 border border-[#9bc7a4] bg-[#f0faf2] px-2 py-1.5 text-[#17602a]">
<p className="font-semibold">
{t(`archiveActions.statuses.${resultKey}` as never)}
</p>
<p className="mt-1 text-[#5f5b52]">
{t("archiveActions.result", {
archived: archiveAction.result.archived_entry_ids.length,
audit: archiveAction.result.audit_dispatch_id ?? "--",
})}
</p>
</div>
) : null}
</div>
<div className="mt-2 grid gap-1 leading-5">
<p>
{t("draftGroup", {
count: group.total_entries,
duplicates: group.duplicate_count,
})}
</p>
<p>
{t("archiveProposal", {
count: group.duplicate_count,
})}
</p>
<p>
{t("ownerAction", {
action: t(
`ownerActions.${kmDedupeActionKey(group.owner_action)}` as never
),
})}
</p>
<p>
{t("readOnlyPlan", {
writes: String(group.writes_on_read),
blocked: String(!group.can_archive_without_owner_approval),
})}
</p>
</div>
</div>
))}
);
})}
</div>
</div>
) : draftGroups.length > 0 ? (
@@ -2090,6 +2194,7 @@ export default function AwoooPWorkItemsPage() {
queue={telemetry.governanceKnowledgeQueue}
reviewDrafts={telemetry.knowledgeReviewDrafts}
dedupe={telemetry.knowledgeReviewDedupe}
onArchived={fetchTelemetry}
/>
<DriftFingerprintPanel

View File

@@ -1,3 +1,61 @@
## 2026-05-20T93 KM duplicate drafts owner archive action
**觸發**
- T92 已把 Hermes KM healthcheck review drafts 做成後端 read-only dedupe planWork Items 可看到 canonical / duplicates / owner action。
- 但 Operator 仍只能「看見封存候選」,不能在 AwoooP 內完成 owner 審核後的可稽核封存;舊 duplicate drafts 仍會讓 KM governance 畫面長期顯示髒資料。
**修正**
- 新增 owner-approved write API`POST /api/v1/ai/governance/km-review-drafts/dedupe/{governance_event_id}/archive-duplicates`
- request 必須帶 `canonical_entry_id``duplicate_entry_ids``owner_approved=true`
- 後端會重新查最新 dedupe plancanonical 或 duplicate list 與最新 plan 不一致時回 `409`,避免前端 stale click 封存錯資料。
- 實際寫入只做 `KnowledgeEntry.status=archived` soft archive不刪除 KM。
- duplicate row 會追加 `archived_by:km_review_dedupe``dedupe_canonical:*``dedupe_owner:*``archived_at:*` 等追蹤 tags。
- 成功封存後新增一筆 terminal `governance_remediation_dispatch` audit row`executor_type=hermes_km_review_dedupe_owner_archive``dispatch_status=succeeded`
- double-click / retry 若已由同 canonical 封存過,回 `noop_already_archived`,不重複寫入。
- `/awooop/work-items` 的 KM 草稿去重視圖新增 owner action button
- 按下後送出 canonical + duplicates + owner approval。
- 成功後重新整理 telemetry讓 draft / duplicate count 回到 production truth。
- UI 全部走 i18nicon 使用 Lucide `Archive`,不使用 emoji。
**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 apps/api/src/services/governance_km_review_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_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-19T92 KM review drafts owner dedupe plan
**觸發**

View File

@@ -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 action2026-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 plancanonical 或 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 reviewAI 只能產生草稿、dedupe plan 與可稽核建議。
- Local verification`py_compile` ok治理 endpoint / dispatcher / Hermes worker tests `59 passed`Work Items Next lint ok`tsc --noEmit` okruff okshared-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