feat(governance): trace km stale ratio rechecks
All checks were successful
Code Review / ai-code-review (push) Successful in 10s
Type Sync Check / check-type-sync (push) Successful in 26s
CD Pipeline / tests (push) Successful in 3m34s
CD Pipeline / build-and-deploy (push) Successful in 4m2s
CD Pipeline / post-deploy-checks (push) Successful in 1m48s

This commit is contained in:
Your Name
2026-05-20 00:52:14 +08:00
parent 5ac315c119
commit d283e65340
8 changed files with 416 additions and 7 deletions

View File

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

View File

@@ -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。",
"ElephantAlpharead-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,
},
}

View File

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

View File

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

View File

@@ -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": "等待治理審核",

View File

@@ -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<typeof useTranslations>
@@ -1717,6 +1746,26 @@ function KnowledgeGovernancePanel({
audit: archiveAction.result.audit_dispatch_id ?? "--",
})}
</p>
<p className="mt-1 text-[#5f5b52]">
{t("archiveActions.recheck", {
status: t(
`archiveActions.recheckStatuses.${staleRatioRecheckStatusKey(
archiveAction.result.stale_ratio_recheck_status
)}` as never
),
dispatch: archiveAction.result.stale_ratio_recheck_dispatch_id ?? "--",
})}
</p>
{archiveAction.result.stale_ratio_snapshot ? (
<p className="mt-1 text-[#5f5b52]">
{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,
})}
</p>
) : null}
</div>
) : null}
</div>