feat(governance): batch queue stale km reviews
All checks were successful
CD Pipeline / tests (push) Successful in 5m47s
Code Review / ai-code-review (push) Successful in 11s
Type Sync Check / check-type-sync (push) Successful in 27s
CD Pipeline / build-and-deploy (push) Successful in 4m13s
CD Pipeline / post-deploy-checks (push) Successful in 2m11s

This commit is contained in:
Your Name
2026-05-24 20:47:31 +08:00
parent fb40b8f469
commit 943093a49b
7 changed files with 1078 additions and 1 deletions

View File

@@ -438,6 +438,57 @@ type KnowledgeStaleOwnerReviewAction = {
error: string | null;
};
type KnowledgeStaleOwnerReviewBatchItem = {
entry_id: string;
title: string;
priority_tier: "P0" | "P1" | "P2";
recommended_action: "refresh_with_evidence" | "owner_review" | "archive_or_supersede";
status: "would_queue" | "queued" | "already_queued" | "skipped";
reason?: string | null;
governance_event_id?: string | null;
dispatch_id?: string | null;
workflow_stage: string;
};
type KnowledgeStaleOwnerReviewBatchResponse = {
schema_version?: string;
project_id: string;
status: "dry_run" | "queued" | "noop_already_queued";
owner: string;
owner_note?: string | null;
dry_run: boolean;
priority_tiers: string[];
requested_limit: number;
candidate_count: number;
queued_count: number;
already_queued_count: number;
skipped_count: number;
batch_governance_event_id?: string | null;
batch_dispatch_id?: string | null;
workflow_stage: string;
writes_km: boolean;
writes_governance_audit: boolean;
stale_ratio_snapshot?: {
stale_count: number;
total_count: number;
stale_ratio: number;
threshold: number;
stale_days: number;
} | null;
dry_run_plan_fingerprint?: string | null;
items: KnowledgeStaleOwnerReviewBatchItem[];
next_action: string;
generated_at?: string | null;
};
type KnowledgeStaleOwnerReviewBatchAction = {
previewLoading: boolean;
confirmLoading: boolean;
previewResult: KnowledgeStaleOwnerReviewBatchResponse | null;
result: KnowledgeStaleOwnerReviewBatchResponse | null;
error: string | null;
};
type KnowledgeStaleOwnerReviewCompleteResponse = {
schema_version?: string;
entry_id: string;
@@ -872,6 +923,9 @@ function governanceKmStageKey(stage?: string | null) {
stage === "detected" ||
stage === "ai_analyzed" ||
stage === "draft_km_updates" ||
stage === "batch_owner_review_previewed" ||
stage === "batch_owner_review_queued" ||
stage === "batch_noop_already_queued" ||
stage === "waiting_owner_review" ||
stage === "owner_updates_or_archives_km" ||
stage === "km_writeback_after_approval" ||
@@ -1152,6 +1206,29 @@ function kmStaleReviewStatusKey(value: string | null | undefined) {
}
}
function kmStaleBatchStatusKey(value: string | null | undefined) {
switch (value) {
case "dry_run":
case "queued":
case "noop_already_queued":
return value;
default:
return "unknown";
}
}
function kmStaleBatchItemStatusKey(value: string | null | undefined) {
switch (value) {
case "would_queue":
case "queued":
case "already_queued":
case "skipped":
return value;
default:
return "unknown";
}
}
function kmStaleReviewCompleteStatusKey(value: string | null | undefined) {
switch (value) {
case "dry_run":
@@ -2170,6 +2247,13 @@ function KnowledgeGovernancePanel({
const t = useTranslations("awooop.workItems.knowledgeGovernance");
const [archiveActions, setArchiveActions] = useState<Record<string, KnowledgeReviewDraftArchiveAction>>({});
const [staleReviewActions, setStaleReviewActions] = useState<Record<string, KnowledgeStaleOwnerReviewAction>>({});
const [staleBatchAction, setStaleBatchAction] = useState<KnowledgeStaleOwnerReviewBatchAction>({
previewLoading: false,
confirmLoading: false,
previewResult: null,
result: null,
error: null,
});
const [staleReviewCompletionActions, setStaleReviewCompletionActions] =
useState<Record<string, KnowledgeStaleOwnerReviewCompleteAction>>({});
const items = queue?.items ?? [];
@@ -2275,6 +2359,79 @@ function KnowledgeGovernancePanel({
}
}, [archiveActions, onArchived, t]);
const previewStaleBatchQueue = useCallback(async () => {
setStaleBatchAction((current) => ({
previewLoading: true,
confirmLoading: false,
previewResult: current.previewResult,
result: null,
error: null,
}));
const result = await postJson<KnowledgeStaleOwnerReviewBatchResponse>(
`${API_BASE}/api/v1/ai/governance/km-stale-candidates/batch-queue-review`,
{
project_id: staleCandidates?.project_id ?? "awoooi",
priority_tiers: ["P0", "P1"],
limit: 10,
owner: "operator_console",
owner_note: "p0_p1_stale_km_batch",
dry_run: true,
},
15000
);
setStaleBatchAction({
previewLoading: false,
confirmLoading: false,
previewResult: result,
result: null,
error: result ? null : t("staleCandidates.batchActions.previewFailed"),
});
}, [staleCandidates?.project_id, t]);
const confirmStaleBatchQueue = useCallback(async () => {
const fingerprint = staleBatchAction.previewResult?.dry_run_plan_fingerprint;
if (!fingerprint) {
setStaleBatchAction((current) => ({
previewLoading: false,
confirmLoading: false,
previewResult: current.previewResult,
result: current.result,
error: t("staleCandidates.batchActions.missingPreviewFingerprint"),
}));
return;
}
setStaleBatchAction((current) => ({
previewLoading: false,
confirmLoading: true,
previewResult: current.previewResult,
result: null,
error: null,
}));
const result = await postJson<KnowledgeStaleOwnerReviewBatchResponse>(
`${API_BASE}/api/v1/ai/governance/km-stale-candidates/batch-queue-review`,
{
project_id: staleCandidates?.project_id ?? "awoooi",
priority_tiers: ["P0", "P1"],
limit: 10,
owner: "operator_console",
owner_note: "p0_p1_stale_km_batch",
dry_run: false,
dry_run_plan_fingerprint: fingerprint,
},
15000
);
setStaleBatchAction((current) => ({
previewLoading: false,
confirmLoading: false,
previewResult: current.previewResult,
result,
error: result ? null : t("staleCandidates.batchActions.confirmFailed"),
}));
if (result?.status === "queued" || result?.status === "noop_already_queued") {
onArchived();
}
}, [onArchived, staleBatchAction.previewResult, staleCandidates?.project_id, t]);
const queueStaleOwnerReview = useCallback(async (candidate: KnowledgeStaleCandidate) => {
setStaleReviewActions((current) => ({
...current,
@@ -2419,6 +2576,12 @@ function KnowledgeGovernancePanel({
}
}, [onArchived, staleReviewActions, staleReviewCompletionActions, t]);
const staleBatchPreview = staleBatchAction.previewResult;
const staleBatchResult = staleBatchAction.result;
const staleBatchPreviewReady = Boolean(staleBatchPreview?.dry_run_plan_fingerprint);
const staleBatchPreviewStatusKey = kmStaleBatchStatusKey(staleBatchPreview?.status);
const staleBatchResultStatusKey = kmStaleBatchStatusKey(staleBatchResult?.status);
return (
<section className="border border-[#e0ddd4] bg-white">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[#e0ddd4] bg-[#faf9f3] px-4 py-3">
@@ -2557,6 +2720,115 @@ function KnowledgeGovernancePanel({
</span>
</div>
</div>
<div className="mb-3 border border-[#e0ddd4] bg-white px-3 py-2 text-xs text-[#5f5b52]">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<p className="font-semibold text-[#141413]">
{t("staleCandidates.batchActions.title")}
</p>
<p className="mt-1 text-[11px] leading-5 text-[#77736a]">
{t("staleCandidates.batchActions.subtitle")}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={previewStaleBatchQueue}
disabled={staleBatchAction.previewLoading || staleBatchAction.confirmLoading}
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"
>
<SearchCheck className="h-3.5 w-3.5" aria-hidden="true" />
{staleBatchAction.previewLoading
? t("staleCandidates.batchActions.previewing")
: t("staleCandidates.batchActions.preview")}
</button>
<button
type="button"
onClick={confirmStaleBatchQueue}
disabled={
!staleBatchPreviewReady ||
staleBatchAction.previewLoading ||
staleBatchAction.confirmLoading
}
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-[#d9b36f] hover:bg-[#fff7e8] hover:text-[#8a5a08] disabled:cursor-not-allowed disabled:opacity-60"
>
<ClipboardList className="h-3.5 w-3.5" aria-hidden="true" />
{staleBatchAction.confirmLoading
? t("staleCandidates.batchActions.confirming")
: t("staleCandidates.batchActions.confirm")}
</button>
</div>
</div>
{staleBatchAction.error ? (
<p className="mt-2 border border-[#e2a29b] bg-[#fff0ef] px-2 py-1 text-[11px] leading-5 text-[#9f2f25]">
{staleBatchAction.error}
</p>
) : null}
{staleBatchPreview ? (
<div className="mt-2 border border-[#d9b36f] bg-[#fff7e8] px-2 py-1.5 text-[11px] leading-5 text-[#8a5a08]">
<p className="font-semibold">
{t(
`staleCandidates.batchActions.statuses.${staleBatchPreviewStatusKey}` as never
)}
</p>
<p className="mt-1 text-[#5f5b52]">
{t("staleCandidates.batchActions.summary", {
candidates: staleBatchPreview.candidate_count,
queued: staleBatchPreview.queued_count,
already: staleBatchPreview.already_queued_count,
skipped: staleBatchPreview.skipped_count,
writesKm: String(staleBatchPreview.writes_km),
writesAudit: String(staleBatchPreview.writes_governance_audit),
})}
</p>
<p className="mt-1 break-all font-mono text-[11px] text-[#5f5b52]">
{t("staleCandidates.batchActions.planFingerprint", {
fingerprint: staleBatchPreview.dry_run_plan_fingerprint ?? "--",
})}
</p>
{staleBatchPreview.stale_ratio_snapshot ? (
<p className="mt-1 text-[#5f5b52]">
{t("staleCandidates.completeActions.snapshot", {
stale: staleBatchPreview.stale_ratio_snapshot.stale_count,
total: staleBatchPreview.stale_ratio_snapshot.total_count,
ratio: formatStaleRatio(staleBatchPreview.stale_ratio_snapshot.stale_ratio),
threshold: formatStaleRatio(staleBatchPreview.stale_ratio_snapshot.threshold),
})}
</p>
) : null}
<div className="mt-2 flex flex-wrap gap-1">
{staleBatchPreview.items.slice(0, 5).map((item) => (
<span
key={`${item.entry_id}-${item.status}`}
className="border border-[#d8d3c7] bg-white px-2 py-0.5 font-mono text-[11px] leading-5 text-[#5f5b52]"
>
{item.priority_tier}:{t(
`staleCandidates.batchActions.itemStatuses.${kmStaleBatchItemStatusKey(item.status)}` as never
)}
</span>
))}
</div>
</div>
) : null}
{staleBatchResult ? (
<div className="mt-2 border border-[#9bc7a4] bg-[#f0faf2] px-2 py-1.5 text-[11px] leading-5 text-[#17602a]">
<p className="font-semibold">
{t(
`staleCandidates.batchActions.statuses.${staleBatchResultStatusKey}` as never
)}
</p>
<p className="mt-1 text-[#5f5b52]">
{t("staleCandidates.batchActions.result", {
batch: staleBatchResult.batch_dispatch_id ?? "--",
event: staleBatchResult.batch_governance_event_id ?? "--",
queued: staleBatchResult.queued_count,
already: staleBatchResult.already_queued_count,
skipped: staleBatchResult.skipped_count,
})}
</p>
</div>
) : null}
</div>
{staleCandidates === null ? (
<p className="text-xs text-[#8a5a08]">
{t("staleCandidates.unavailable")}