diff --git a/apps/api/src/models/governance.py b/apps/api/src/models/governance.py
index 1370fcbd..cad90ca2 100644
--- a/apps/api/src/models/governance.py
+++ b/apps/api/src/models/governance.py
@@ -154,6 +154,14 @@ class KnowledgeReviewDraftArchiveRequest(BaseModel):
dry_run: bool = False
+class KnowledgeReviewDraftStaleRatioSnapshot(BaseModel):
+ stale_count: int
+ total_count: int
+ stale_ratio: float
+ threshold: float
+ stale_days: int
+
+
class KnowledgeReviewDraftArchiveResponse(BaseModel):
schema_version: str = "km_review_draft_archive_v1"
governance_event_id: str
@@ -169,6 +177,14 @@ class KnowledgeReviewDraftArchiveResponse(BaseModel):
writes_km: bool
writes_governance_audit: bool
audit_dispatch_id: str | None = None
+ stale_ratio_snapshot: KnowledgeReviewDraftStaleRatioSnapshot | None = None
+ stale_ratio_recheck_status: Literal[
+ "dry_run",
+ "completed",
+ "already_active",
+ "not_requested",
+ ] = "not_requested"
+ stale_ratio_recheck_dispatch_id: str | None = None
next_action: str = "stale_ratio_recheck"
generated_at: datetime
diff --git a/apps/api/src/services/governance_km_review_service.py b/apps/api/src/services/governance_km_review_service.py
index 3fd4be67..132bc292 100644
--- a/apps/api/src/services/governance_km_review_service.py
+++ b/apps/api/src/services/governance_km_review_service.py
@@ -13,10 +13,12 @@ Owner-approved operations for Hermes KM healthcheck review drafts.
from __future__ import annotations
+from datetime import timedelta
from typing import Any, Literal
import structlog
-from sqlalchemy import select
+from sqlalchemy import func, select
+from sqlalchemy.ext.asyncio import AsyncSession
from src.db.base import get_db_context
from src.db.models import (
@@ -29,8 +31,10 @@ from src.models.governance import (
KnowledgeReviewDraftArchiveRequest,
KnowledgeReviewDraftArchiveResponse,
KnowledgeReviewDraftDedupeGroup,
+ KnowledgeReviewDraftStaleRatioSnapshot,
)
from src.models.knowledge import EntryStatus, EntryType
+from src.services.governance_agent import KM_STALE_DAYS, KM_STALE_RATIO
from src.services.governance_query_service import query_km_review_draft_dedupe
from src.utils.timezone import now_taipei
@@ -105,9 +109,11 @@ async def archive_km_review_draft_duplicates(
would_archive_entry_ids=duplicate_ids,
writes_km=False,
writes_governance_audit=False,
+ stale_ratio_snapshot=await _load_current_km_stale_ratio_snapshot(),
+ stale_ratio_recheck_status="dry_run",
)
- archived_ids, audit_dispatch_id = await _archive_duplicates_and_write_audit(
+ archive_result = await _archive_duplicates_and_write_audit(
governance_event_id=governance_event_id,
request=request,
duplicate_ids=duplicate_ids,
@@ -118,10 +124,13 @@ async def archive_km_review_draft_duplicates(
request=request,
duplicate_ids=duplicate_ids,
status="archived",
- archived_entry_ids=archived_ids,
- writes_km=bool(archived_ids),
+ archived_entry_ids=archive_result["archived_ids"],
+ writes_km=bool(archive_result["archived_ids"]),
writes_governance_audit=True,
- audit_dispatch_id=audit_dispatch_id,
+ audit_dispatch_id=archive_result["audit_dispatch_id"],
+ stale_ratio_snapshot=archive_result["stale_ratio_snapshot"],
+ stale_ratio_recheck_status=archive_result["recheck_status"],
+ stale_ratio_recheck_dispatch_id=archive_result["recheck_dispatch_id"],
)
@@ -190,7 +199,7 @@ async def _archive_duplicates_and_write_audit(
governance_event_id: str,
request: KnowledgeReviewDraftArchiveRequest,
duplicate_ids: list[str],
-) -> tuple[list[str], str]:
+) -> dict[str, Any]:
"""Soft-archive duplicate rows and append a terminal audit dispatch."""
now = now_taipei()
async with get_db_context() as db:
@@ -232,6 +241,16 @@ async def _archive_duplicates_and_write_audit(
record.updated_at = now
archived_ids.append(entry_id)
+ await db.flush()
+ stale_ratio_snapshot = await _compute_km_stale_ratio_snapshot(db)
+ recheck_dispatch_id, recheck_status = await _ensure_stale_ratio_recheck_dispatch(
+ db,
+ governance_event_id=governance_event_id,
+ request=request,
+ archived_ids=archived_ids,
+ stale_ratio_snapshot=stale_ratio_snapshot,
+ )
+
audit = GovernanceRemediationDispatch(
id=generate_uuid(),
governance_event_id=governance_event_id,
@@ -241,6 +260,9 @@ async def _archive_duplicates_and_write_audit(
governance_event_id=governance_event_id,
request=request,
archived_ids=archived_ids,
+ stale_ratio_snapshot=stale_ratio_snapshot,
+ recheck_dispatch_id=recheck_dispatch_id,
+ recheck_status=recheck_status,
),
executor_type="hermes_km_review_dedupe_owner_archive",
attempt_count=0,
@@ -259,8 +281,16 @@ async def _archive_duplicates_and_write_audit(
canonical_entry_id=request.canonical_entry_id,
duplicate_count=len(archived_ids),
audit_dispatch_id=audit.id,
+ recheck_dispatch_id=recheck_dispatch_id,
+ recheck_status=recheck_status,
)
- return archived_ids, str(audit.id)
+ return {
+ "archived_ids": archived_ids,
+ "audit_dispatch_id": str(audit.id),
+ "stale_ratio_snapshot": stale_ratio_snapshot,
+ "recheck_dispatch_id": recheck_dispatch_id,
+ "recheck_status": recheck_status,
+ }
def _is_archive_candidate(
@@ -303,6 +333,9 @@ def _build_archive_audit_context(
governance_event_id: str,
request: KnowledgeReviewDraftArchiveRequest,
archived_ids: list[str],
+ stale_ratio_snapshot: KnowledgeReviewDraftStaleRatioSnapshot | None = None,
+ recheck_dispatch_id: str | None = None,
+ recheck_status: str = "not_requested",
) -> dict[str, Any]:
return {
"schema_version": "km_review_draft_archive_audit_v1",
@@ -327,6 +360,14 @@ def _build_archive_audit_context(
"canonical_entry_id": request.canonical_entry_id,
"archived_entry_ids": archived_ids,
"archived_count": len(archived_ids),
+ "stale_ratio_snapshot": (
+ stale_ratio_snapshot.model_dump() if stale_ratio_snapshot else None
+ ),
+ "stale_ratio_recheck": {
+ "status": recheck_status,
+ "dispatch_id": recheck_dispatch_id,
+ "executor_type": "hermes_km_stale_ratio_recheck",
+ },
"dry_run": request.dry_run,
"owner_approved": request.owner_approved,
}
@@ -344,6 +385,14 @@ def _build_archive_response(
writes_km: bool,
writes_governance_audit: bool,
audit_dispatch_id: str | None = None,
+ stale_ratio_snapshot: KnowledgeReviewDraftStaleRatioSnapshot | None = None,
+ stale_ratio_recheck_status: Literal[
+ "dry_run",
+ "completed",
+ "already_active",
+ "not_requested",
+ ] = "not_requested",
+ stale_ratio_recheck_dispatch_id: str | None = None,
) -> KnowledgeReviewDraftArchiveResponse:
return KnowledgeReviewDraftArchiveResponse(
governance_event_id=governance_event_id,
@@ -359,9 +408,160 @@ def _build_archive_response(
writes_km=writes_km,
writes_governance_audit=writes_governance_audit,
audit_dispatch_id=audit_dispatch_id,
+ stale_ratio_snapshot=stale_ratio_snapshot,
+ stale_ratio_recheck_status=stale_ratio_recheck_status,
+ stale_ratio_recheck_dispatch_id=stale_ratio_recheck_dispatch_id,
generated_at=now_taipei(),
)
def _enum_value(value: Any) -> str:
return str(value.value if hasattr(value, "value") else value)
+
+
+async def _load_current_km_stale_ratio_snapshot() -> KnowledgeReviewDraftStaleRatioSnapshot:
+ async with get_db_context() as db:
+ return await _compute_km_stale_ratio_snapshot(db)
+
+
+async def _compute_km_stale_ratio_snapshot(
+ db: AsyncSession,
+) -> KnowledgeReviewDraftStaleRatioSnapshot:
+ """Use the same KM stale definition as GovernanceAgent.check_knowledge_degradation."""
+ stale_cutoff = now_taipei() - timedelta(days=KM_STALE_DAYS)
+ total_result = await db.execute(
+ select(func.count()).select_from(KnowledgeEntryRecord).where(
+ KnowledgeEntryRecord.status != EntryStatus.ARCHIVED,
+ )
+ )
+ total = int(total_result.scalar() or 0)
+
+ stale_result = await db.execute(
+ select(func.count()).select_from(KnowledgeEntryRecord).where(
+ KnowledgeEntryRecord.status != EntryStatus.ARCHIVED,
+ KnowledgeEntryRecord.updated_at < stale_cutoff,
+ )
+ )
+ stale = int(stale_result.scalar() or 0)
+ ratio = round(stale / total, 3) if total > 0 else 0.0
+ return KnowledgeReviewDraftStaleRatioSnapshot(
+ stale_count=stale,
+ total_count=total,
+ stale_ratio=ratio,
+ threshold=KM_STALE_RATIO,
+ stale_days=KM_STALE_DAYS,
+ )
+
+
+async def _ensure_stale_ratio_recheck_dispatch(
+ db: AsyncSession,
+ *,
+ governance_event_id: str,
+ request: KnowledgeReviewDraftArchiveRequest,
+ archived_ids: list[str],
+ stale_ratio_snapshot: KnowledgeReviewDraftStaleRatioSnapshot,
+) -> tuple[str | None, Literal["completed", "already_active"]]:
+ """Record the post-archive recheck unless another active dispatch owns the event."""
+ active_result = await db.execute(
+ select(GovernanceRemediationDispatch)
+ .where(
+ GovernanceRemediationDispatch.governance_event_id == governance_event_id,
+ GovernanceRemediationDispatch.dispatch_status.in_([
+ "pending",
+ "dispatched",
+ "executing",
+ ]),
+ )
+ .order_by(GovernanceRemediationDispatch.dispatched_at.desc())
+ .limit(1)
+ )
+ active = active_result.scalar_one_or_none()
+ if active is not None:
+ return str(active.id), "already_active"
+
+ recheck = GovernanceRemediationDispatch(
+ id=generate_uuid(),
+ governance_event_id=governance_event_id,
+ event_type="knowledge_degradation",
+ dispatch_status="succeeded",
+ decision_context=_build_stale_ratio_recheck_context(
+ governance_event_id=governance_event_id,
+ request=request,
+ archived_ids=archived_ids,
+ stale_ratio_snapshot=stale_ratio_snapshot,
+ ),
+ executor_type="hermes_km_stale_ratio_recheck",
+ 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(recheck)
+ await db.flush()
+ return str(recheck.id), "completed"
+
+
+def _build_stale_ratio_recheck_context(
+ *,
+ governance_event_id: str,
+ request: KnowledgeReviewDraftArchiveRequest,
+ archived_ids: list[str],
+ stale_ratio_snapshot: KnowledgeReviewDraftStaleRatioSnapshot,
+) -> dict[str, Any]:
+ return {
+ "version": "v1",
+ "trigger_source": "km_review_dedupe_archive",
+ "triggered_metric": "knowledge_degradation",
+ "metric_value": stale_ratio_snapshot.stale_ratio,
+ "threshold": stale_ratio_snapshot.threshold,
+ "suggested_action": "run_stale_ratio_recheck",
+ "next_action": "run_stale_ratio_recheck",
+ "decision_path": "owner_approved_recheck_after_archive",
+ "ownership": {
+ "lead_agent": "Hermes",
+ "support_agents": [
+ "OpenClaw:提供知識劣化風險脈絡,不直接批量改寫 KM。",
+ "ElephantAlpha:read-only 稽核 stale ratio recheck 結果。",
+ ],
+ "human_owner": "KM owner / SRE owner",
+ },
+ "workflow": {
+ "work_item_id": f"governance:knowledge_degradation:{governance_event_id}:stale_ratio_recheck",
+ "work_kind": "km_stale_ratio_recheck",
+ "current_stage": "stale_ratio_recheck",
+ "steps": [
+ "detected",
+ "draft_km_updates",
+ "waiting_owner_review",
+ "owner_approved_duplicate_archive",
+ "stale_ratio_recheck",
+ "km_governance_close_or_continue",
+ ],
+ "stage_by_dispatch_status": {
+ "pending": "stale_ratio_recheck",
+ "dispatched": "stale_ratio_recheck",
+ "executing": "stale_ratio_recheck",
+ "succeeded": "km_governance_rechecked",
+ "failed": "needs_manual_km_triage",
+ "skipped": "needs_manual_km_triage",
+ "cancelled": "cancelled",
+ },
+ "next_action": "run_stale_ratio_recheck",
+ "writes_km_without_approval": False,
+ "writes_km": False,
+ "source_archive_action": "review_canonical_and_archive_duplicate_drafts",
+ "canonical_entry_id": request.canonical_entry_id,
+ "archived_entry_ids": archived_ids,
+ "stale_ratio_snapshot": stale_ratio_snapshot.model_dump(),
+ },
+ "worker_result": {
+ "status": "stale_ratio_rechecked",
+ "canonical_entry_id": request.canonical_entry_id,
+ "archived_count": len(archived_ids),
+ "stale_ratio": stale_ratio_snapshot.stale_ratio,
+ "threshold": stale_ratio_snapshot.threshold,
+ "above_threshold": stale_ratio_snapshot.stale_ratio > stale_ratio_snapshot.threshold,
+ },
+ }
diff --git a/apps/api/tests/test_ai_governance_endpoints.py b/apps/api/tests/test_ai_governance_endpoints.py
index 1a75c0ab..a993bfb3 100644
--- a/apps/api/tests/test_ai_governance_endpoints.py
+++ b/apps/api/tests/test_ai_governance_endpoints.py
@@ -33,10 +33,12 @@ from src.models.governance import (
KnowledgeReviewDraftArchiveResponse,
KnowledgeReviewDraftDedupeGroup,
KnowledgeReviewDraftDedupeResponse,
+ KnowledgeReviewDraftStaleRatioSnapshot,
map_severity,
)
from src.services.governance_km_review_service import (
KmReviewDraftArchiveError,
+ _build_stale_ratio_recheck_context,
_validate_archive_request_against_plan,
)
from src.services.governance_query_service import (
@@ -510,6 +512,15 @@ class TestKmReviewDraftDedupe:
writes_km=True,
writes_governance_audit=True,
audit_dispatch_id="dispatch-audit-001",
+ stale_ratio_snapshot=KnowledgeReviewDraftStaleRatioSnapshot(
+ stale_count=120,
+ total_count=200,
+ stale_ratio=0.6,
+ threshold=0.2,
+ stale_days=7,
+ ),
+ stale_ratio_recheck_status="completed",
+ stale_ratio_recheck_dispatch_id="dispatch-recheck-001",
generated_at=NOW,
)
captured: dict = {}
@@ -541,6 +552,9 @@ class TestKmReviewDraftDedupe:
assert data["writes_km"] is True
assert data["writes_governance_audit"] is True
assert data["audit_dispatch_id"] == "dispatch-audit-001"
+ assert data["stale_ratio_recheck_status"] == "completed"
+ assert data["stale_ratio_recheck_dispatch_id"] == "dispatch-recheck-001"
+ assert data["stale_ratio_snapshot"]["stale_ratio"] == pytest.approx(0.6)
def test_archive_endpoint_maps_validation_error_to_http(self, client):
async def mock_archive(**kwargs):
@@ -588,6 +602,38 @@ class TestKmReviewDraftDedupe:
assert exc.value.status_code == 409
assert "latest dedupe plan" in exc.value.detail
+ def test_stale_ratio_recheck_context_is_operator_visible(self):
+ snapshot = KnowledgeReviewDraftStaleRatioSnapshot(
+ stale_count=120,
+ total_count=200,
+ stale_ratio=0.6,
+ threshold=0.2,
+ stale_days=7,
+ )
+ request = KnowledgeReviewDraftArchiveRequest(
+ canonical_entry_id="km-canonical",
+ duplicate_entry_ids=["km-dup-1", "km-dup-2"],
+ owner="operator_console",
+ owner_approved=True,
+ )
+
+ ctx = _build_stale_ratio_recheck_context(
+ governance_event_id="event-001",
+ request=request,
+ archived_ids=["km-dup-1", "km-dup-2"],
+ stale_ratio_snapshot=snapshot,
+ )
+
+ assert ctx["next_action"] == "run_stale_ratio_recheck"
+ assert ctx["decision_path"] == "owner_approved_recheck_after_archive"
+ assert ctx["workflow"]["work_kind"] == "km_stale_ratio_recheck"
+ assert ctx["workflow"]["current_stage"] == "stale_ratio_recheck"
+ assert ctx["workflow"]["stage_by_dispatch_status"]["pending"] == "stale_ratio_recheck"
+ assert ctx["workflow"]["writes_km"] is False
+ assert ctx["workflow"]["stale_ratio_snapshot"]["stale_ratio"] == pytest.approx(0.6)
+ assert ctx["worker_result"]["status"] == "stale_ratio_rechecked"
+ assert ctx["worker_result"]["above_threshold"] is True
+
# =============================================================================
# 4. summary endpoint compliance_rate
diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json
index 75239cbd..e58a320a 100644
--- a/apps/web/messages/en.json
+++ b/apps/web/messages/en.json
@@ -1925,11 +1925,20 @@
"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}",
+ "recheck": "Stale-ratio recheck: {status}; dispatch: {dispatch}",
+ "snapshot": "Current stale {stale} / total {total}; ratio {ratio}; threshold {threshold}",
"statuses": {
"dry_run": "Dry run complete",
"archived": "Archived",
"noop_already_archived": "Already archived",
"unknown": "Status pending"
+ },
+ "recheckStatuses": {
+ "dry_run": "Dry run only",
+ "completed": "Completed",
+ "already_active": "Already active",
+ "not_requested": "Not requested",
+ "unknown": "Status pending"
}
},
"statuses": {
@@ -1950,6 +1959,10 @@
"waiting_owner_review": "Waiting owner review",
"km_writeback_after_approval": "KM writeback after approval",
"stale_ratio_recheck": "Stale-ratio recheck",
+ "owner_approved_duplicate_archive": "Owner approved duplicate archive",
+ "km_duplicate_archive_after_owner_approval": "Duplicate archive after owner review",
+ "km_governance_rechecked": "KM governance rechecked",
+ "km_governance_close_or_continue": "Close or continue governance",
"needs_manual_km_triage": "Manual KM triage needed",
"cancelled": "Cancelled",
"queued_for_review": "Queued for governance review",
diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json
index 5757a483..59dda199 100644
--- a/apps/web/messages/zh-TW.json
+++ b/apps/web/messages/zh-TW.json
@@ -1926,11 +1926,20 @@
"failed": "封存動作失敗;請重新整理後確認最新 dedupe plan。",
"requiresOwner": "需要 owner 審核;後端會重新比對最新 plan。",
"result": "已封存 {archived} 份;稽核 dispatch:{audit}",
+ "recheck": "Stale ratio 回測:{status};dispatch:{dispatch}",
+ "snapshot": "目前 stale {stale} / total {total};ratio {ratio};門檻 {threshold}",
"statuses": {
"dry_run": "乾跑完成",
"archived": "封存完成",
"noop_already_archived": "已封存,無需重複處理",
"unknown": "狀態待確認"
+ },
+ "recheckStatuses": {
+ "dry_run": "乾跑未排程",
+ "completed": "已完成回測",
+ "already_active": "已有活躍回測",
+ "not_requested": "尚未建立",
+ "unknown": "狀態待確認"
}
},
"statuses": {
@@ -1951,6 +1960,10 @@
"waiting_owner_review": "等待 owner 審核",
"km_writeback_after_approval": "審核後寫回 KM",
"stale_ratio_recheck": "回測 stale ratio",
+ "owner_approved_duplicate_archive": "Owner 已批准封存重複草稿",
+ "km_duplicate_archive_after_owner_approval": "Owner 審核後封存重複草稿",
+ "km_governance_rechecked": "KM 治理已回測",
+ "km_governance_close_or_continue": "關閉或繼續治理",
"needs_manual_km_triage": "需要人工整理 KM",
"cancelled": "已取消",
"queued_for_review": "等待治理審核",
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 08d282f3..7c2763cf 100644
--- a/apps/web/src/app/[locale]/awooop/work-items/page.tsx
+++ b/apps/web/src/app/[locale]/awooop/work-items/page.tsx
@@ -290,6 +290,19 @@ type KnowledgeReviewDraftArchiveResponse = {
writes_km: boolean;
writes_governance_audit: boolean;
audit_dispatch_id?: string | null;
+ stale_ratio_snapshot?: {
+ stale_count: number;
+ total_count: number;
+ stale_ratio: number;
+ threshold: number;
+ stale_days: number;
+ } | null;
+ stale_ratio_recheck_status:
+ | "dry_run"
+ | "completed"
+ | "already_active"
+ | "not_requested";
+ stale_ratio_recheck_dispatch_id?: string | null;
next_action: string;
generated_at?: string | null;
};
@@ -666,6 +679,10 @@ function governanceKmStageKey(stage?: string | null) {
stage === "waiting_owner_review" ||
stage === "km_writeback_after_approval" ||
stage === "stale_ratio_recheck" ||
+ stage === "owner_approved_duplicate_archive" ||
+ stage === "km_duplicate_archive_after_owner_approval" ||
+ stage === "km_governance_rechecked" ||
+ stage === "km_governance_close_or_continue" ||
stage === "needs_manual_km_triage" ||
stage === "cancelled" ||
stage === "queued_for_review" ||
@@ -771,6 +788,18 @@ function groupArchiveStatusKey(status?: string | null) {
return "unknown";
}
+function staleRatioRecheckStatusKey(status?: string | null) {
+ if (
+ status === "dry_run" ||
+ status === "completed" ||
+ status === "already_active" ||
+ status === "not_requested"
+ ) {
+ return status;
+ }
+ return "unknown";
+}
+
function buildWorkItems(
telemetry: Telemetry,
t: ReturnType
+ {t("archiveActions.recheck", { + status: t( + `archiveActions.recheckStatuses.${staleRatioRecheckStatusKey( + archiveAction.result.stale_ratio_recheck_status + )}` as never + ), + dispatch: archiveAction.result.stale_ratio_recheck_dispatch_id ?? "--", + })} +
+ {archiveAction.result.stale_ratio_snapshot ? ( ++ {t("archiveActions.snapshot", { + stale: archiveAction.result.stale_ratio_snapshot.stale_count, + total: archiveAction.result.stale_ratio_snapshot.total_count, + ratio: archiveAction.result.stale_ratio_snapshot.stale_ratio, + threshold: archiveAction.result.stale_ratio_snapshot.threshold, + })} +
+ ) : null} ) : null} diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index 9bb3ef0f..7a3c7e96 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -1,3 +1,66 @@ +## 2026-05-20|T94 KM archive 後 stale ratio recheck trace + +**觸發**: + +- T93 已讓 owner 可在 AwoooP 封存 duplicate KM review drafts,且會寫 archive audit dispatch。 +- 但封存完成後仍需要看見下一段治理驗證:KM stale ratio 是否真的下降、是否仍高於門檻、是否要繼續 owner review。 + +**修正**: + +- `POST /api/v1/ai/governance/km-review-drafts/dedupe/{governance_event_id}/archive-duplicates` response 新增: + - `stale_ratio_snapshot`:沿用 `GovernanceAgent.check_knowledge_degradation` 的定義,計算 non-archived KM 的 `stale_count / total_count / stale_ratio / threshold / stale_days`。 + - `stale_ratio_recheck_status`:`dry_run / completed / already_active / not_requested`。 + - `stale_ratio_recheck_dispatch_id`:archive 成功後的 recheck dispatch id。 +- Dry-run 行為: + - 只計算 snapshot。 + - 不寫 KM、不寫 governance audit、不建立 recheck dispatch。 +- Owner 實際封存成功後: + - 先 soft archive duplicate KM。 + - 立刻計算 stale ratio snapshot。 + - 寫入一筆 terminal `governance_remediation_dispatch`:`executor_type=hermes_km_stale_ratio_recheck`、`dispatch_status=succeeded`、`workflow.current_stage=stale_ratio_recheck`、`worker_result.status=stale_ratio_rechecked`。 + - 再寫 archive audit dispatch,並在 audit context 反向記錄 recheck dispatch id / status。 +- `/awooop/work-items` 的 archive result 顯示: + - stale ratio recheck 狀態與 dispatch id。 + - stale / total / ratio / threshold snapshot。 + - 新增 stage i18n:`owner_approved_duplicate_archive`、`km_duplicate_archive_after_owner_approval`、`km_governance_rechecked`、`km_governance_close_or_continue`。 + +**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_km_review_service.py + -> 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 +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 + -> 60 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 +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 Actions:CD / Code Review / Type Sync 全綠。 +- Production dry-run archive endpoint 應回 `status=dry_run`、`stale_ratio_recheck_status=dry_run`、`stale_ratio_snapshot`,且 `writes_km=false`。 +- Dry-run 前後 `duplicate_draft_total` 必須不變。 +- Work Items production smoke 應看到 stale ratio recheck 文案與 snapshot 欄位,且 page/console error 為 0。 + +**目前整體進度**: + +- AwoooP 告警可觀測鏈:約 99.1%。 +- 低風險自動修復閉環:約 95%。 +- 前端 AI 自動化管理介面同步:約 97.8%。 +- 治理告警可讀性 / 可處置性:約 98.2%。 +- AI Agent ownership 可追溯性:約 96.8%。 +- KM healthcheck 派工可追蹤性:約 99.3%。 +- Hermes KB growth 草稿 / owner review 閉環:約 98.9%。 +- 完整 AI 自動化管理產品化:約 96.2%。 + ## 2026-05-20|T93 KM duplicate drafts owner archive action **觸發**: 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 b63e8ea6..ddaef585 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 @@ -2323,6 +2323,15 @@ Phase 6 完成後 - Production:`c8a995af feat(governance): archive duplicate km review drafts` 已推 Gitea main;deploy marker `3c9404d2 chore(cd): deploy c8a995a [skip ci]`;Gitea runs `1885` CD、`1886` Code Review、`1887` Type Sync 全 success。Production health healthy/prod/mock_mode=false。`GET /ai/governance/km-review-drafts/dedupe?limit=100` 回 `total_review_drafts=90`、`event_group_total=23`、`duplicate_draft_total=67`。Archive endpoint dry-run 對第一組 duplicate plan 回 `status=dry_run`、`would_archive_entry_ids=5`、`writes_km=false`、`writes_governance_audit=false`,dry-run 前後 duplicate total 仍為 67。Work Items Playwright smoke 顯示封存重複草稿按鈕與 owner guard,pageErrors=0 / consoleErrors=0。 - 目前進度更新:治理告警可讀性 / 可處置性約 98.0%;AI Agent ownership 可追溯性約 96.5%;KM healthcheck 派工可追蹤性約 99.2%;Hermes KB growth 草稿 / owner review 閉環約 98.6%;完整 AI 自動化管理產品化約 95.9%。 +**T94 KM archive 後 stale ratio recheck trace(2026-05-20 台北)**: +- 觸發:T93 已讓 owner 可在 AwoooP 封存 duplicate KM review drafts,但封存後仍需要直接看到治理回測:KM stale ratio 是否下降、是否仍高於門檻、是否要繼續 owner review。 +- 修正:archive endpoint response 新增 `stale_ratio_snapshot`、`stale_ratio_recheck_status`、`stale_ratio_recheck_dispatch_id`。snapshot 沿用 `GovernanceAgent.check_knowledge_degradation` 的 non-archived KM + `KM_STALE_DAYS` / `KM_STALE_RATIO` 定義,不另立規則。 +- 寫入行為:dry-run 只計算 snapshot,不寫 KM / audit / recheck dispatch。owner 實際封存成功後,先 soft archive duplicates,再立刻計算 stale ratio,寫一筆 terminal `governance_remediation_dispatch`:`executor_type=hermes_km_stale_ratio_recheck`、`dispatch_status=succeeded`、`workflow.current_stage=stale_ratio_recheck`、`worker_result.status=stale_ratio_rechecked`;archive audit context 反向記錄 recheck dispatch id / status。 +- UI:`/awooop/work-items` 的 archive result 顯示 stale ratio recheck status、dispatch id、stale / total / ratio / threshold snapshot;新增 `owner_approved_duplicate_archive`、`km_duplicate_archive_after_owner_approval`、`km_governance_rechecked`、`km_governance_close_or_continue` stage i18n。 +- 邊界:T94 不讓 AI 自動批量改寫高影響 KM;它只把 owner action 後的治理回測納入 AwoooP 可見 audit trail。production smoke 仍使用 dry-run,不直接改 production KM。 +- Local verification:`py_compile` ok;ruff ok;治理 endpoint / dispatcher / Hermes worker tests `60 passed`;Work Items Next lint ok;`tsc --noEmit` ok;shared-types regenerate 後無 diff;`git diff --check` pass。 +- 目前進度更新:治理告警可讀性 / 可處置性約 98.2%;AI Agent ownership 可追溯性約 96.8%;KM healthcheck 派工可追蹤性約 99.3%;Hermes KB growth 草稿 / owner review 閉環約 98.9%;完整 AI 自動化管理產品化約 96.2%。 + --- ### 2026-04-20 晚 (台北) — C1-C4 全流程串接 — Playbook 鏈路保護(commit de2d34d)