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

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

View File

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