feat(governance): bind km archive confirm to dry-run fingerprint
All checks were successful
Code Review / ai-code-review (push) Successful in 14s
Type Sync Check / check-type-sync (push) Successful in 31s
CD Pipeline / tests (push) Successful in 4m8s
CD Pipeline / build-and-deploy (push) Successful in 4m48s
CD Pipeline / post-deploy-checks (push) Successful in 2m13s
All checks were successful
Code Review / ai-code-review (push) Successful in 14s
Type Sync Check / check-type-sync (push) Successful in 31s
CD Pipeline / tests (push) Successful in 4m8s
CD Pipeline / build-and-deploy (push) Successful in 4m48s
CD Pipeline / post-deploy-checks (push) Successful in 2m13s
This commit is contained in:
@@ -152,6 +152,11 @@ class KnowledgeReviewDraftArchiveRequest(BaseModel):
|
||||
owner: str = Field(default="operator_console", min_length=1, max_length=100)
|
||||
owner_approved: bool = False
|
||||
dry_run: bool = False
|
||||
dry_run_plan_fingerprint: str | None = Field(
|
||||
default=None,
|
||||
max_length=80,
|
||||
description="Dry-run response fingerprint that must be echoed before a write.",
|
||||
)
|
||||
|
||||
|
||||
class KnowledgeReviewDraftStaleRatioSnapshot(BaseModel):
|
||||
@@ -185,6 +190,7 @@ class KnowledgeReviewDraftArchiveResponse(BaseModel):
|
||||
"not_requested",
|
||||
] = "not_requested"
|
||||
stale_ratio_recheck_dispatch_id: str | None = None
|
||||
dry_run_plan_fingerprint: str | None = None
|
||||
next_action: str = "stale_ratio_recheck"
|
||||
generated_at: datetime
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ Owner-approved operations for Hermes KM healthcheck review drafts.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import timedelta
|
||||
from typing import Any, Literal
|
||||
|
||||
@@ -99,6 +101,15 @@ async def archive_km_review_draft_duplicates(
|
||||
)
|
||||
|
||||
_validate_archive_request_against_plan(group, request, duplicate_ids)
|
||||
dry_run_plan_fingerprint = _build_dry_run_plan_fingerprint(
|
||||
governance_event_id,
|
||||
group,
|
||||
)
|
||||
if not request.dry_run:
|
||||
_validate_dry_run_plan_fingerprint(
|
||||
request.dry_run_plan_fingerprint,
|
||||
dry_run_plan_fingerprint,
|
||||
)
|
||||
|
||||
if request.dry_run:
|
||||
return _build_archive_response(
|
||||
@@ -111,6 +122,7 @@ async def archive_km_review_draft_duplicates(
|
||||
writes_governance_audit=False,
|
||||
stale_ratio_snapshot=await _load_current_km_stale_ratio_snapshot(),
|
||||
stale_ratio_recheck_status="dry_run",
|
||||
dry_run_plan_fingerprint=dry_run_plan_fingerprint,
|
||||
)
|
||||
|
||||
archive_result = await _archive_duplicates_and_write_audit(
|
||||
@@ -131,6 +143,7 @@ async def archive_km_review_draft_duplicates(
|
||||
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"],
|
||||
dry_run_plan_fingerprint=dry_run_plan_fingerprint,
|
||||
)
|
||||
|
||||
|
||||
@@ -164,6 +177,47 @@ def _validate_archive_request_against_plan(
|
||||
)
|
||||
|
||||
|
||||
def _build_dry_run_plan_fingerprint(
|
||||
governance_event_id: str,
|
||||
group: KnowledgeReviewDraftDedupeGroup,
|
||||
) -> str:
|
||||
"""Build a stable fingerprint the owner must echo from dry-run to confirm."""
|
||||
payload = {
|
||||
"schema_version": "km_review_draft_archive_plan_v1",
|
||||
"governance_event_id": governance_event_id,
|
||||
"canonical_entry_id": group.canonical_entry_id,
|
||||
"duplicate_entry_ids": sorted(_unique_ids(group.duplicate_entry_ids)),
|
||||
"owner_action": group.owner_action,
|
||||
"preferred_source": group.preferred_source,
|
||||
"suggested_action": group.suggested_action,
|
||||
"total_entries": group.total_entries,
|
||||
}
|
||||
encoded = json.dumps(
|
||||
payload,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
return "sha256:" + hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _validate_dry_run_plan_fingerprint(
|
||||
provided: str | None,
|
||||
expected: str,
|
||||
) -> None:
|
||||
"""Require a matching dry-run preview fingerprint before any write."""
|
||||
if not provided:
|
||||
raise KmReviewDraftArchiveError(
|
||||
403,
|
||||
"dry_run_plan_fingerprint from a dry-run preview is required before archiving",
|
||||
)
|
||||
if provided != expected:
|
||||
raise KmReviewDraftArchiveError(
|
||||
409,
|
||||
"dry_run_plan_fingerprint does not match the latest dedupe plan",
|
||||
)
|
||||
|
||||
|
||||
async def _load_already_archived_duplicate_ids(
|
||||
duplicate_ids: list[str],
|
||||
*,
|
||||
@@ -360,6 +414,7 @@ def _build_archive_audit_context(
|
||||
"canonical_entry_id": request.canonical_entry_id,
|
||||
"archived_entry_ids": archived_ids,
|
||||
"archived_count": len(archived_ids),
|
||||
"dry_run_plan_fingerprint": request.dry_run_plan_fingerprint,
|
||||
"stale_ratio_snapshot": (
|
||||
stale_ratio_snapshot.model_dump() if stale_ratio_snapshot else None
|
||||
),
|
||||
@@ -393,6 +448,7 @@ def _build_archive_response(
|
||||
"not_requested",
|
||||
] = "not_requested",
|
||||
stale_ratio_recheck_dispatch_id: str | None = None,
|
||||
dry_run_plan_fingerprint: str | None = None,
|
||||
) -> KnowledgeReviewDraftArchiveResponse:
|
||||
return KnowledgeReviewDraftArchiveResponse(
|
||||
governance_event_id=governance_event_id,
|
||||
@@ -411,6 +467,9 @@ def _build_archive_response(
|
||||
stale_ratio_snapshot=stale_ratio_snapshot,
|
||||
stale_ratio_recheck_status=stale_ratio_recheck_status,
|
||||
stale_ratio_recheck_dispatch_id=stale_ratio_recheck_dispatch_id,
|
||||
dry_run_plan_fingerprint=(
|
||||
dry_run_plan_fingerprint or request.dry_run_plan_fingerprint
|
||||
),
|
||||
generated_at=now_taipei(),
|
||||
)
|
||||
|
||||
@@ -554,6 +613,7 @@ def _build_stale_ratio_recheck_context(
|
||||
"source_archive_action": "review_canonical_and_archive_duplicate_drafts",
|
||||
"canonical_entry_id": request.canonical_entry_id,
|
||||
"archived_entry_ids": archived_ids,
|
||||
"dry_run_plan_fingerprint": request.dry_run_plan_fingerprint,
|
||||
"stale_ratio_snapshot": stale_ratio_snapshot.model_dump(),
|
||||
},
|
||||
"worker_result": {
|
||||
|
||||
@@ -38,8 +38,10 @@ from src.models.governance import (
|
||||
)
|
||||
from src.services.governance_km_review_service import (
|
||||
KmReviewDraftArchiveError,
|
||||
_build_dry_run_plan_fingerprint,
|
||||
_build_stale_ratio_recheck_context,
|
||||
_validate_archive_request_against_plan,
|
||||
_validate_dry_run_plan_fingerprint,
|
||||
)
|
||||
from src.services.governance_query_service import (
|
||||
_build_km_review_draft_dedupe_groups,
|
||||
@@ -541,12 +543,14 @@ class TestKmReviewDraftDedupe:
|
||||
"owner": "operator_console",
|
||||
"owner_approved": True,
|
||||
"dry_run": False,
|
||||
"dry_run_plan_fingerprint": "sha256:" + "a" * 64,
|
||||
},
|
||||
)
|
||||
|
||||
assert r.status_code == 200
|
||||
assert captured["governance_event_id"] == "event-001"
|
||||
assert captured["request"].owner_approved is True
|
||||
assert captured["request"].dry_run_plan_fingerprint == "sha256:" + "a" * 64
|
||||
data = r.json()
|
||||
assert data["status"] == "archived"
|
||||
assert data["writes_km"] is True
|
||||
@@ -602,6 +606,39 @@ class TestKmReviewDraftDedupe:
|
||||
assert exc.value.status_code == 409
|
||||
assert "latest dedupe plan" in exc.value.detail
|
||||
|
||||
def test_archive_plan_fingerprint_is_required_before_write(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-2", "km-dup-1"],
|
||||
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,
|
||||
)
|
||||
|
||||
fingerprint = _build_dry_run_plan_fingerprint("event-001", group)
|
||||
|
||||
assert fingerprint.startswith("sha256:")
|
||||
assert fingerprint == _build_dry_run_plan_fingerprint("event-001", group)
|
||||
_validate_dry_run_plan_fingerprint(fingerprint, fingerprint)
|
||||
|
||||
with pytest.raises(KmReviewDraftArchiveError) as missing:
|
||||
_validate_dry_run_plan_fingerprint(None, fingerprint)
|
||||
|
||||
assert missing.value.status_code == 403
|
||||
assert "dry_run_plan_fingerprint" in missing.value.detail
|
||||
|
||||
with pytest.raises(KmReviewDraftArchiveError) as mismatch:
|
||||
_validate_dry_run_plan_fingerprint("sha256:" + "0" * 64, fingerprint)
|
||||
|
||||
assert mismatch.value.status_code == 409
|
||||
assert "latest dedupe plan" in mismatch.value.detail
|
||||
|
||||
def test_stale_ratio_recheck_context_is_operator_visible(self):
|
||||
snapshot = KnowledgeReviewDraftStaleRatioSnapshot(
|
||||
stale_count=120,
|
||||
@@ -615,6 +652,7 @@ class TestKmReviewDraftDedupe:
|
||||
duplicate_entry_ids=["km-dup-1", "km-dup-2"],
|
||||
owner="operator_console",
|
||||
owner_approved=True,
|
||||
dry_run_plan_fingerprint="sha256:" + "a" * 64,
|
||||
)
|
||||
|
||||
ctx = _build_stale_ratio_recheck_context(
|
||||
@@ -630,6 +668,7 @@ class TestKmReviewDraftDedupe:
|
||||
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"]["dry_run_plan_fingerprint"] == "sha256:" + "a" * 64
|
||||
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
|
||||
|
||||
@@ -1929,9 +1929,11 @@
|
||||
"failed": "Archive action failed; refresh and verify the latest dedupe plan.",
|
||||
"previewFailed": "Dry-run preview failed; refresh and verify the latest dedupe plan.",
|
||||
"confirmFailed": "Archive confirmation failed; the backend may have detected a changed dedupe plan.",
|
||||
"missingPreviewFingerprint": "Missing dry-run plan fingerprint; run the preview again first.",
|
||||
"requiresOwner": "Run the dry-run preview first, then owner-confirm the archive; the backend rechecks the latest plan.",
|
||||
"previewResult": "Dry run would archive {count}; writes KM: {writesKm}; writes audit: {writesAudit}",
|
||||
"previewNext": "Next: only after owner confirmation will duplicate KM be soft-archived and audit / stale-ratio recheck rows be written.",
|
||||
"planFingerprint": "Plan fingerprint: {fingerprint}",
|
||||
"result": "Archived {archived}; audit dispatch: {audit}",
|
||||
"recheck": "Stale-ratio recheck: {status}; dispatch: {dispatch}",
|
||||
"snapshot": "Current stale {stale} / total {total}; ratio {ratio}; threshold {threshold}",
|
||||
|
||||
@@ -1930,9 +1930,11 @@
|
||||
"failed": "封存動作失敗;請重新整理後確認最新 dedupe plan。",
|
||||
"previewFailed": "乾跑預覽失敗;請重新整理後確認最新 dedupe plan。",
|
||||
"confirmFailed": "確認封存失敗;後端可能偵測到 dedupe plan 已變更。",
|
||||
"missingPreviewFingerprint": "缺少乾跑 plan fingerprint;請先重新執行乾跑預覽。",
|
||||
"requiresOwner": "必須先乾跑預覽,再由 owner 確認封存;後端會重新比對最新 plan。",
|
||||
"previewResult": "乾跑將封存 {count} 份;寫 KM:{writesKm};寫稽核:{writesAudit}",
|
||||
"previewNext": "下一步:owner 確認後才會 soft archive duplicate KM 並寫入 audit / stale ratio 回測。",
|
||||
"planFingerprint": "Plan fingerprint:{fingerprint}",
|
||||
"result": "已封存 {archived} 份;稽核 dispatch:{audit}",
|
||||
"recheck": "Stale ratio 回測:{status};dispatch:{dispatch}",
|
||||
"snapshot": "目前 stale {stale} / total {total};ratio {ratio};門檻 {threshold}",
|
||||
|
||||
@@ -303,6 +303,7 @@ type KnowledgeReviewDraftArchiveResponse = {
|
||||
| "already_active"
|
||||
| "not_requested";
|
||||
stale_ratio_recheck_dispatch_id?: string | null;
|
||||
dry_run_plan_fingerprint?: string | null;
|
||||
next_action: string;
|
||||
generated_at?: string | null;
|
||||
};
|
||||
@@ -1558,6 +1559,22 @@ function KnowledgeGovernancePanel({
|
||||
}, [t]);
|
||||
|
||||
const confirmArchiveDuplicates = useCallback(async (group: KnowledgeReviewDraftDedupeGroup) => {
|
||||
const dryRunPlanFingerprint =
|
||||
archiveActions[group.governance_event_id]?.previewResult?.dry_run_plan_fingerprint;
|
||||
if (!dryRunPlanFingerprint) {
|
||||
setArchiveActions((current) => ({
|
||||
...current,
|
||||
[group.governance_event_id]: {
|
||||
previewLoading: false,
|
||||
confirmLoading: false,
|
||||
previewResult: current[group.governance_event_id]?.previewResult ?? null,
|
||||
result: null,
|
||||
error: t("archiveActions.missingPreviewFingerprint"),
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
setArchiveActions((current) => ({
|
||||
...current,
|
||||
[group.governance_event_id]: {
|
||||
@@ -1576,6 +1593,7 @@ function KnowledgeGovernancePanel({
|
||||
owner: "operator_console",
|
||||
owner_approved: true,
|
||||
dry_run: false,
|
||||
dry_run_plan_fingerprint: dryRunPlanFingerprint,
|
||||
},
|
||||
15000
|
||||
);
|
||||
@@ -1592,7 +1610,7 @@ function KnowledgeGovernancePanel({
|
||||
if (result?.status === "archived" || result?.status === "noop_already_archived") {
|
||||
onArchived();
|
||||
}
|
||||
}, [onArchived, t]);
|
||||
}, [archiveActions, onArchived, t]);
|
||||
|
||||
return (
|
||||
<section className="border border-[#e0ddd4] bg-white">
|
||||
@@ -1723,7 +1741,10 @@ function KnowledgeGovernancePanel({
|
||||
const archiveAction = archiveActions[group.governance_event_id];
|
||||
const previewResult = archiveAction?.previewResult ?? null;
|
||||
const finalResult = archiveAction?.result ?? null;
|
||||
const previewReady = previewResult?.status === "dry_run";
|
||||
const previewReady = (
|
||||
previewResult?.status === "dry_run" &&
|
||||
Boolean(previewResult.dry_run_plan_fingerprint)
|
||||
);
|
||||
const previewKey = groupArchiveStatusKey(previewResult?.status);
|
||||
const finalResultKey = groupArchiveStatusKey(finalResult?.status);
|
||||
return (
|
||||
@@ -1824,6 +1845,11 @@ function KnowledgeGovernancePanel({
|
||||
<p className="mt-1 text-[#5f5b52]">
|
||||
{t("archiveActions.previewNext")}
|
||||
</p>
|
||||
<p className="mt-1 break-all font-mono text-[11px] text-[#5f5b52]">
|
||||
{t("archiveActions.planFingerprint", {
|
||||
fingerprint: previewResult.dry_run_plan_fingerprint ?? "--",
|
||||
})}
|
||||
</p>
|
||||
{previewResult.stale_ratio_snapshot ? (
|
||||
<p className="mt-1 text-[#5f5b52]">
|
||||
{t("archiveActions.snapshot", {
|
||||
|
||||
Reference in New Issue
Block a user