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
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:
@@ -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
|
||||
# =============================================================================
|
||||
|
||||
@@ -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
|
||||
# =============================================================================
|
||||
|
||||
367
apps/api/src/services/governance_km_review_service.py
Normal file
367
apps/api/src/services/governance_km_review_service.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "已派遣",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +1,61 @@
|
||||
## 2026-05-20|T93 KM duplicate drafts owner archive action
|
||||
|
||||
**觸發**:
|
||||
|
||||
- T92 已把 Hermes KM healthcheck review drafts 做成後端 read-only dedupe plan,Work 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 plan;canonical 或 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 全部走 i18n,icon 使用 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-19|T92 KM review drafts owner dedupe plan
|
||||
|
||||
**觸發**:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user