feat(governance): preview stale km completion batches
All checks were successful
CD Pipeline / tests (push) Successful in 1m8s
Code Review / ai-code-review (push) Successful in 11s
Type Sync Check / check-type-sync (push) Successful in 26s
CD Pipeline / build-and-deploy (push) Successful in 4m9s
CD Pipeline / post-deploy-checks (push) Successful in 1m33s

This commit is contained in:
Your Name
2026-05-24 23:15:03 +08:00
parent 1a4ac330b1
commit 4cfc6a4c79
7 changed files with 624 additions and 7 deletions

View File

@@ -37,6 +37,8 @@ from src.models.governance import (
KnowledgeStaleOwnerReviewBurnDownResponse,
KnowledgeStaleOwnerReviewCompleteRequest,
KnowledgeStaleOwnerReviewCompleteResponse,
KnowledgeStaleOwnerReviewCompletionBatchPreviewRequest,
KnowledgeStaleOwnerReviewCompletionBatchPreviewResponse,
KnowledgeStaleOwnerReviewCompletionQueueResponse,
KnowledgeStaleOwnerReviewInboxResponse,
KnowledgeStaleOwnerReviewRequest,
@@ -50,6 +52,7 @@ from src.services.governance_km_stale_review_service import (
KmStaleOwnerReviewError,
batch_queue_km_stale_owner_reviews,
complete_km_stale_owner_review,
preview_km_stale_owner_review_completion_batch,
query_km_stale_owner_review_burndown,
query_km_stale_owner_review_completion_queue,
query_km_stale_owner_review_inbox,
@@ -320,6 +323,13 @@ async def get_km_stale_owner_review_completion_queue(
str,
Query(pattern="^(all|ready|blocked|completed|failed|pending)$"),
] = "all",
priority_tier: Annotated[list[str] | None, Query(alias="priority_tier")] = None,
recommended_completion_outcome: Annotated[
str,
Query(pattern="^(all|refresh_with_evidence|archive|supersede)$"),
] = "all",
batch_governance_event_id: Annotated[str | None, Query(max_length=120)] = None,
can_preview: bool | None = None,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
) -> KnowledgeStaleOwnerReviewCompletionQueueResponse:
"""
@@ -332,13 +342,59 @@ async def get_km_stale_owner_review_completion_queue(
"km_stale_owner_review_completion_queue_request",
project_id=project_id,
status_bucket=status_bucket,
priority_tiers=priority_tier,
recommended_completion_outcome=recommended_completion_outcome,
batch_governance_event_id=batch_governance_event_id,
can_preview=can_preview,
limit=limit,
)
return await query_km_stale_owner_review_completion_queue(
project_id=project_id,
status_bucket=status_bucket,
limit=limit,
try:
return await query_km_stale_owner_review_completion_queue(
project_id=project_id,
status_bucket=status_bucket,
priority_tiers=priority_tier,
recommended_completion_outcome=recommended_completion_outcome,
batch_governance_event_id=batch_governance_event_id,
can_preview=can_preview,
limit=limit,
)
except KmStaleOwnerReviewError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
# =============================================================================
# POST /api/v1/ai/governance/km-stale-owner-review-completion-queue/batch-preview
# =============================================================================
@router.post(
"/ai/governance/km-stale-owner-review-completion-queue/batch-preview",
response_model=KnowledgeStaleOwnerReviewCompletionBatchPreviewResponse,
)
async def post_km_stale_owner_review_completion_batch_preview(
request: KnowledgeStaleOwnerReviewCompletionBatchPreviewRequest,
) -> KnowledgeStaleOwnerReviewCompletionBatchPreviewResponse:
"""
Preview a bounded set of owner-review completion candidates.
This endpoint is intentionally dry-run only: it does not write KM, does not
enqueue a batch executor, and does not create governance audit rows. Each
item must still be completed through the single-item dry-run + owner confirm
endpoint.
"""
logger.info(
"km_stale_owner_review_completion_batch_preview_request",
project_id=request.project_id,
status_bucket=request.status_bucket,
priority_tiers=request.priority_tiers,
recommended_completion_outcome=request.recommended_completion_outcome,
batch_governance_event_id=request.batch_governance_event_id,
limit=request.limit,
owner=request.owner,
)
try:
return await preview_km_stale_owner_review_completion_batch(request=request)
except KmStaleOwnerReviewError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
# =============================================================================

View File

@@ -465,6 +465,15 @@ class KnowledgeStaleOwnerReviewCompletionQueueResponse(BaseModel):
schema_version: str = "km_stale_owner_review_completion_queue_v1"
project_id: str
status_bucket: Literal["all", "ready", "blocked", "completed", "failed", "pending"]
priority_tiers: list[str] = Field(default_factory=list)
recommended_completion_outcome: Literal[
"all",
"refresh_with_evidence",
"archive",
"supersede",
] = "all"
batch_governance_event_id: str | None = None
can_preview: bool | None = None
total: int
returned: int
pending_count: int
@@ -479,6 +488,57 @@ class KnowledgeStaleOwnerReviewCompletionQueueResponse(BaseModel):
generated_at: datetime
class KnowledgeStaleOwnerReviewCompletionBatchPreviewRequest(BaseModel):
project_id: str = Field(default="awoooi", min_length=1, max_length=64)
status_bucket: Literal["all", "ready", "blocked", "completed", "failed", "pending"] = "ready"
priority_tiers: list[Literal["P0", "P1", "P2"]] = Field(
default_factory=lambda: ["P0", "P1", "P2"],
min_length=1,
max_length=3,
)
recommended_completion_outcome: Literal[
"all",
"refresh_with_evidence",
"archive",
"supersede",
] = "all"
batch_governance_event_id: str | None = Field(default=None, max_length=120)
limit: int = Field(default=10, ge=1, le=30)
owner: str = Field(default="operator_console", min_length=1, max_length=100)
owner_note: str | None = Field(default=None, max_length=240)
class KnowledgeStaleOwnerReviewCompletionBatchPreviewResponse(BaseModel):
schema_version: str = "km_stale_owner_review_completion_batch_preview_v1"
project_id: str
status: Literal["dry_run"] = "dry_run"
owner: str
owner_note: str | None = None
status_bucket: Literal["all", "ready", "blocked", "completed", "failed", "pending"]
priority_tiers: list[str]
recommended_completion_outcome: Literal[
"all",
"refresh_with_evidence",
"archive",
"supersede",
]
batch_governance_event_id: str | None = None
requested_limit: int
candidate_count: int
previewable_count: int
blocked_count: int
completed_count: int
failed_count: int
writes_km: bool = False
writes_governance_audit: bool = False
batch_writes_allowed: bool = False
manual_review_required: bool = True
dry_run_plan_fingerprint: str
next_action: str = "preview_each_ready_item_then_confirm_single_item"
items: list[KnowledgeStaleOwnerReviewCompletionQueueItem] = Field(default_factory=list)
generated_at: datetime
class KnowledgeStaleOwnerReviewCompleteRequest(BaseModel):
dispatch_id: str | None = Field(
default=None,

View File

@@ -37,6 +37,8 @@ from src.models.governance import (
KnowledgeStaleOwnerReviewBurnDownResponse,
KnowledgeStaleOwnerReviewCompleteRequest,
KnowledgeStaleOwnerReviewCompleteResponse,
KnowledgeStaleOwnerReviewCompletionBatchPreviewRequest,
KnowledgeStaleOwnerReviewCompletionBatchPreviewResponse,
KnowledgeStaleOwnerReviewCompletionQueueItem,
KnowledgeStaleOwnerReviewCompletionQueueResponse,
KnowledgeStaleOwnerReviewInboxItem,
@@ -120,9 +122,15 @@ async def query_km_stale_owner_review_completion_queue(
*,
project_id: str = "awoooi",
status_bucket: Literal["all", "ready", "blocked", "completed", "failed", "pending"] = "all",
priority_tiers: list[str] | None = None,
recommended_completion_outcome: str = "all",
batch_governance_event_id: str | None = None,
can_preview: bool | None = None,
limit: int = 20,
) -> KnowledgeStaleOwnerReviewCompletionQueueResponse:
"""Read owner-review completion readiness without writing KM content."""
normalized_priority_tiers = _normalize_priority_tiers(priority_tiers)
normalized_outcome = _normalize_completion_outcome(recommended_completion_outcome)
generated_at = now_taipei()
inbox_items = await _load_owner_review_inbox_items(
project_id=project_id,
@@ -148,10 +156,21 @@ async def query_km_stale_owner_review_completion_queue(
item for item in queue_items
if _completion_queue_bucket_matches(item, status_bucket=status_bucket)
]
filtered_items = _filter_completion_queue_items(
filtered_items,
priority_tiers=normalized_priority_tiers,
recommended_completion_outcome=normalized_outcome,
batch_governance_event_id=batch_governance_event_id,
can_preview=can_preview,
)
limited = filtered_items[:limit]
return KnowledgeStaleOwnerReviewCompletionQueueResponse(
project_id=project_id,
status_bucket=status_bucket,
priority_tiers=normalized_priority_tiers,
recommended_completion_outcome=normalized_outcome,
batch_governance_event_id=batch_governance_event_id,
can_preview=can_preview,
total=len(filtered_items),
returned=len(limited),
pending_count=sum(
@@ -167,6 +186,47 @@ async def query_km_stale_owner_review_completion_queue(
)
async def preview_km_stale_owner_review_completion_batch(
*,
request: KnowledgeStaleOwnerReviewCompletionBatchPreviewRequest,
) -> KnowledgeStaleOwnerReviewCompletionBatchPreviewResponse:
"""Preview a bounded completion batch without writing KM or governance audit rows."""
generated_at = now_taipei()
queue = await query_km_stale_owner_review_completion_queue(
project_id=request.project_id,
status_bucket=request.status_bucket,
priority_tiers=list(request.priority_tiers),
recommended_completion_outcome=request.recommended_completion_outcome,
batch_governance_event_id=request.batch_governance_event_id,
can_preview=None,
limit=request.limit,
)
items = queue.items
previewable_items = [item for item in items if item.can_preview]
fingerprint = _build_completion_batch_preview_fingerprint(
request=request,
items=items,
)
return KnowledgeStaleOwnerReviewCompletionBatchPreviewResponse(
project_id=request.project_id,
owner=request.owner,
owner_note=request.owner_note,
status_bucket=request.status_bucket,
priority_tiers=list(dict.fromkeys(request.priority_tiers)),
recommended_completion_outcome=request.recommended_completion_outcome,
batch_governance_event_id=request.batch_governance_event_id,
requested_limit=request.limit,
candidate_count=len(items),
previewable_count=len(previewable_items),
blocked_count=sum(1 for item in items if item.readiness == "blocked"),
completed_count=sum(1 for item in items if item.readiness == "completed"),
failed_count=sum(1 for item in items if item.readiness == "failed"),
dry_run_plan_fingerprint=fingerprint,
items=items,
generated_at=generated_at,
)
async def _load_owner_review_inbox_items(
*,
project_id: str,
@@ -973,6 +1033,107 @@ def _completion_queue_bucket_matches(
return item.readiness == status_bucket
def _normalize_priority_tiers(priority_tiers: list[str] | None) -> list[str]:
if not priority_tiers:
return []
allowed = {"P0", "P1", "P2"}
normalized = list(dict.fromkeys(str(tier).upper() for tier in priority_tiers))
unsupported = [tier for tier in normalized if tier not in allowed]
if unsupported:
raise KmStaleOwnerReviewError(
422,
"unsupported stale KM owner-review completion priority_tier",
)
return normalized
def _normalize_completion_outcome(outcome: str) -> Literal[
"all",
"refresh_with_evidence",
"archive",
"supersede",
]:
if outcome == "all":
return "all"
if outcome == "refresh_with_evidence":
return "refresh_with_evidence"
if outcome == "archive":
return "archive"
if outcome == "supersede":
return "supersede"
raise KmStaleOwnerReviewError(
422,
"unsupported stale KM owner-review completion outcome",
)
def _filter_completion_queue_items(
items: list[KnowledgeStaleOwnerReviewCompletionQueueItem],
*,
priority_tiers: list[str],
recommended_completion_outcome: str,
batch_governance_event_id: str | None,
can_preview: bool | None,
) -> list[KnowledgeStaleOwnerReviewCompletionQueueItem]:
filtered = items
if priority_tiers:
wanted = set(priority_tiers)
filtered = [item for item in filtered if item.priority_tier in wanted]
if recommended_completion_outcome != "all":
filtered = [
item for item in filtered
if item.recommended_completion_outcome == recommended_completion_outcome
]
if batch_governance_event_id:
filtered = [
item for item in filtered
if item.batch_governance_event_id == batch_governance_event_id
]
if can_preview is not None:
filtered = [item for item in filtered if item.can_preview is can_preview]
return filtered
def _build_completion_batch_preview_fingerprint(
*,
request: KnowledgeStaleOwnerReviewCompletionBatchPreviewRequest,
items: list[KnowledgeStaleOwnerReviewCompletionQueueItem],
) -> str:
payload = {
"schema_version": "km_stale_owner_review_completion_batch_preview_v1",
"project_id": request.project_id,
"status_bucket": request.status_bucket,
"priority_tiers": list(dict.fromkeys(request.priority_tiers)),
"recommended_completion_outcome": request.recommended_completion_outcome,
"batch_governance_event_id": request.batch_governance_event_id,
"limit": request.limit,
"owner": request.owner,
"owner_note_sha256": (
hashlib.sha256(request.owner_note.encode("utf-8")).hexdigest()
if request.owner_note
else None
),
"items": [
{
"dispatch_id": item.dispatch_id,
"entry_id": item.entry_id,
"readiness": item.readiness,
"recommended_completion_outcome": item.recommended_completion_outcome,
"can_preview": item.can_preview,
"workflow_stage": item.workflow_stage,
}
for item in items
],
}
encoded = json.dumps(
payload,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
return "sha256:" + hashlib.sha256(encoded.encode("utf-8")).hexdigest()
def _completion_outcome_for_owner_review_item(
item: KnowledgeStaleOwnerReviewInboxItem,
) -> Literal["refresh_with_evidence", "archive", "supersede"]:

View File

@@ -45,6 +45,8 @@ from src.models.governance import (
KnowledgeStaleOwnerReviewBurnDownResponse,
KnowledgeStaleOwnerReviewCompleteRequest,
KnowledgeStaleOwnerReviewCompleteResponse,
KnowledgeStaleOwnerReviewCompletionBatchPreviewRequest,
KnowledgeStaleOwnerReviewCompletionBatchPreviewResponse,
KnowledgeStaleOwnerReviewCompletionQueueItem,
KnowledgeStaleOwnerReviewCompletionQueueResponse,
KnowledgeStaleOwnerReviewInboxItem,
@@ -65,6 +67,7 @@ from src.services.governance_km_stale_review_service import (
KmStaleOwnerReviewError,
_build_batch_owner_review_decision_context,
_build_batch_queue_plan_fingerprint,
_build_completion_batch_preview_fingerprint,
_build_completion_plan_fingerprint,
_build_completion_queue_item,
_build_owner_review_burndown_items,
@@ -1224,11 +1227,21 @@ class TestKmReviewDraftDedupe:
):
r = client.get(
"/api/v1/ai/governance/km-stale-owner-review-completion-queue"
"?project_id=awoooi&status_bucket=all&limit=12"
"?project_id=awoooi&status_bucket=all&priority_tier=P0"
"&recommended_completion_outcome=refresh_with_evidence"
"&can_preview=true&limit=12"
)
assert r.status_code == 200
assert captured == {"project_id": "awoooi", "status_bucket": "all", "limit": 12}
assert captured == {
"project_id": "awoooi",
"status_bucket": "all",
"priority_tiers": ["P0"],
"recommended_completion_outcome": "refresh_with_evidence",
"batch_governance_event_id": None,
"can_preview": True,
"limit": 12,
}
data = r.json()
assert data["schema_version"] == "km_stale_owner_review_completion_queue_v1"
assert data["writes_on_read"] is False
@@ -1240,6 +1253,83 @@ class TestKmReviewDraftDedupe:
assert data["items"][0]["can_preview"] is True
assert data["items"][1]["writes_km_on_confirm"] is False
def test_completion_queue_batch_preview_endpoint_is_dry_run_only(self, client):
fake = KnowledgeStaleOwnerReviewCompletionBatchPreviewResponse(
project_id="awoooi",
owner="operator_console",
owner_note="review top ready items",
status_bucket="ready",
priority_tiers=["P0", "P1"],
recommended_completion_outcome="all",
requested_limit=10,
candidate_count=1,
previewable_count=1,
blocked_count=0,
completed_count=0,
failed_count=0,
dry_run_plan_fingerprint="sha256:" + "d" * 64,
items=[
KnowledgeStaleOwnerReviewCompletionQueueItem(
dispatch_id="dispatch-ready-001",
governance_event_id="event-001",
entry_id="km-001",
project_id="awoooi",
title="Sentry checkout failure repair",
dispatch_status="pending",
workflow_stage="waiting_owner_review",
readiness="ready",
recommended_completion_outcome="refresh_with_evidence",
next_action="preview_stale_km_review_completion",
required_owner_fields=["owner_note_or_updated_content"],
can_preview=True,
can_confirm_after_preview=True,
writes_km_on_confirm=True,
priority_tier="P0",
priority_score=265,
recommended_action="refresh_with_evidence",
stale_days=35,
view_count=7,
correlation_sources=["incident", "playbook", "sentry"],
reasons=["linked_incident"],
queued_at=NOW,
)
],
generated_at=NOW,
)
captured: dict = {}
async def mock_completion_batch_preview(**kwargs):
captured.update(kwargs)
return fake
with patch(
"src.api.v1.ai_governance.preview_km_stale_owner_review_completion_batch",
new=mock_completion_batch_preview,
):
r = client.post(
"/api/v1/ai/governance/km-stale-owner-review-completion-queue/batch-preview",
json={
"project_id": "awoooi",
"status_bucket": "ready",
"priority_tiers": ["P0", "P1"],
"limit": 10,
"owner": "operator_console",
"owner_note": "review top ready items",
},
)
assert r.status_code == 200
assert isinstance(captured["request"], KnowledgeStaleOwnerReviewCompletionBatchPreviewRequest)
data = r.json()
assert data["schema_version"] == "km_stale_owner_review_completion_batch_preview_v1"
assert data["status"] == "dry_run"
assert data["writes_km"] is False
assert data["writes_governance_audit"] is False
assert data["batch_writes_allowed"] is False
assert data["manual_review_required"] is True
assert data["previewable_count"] == 1
assert data["items"][0]["can_preview"] is True
def test_completion_queue_item_marks_active_review_ready(self):
inbox_item = KnowledgeStaleOwnerReviewInboxItem(
dispatch_id="dispatch-ready-001",
@@ -1301,6 +1391,50 @@ class TestKmReviewDraftDedupe:
assert item.can_confirm_after_preview is False
assert item.writes_km_on_confirm is False
def test_completion_batch_preview_fingerprint_is_stable_and_read_only(self):
request = KnowledgeStaleOwnerReviewCompletionBatchPreviewRequest(
project_id="awoooi",
status_bucket="ready",
priority_tiers=["P0", "P1"],
limit=10,
owner="operator_console",
owner_note="top ready items",
)
items = [
KnowledgeStaleOwnerReviewCompletionQueueItem(
dispatch_id="dispatch-ready-001",
governance_event_id="event-001",
entry_id="km-001",
project_id="awoooi",
title="Sentry checkout failure repair",
dispatch_status="pending",
workflow_stage="waiting_owner_review",
readiness="ready",
recommended_completion_outcome="refresh_with_evidence",
next_action="preview_stale_km_review_completion",
required_owner_fields=["owner_note_or_updated_content"],
can_preview=True,
can_confirm_after_preview=True,
writes_km_on_confirm=True,
priority_tier="P0",
priority_score=265,
recommended_action="refresh_with_evidence",
stale_days=35,
view_count=7,
correlation_sources=["incident", "playbook"],
reasons=["linked_incident"],
queued_at=NOW,
)
]
fingerprint = _build_completion_batch_preview_fingerprint(
request=request,
items=items,
)
assert fingerprint.startswith("sha256:")
assert len(fingerprint) == 71
def test_stale_owner_review_batch_context_is_operator_visible(self):
request = KnowledgeStaleOwnerReviewBatchQueueRequest(
project_id="awoooi",

View File

@@ -2149,6 +2149,23 @@
"next": "Next: {action}; outcome: {outcome}",
"required": "Required fields: {fields}",
"blockers": "Blockers: {blockers}",
"filters": {
"ready": "Ready",
"blocked": "Blocked",
"completed": "Completed",
"failed": "Failed",
"pending": "Pending",
"all": "All",
"priorityAll": "All priorities"
},
"batchPreview": {
"preview": "Batch preview",
"previewing": "Previewing",
"previewFailed": "Completion batch preview failed",
"summary": "Candidates {candidates}; single-item dry-run ready {previewable}; blocked {blocked}; writes KM={writesKm}; writes audit={writesAudit}; batch writes={batchWrites}",
"planFingerprint": "Preview fingerprint: {fingerprint}",
"next": "Next: {action}"
},
"readiness": {
"ready": "Ready to dry-run",
"blocked": "Needs manual unblock",

View File

@@ -2150,6 +2150,23 @@
"next": "下一步:{action};結果:{outcome}",
"required": "需要欄位:{fields}",
"blockers": "卡點:{blockers}",
"filters": {
"ready": "可處理",
"blocked": "卡住",
"completed": "已完成",
"failed": "失敗",
"pending": "待處理",
"all": "全部",
"priorityAll": "全部優先級"
},
"batchPreview": {
"preview": "批次預覽",
"previewing": "預覽中",
"previewFailed": "批次 completion 預覽失敗",
"summary": "候選 {candidates};可逐筆乾跑 {previewable};卡住 {blocked};寫 KM={writesKm};寫 audit={writesAudit};批次寫入={batchWrites}",
"planFingerprint": "預覽指紋:{fingerprint}",
"next": "下一步:{action}"
},
"readiness": {
"ready": "可乾跑",
"blocked": "需人工排除",

View File

@@ -616,6 +616,10 @@ type KnowledgeStaleOwnerReviewCompletionQueueResponse = {
schema_version?: string;
project_id: string;
status_bucket: "all" | "ready" | "blocked" | "completed" | "failed" | "pending";
priority_tiers?: string[];
recommended_completion_outcome?: "all" | "refresh_with_evidence" | "archive" | "supersede";
batch_governance_event_id?: string | null;
can_preview?: boolean | null;
total: number;
returned: number;
pending_count: number;
@@ -630,6 +634,38 @@ type KnowledgeStaleOwnerReviewCompletionQueueResponse = {
generated_at?: string | null;
};
type KnowledgeStaleOwnerReviewCompletionBatchPreviewResponse = {
schema_version?: string;
project_id: string;
status: "dry_run";
owner: string;
owner_note?: string | null;
status_bucket: "all" | "ready" | "blocked" | "completed" | "failed" | "pending";
priority_tiers: string[];
recommended_completion_outcome: "all" | "refresh_with_evidence" | "archive" | "supersede";
batch_governance_event_id?: string | null;
requested_limit: number;
candidate_count: number;
previewable_count: number;
blocked_count: number;
completed_count: number;
failed_count: number;
writes_km: boolean;
writes_governance_audit: boolean;
batch_writes_allowed: boolean;
manual_review_required: boolean;
dry_run_plan_fingerprint: string;
next_action: string;
items: KnowledgeStaleOwnerReviewCompletionQueueItem[];
generated_at?: string | null;
};
type KnowledgeStaleOwnerReviewCompletionBatchPreviewAction = {
loading: boolean;
result: KnowledgeStaleOwnerReviewCompletionBatchPreviewResponse | null;
error: string | null;
};
type KnowledgeStaleOwnerReviewCompleteResponse = {
schema_version?: string;
entry_id: string;
@@ -2452,6 +2488,16 @@ function KnowledgeGovernancePanel({
});
const [staleReviewCompletionActions, setStaleReviewCompletionActions] =
useState<Record<string, KnowledgeStaleOwnerReviewCompleteAction>>({});
const [completionReadinessFilter, setCompletionReadinessFilter] =
useState<"all" | "ready" | "blocked" | "completed" | "failed" | "pending">("ready");
const [completionPriorityFilter, setCompletionPriorityFilter] =
useState<"all" | "P0" | "P1" | "P2">("all");
const [completionBatchPreviewAction, setCompletionBatchPreviewAction] =
useState<KnowledgeStaleOwnerReviewCompletionBatchPreviewAction>({
loading: false,
result: null,
error: null,
});
const items = queue?.items ?? [];
const draftGroups = groupKnowledgeReviewDrafts(reviewDrafts, items);
const dedupeGroups = dedupe?.groups ?? [];
@@ -2463,7 +2509,20 @@ function KnowledgeGovernancePanel({
const ownerReviewItems = ownerReviewInbox?.items ?? [];
const burnDownItems = burnDown?.items ?? [];
const burnDownSnapshot = burnDown?.current_snapshot ?? null;
const completionQueueItems = completionQueue?.items ?? [];
const completionQueueItems = useMemo(
() => (completionQueue?.items ?? []).filter((item) => {
const readinessMatches =
completionReadinessFilter === "all" ||
(completionReadinessFilter === "pending"
? ["pending", "dispatched", "executing"].includes(item.dispatch_status)
: item.readiness === completionReadinessFilter);
const priorityMatches =
completionPriorityFilter === "all" ||
item.priority_tier === completionPriorityFilter;
return readinessMatches && priorityMatches;
}),
[completionPriorityFilter, completionQueue?.items, completionReadinessFilter]
);
const draftTotal = dedupe?.total_review_drafts ?? reviewDrafts?.total ?? 0;
const activeCount = items.filter((item) =>
["pending", "dispatched", "executing"].includes(item.dispatch_status)
@@ -2776,11 +2835,46 @@ function KnowledgeGovernancePanel({
}
}, [onArchived, staleReviewActions, staleReviewCompletionActions, t]);
const previewCompletionBatch = useCallback(async () => {
setCompletionBatchPreviewAction({
loading: true,
result: null,
error: null,
});
const result = await postJson<KnowledgeStaleOwnerReviewCompletionBatchPreviewResponse>(
`${API_BASE}/api/v1/ai/governance/km-stale-owner-review-completion-queue/batch-preview`,
{
project_id: completionQueue?.project_id ?? staleCandidates?.project_id ?? "awoooi",
status_bucket: completionReadinessFilter,
priority_tiers: completionPriorityFilter === "all"
? ["P0", "P1", "P2"]
: [completionPriorityFilter],
recommended_completion_outcome: "all",
limit: 10,
owner: "operator_console",
owner_note: "completion_queue_batch_preview",
},
15000
);
setCompletionBatchPreviewAction({
loading: false,
result,
error: result ? null : t("staleCandidates.completionQueue.batchPreview.previewFailed"),
});
}, [
completionPriorityFilter,
completionQueue?.project_id,
completionReadinessFilter,
staleCandidates?.project_id,
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);
const completionBatchPreview = completionBatchPreviewAction.result;
return (
<section className="border border-[#e0ddd4] bg-white">
@@ -3216,6 +3310,84 @@ function KnowledgeGovernancePanel({
</span>
</div>
</div>
<div className="mb-2 flex flex-wrap items-center justify-between gap-2 border border-[#e0ddd4] bg-[#faf9f3] px-2 py-2">
<div className="flex flex-wrap gap-1">
{(["ready", "blocked", "completed", "failed", "pending", "all"] as const).map((filter) => (
<button
key={filter}
type="button"
onClick={() => setCompletionReadinessFilter(filter)}
className={cn(
"border px-2 py-1 text-[11px] font-semibold leading-4",
completionReadinessFilter === filter
? "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]"
: "border-[#d8d3c7] bg-white text-[#5f5b52] hover:border-[#d9b36f]"
)}
>
{t(`staleCandidates.completionQueue.filters.${filter}` as never)}
</button>
))}
</div>
<div className="flex flex-wrap gap-1">
{(["all", "P0", "P1", "P2"] as const).map((filter) => (
<button
key={filter}
type="button"
onClick={() => setCompletionPriorityFilter(filter)}
className={cn(
"border px-2 py-1 font-mono text-[11px] font-semibold leading-4",
completionPriorityFilter === filter
? "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]"
: "border-[#d8d3c7] bg-white text-[#5f5b52] hover:border-[#9bc7a4]"
)}
>
{filter === "all"
? t("staleCandidates.completionQueue.filters.priorityAll")
: filter}
</button>
))}
</div>
<button
type="button"
onClick={previewCompletionBatch}
disabled={completionBatchPreviewAction.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-[#1f6feb] hover:bg-[#edf4ff] hover:text-[#0f4fa8] disabled:cursor-not-allowed disabled:opacity-60"
>
<SearchCheck className="h-3.5 w-3.5" aria-hidden="true" />
{completionBatchPreviewAction.loading
? t("staleCandidates.completionQueue.batchPreview.previewing")
: t("staleCandidates.completionQueue.batchPreview.preview")}
</button>
</div>
{completionBatchPreviewAction.error ? (
<p className="mb-2 border border-[#e2a29b] bg-[#fff0ef] px-2 py-1 text-[11px] leading-5 text-[#9f2f25]">
{completionBatchPreviewAction.error}
</p>
) : null}
{completionBatchPreview ? (
<div className="mb-2 border border-[#1f6feb] bg-[#edf4ff] px-2 py-1.5 text-[11px] leading-5 text-[#0f4fa8]">
<p className="font-semibold">
{t("staleCandidates.completionQueue.batchPreview.summary", {
candidates: completionBatchPreview.candidate_count,
previewable: completionBatchPreview.previewable_count,
blocked: completionBatchPreview.blocked_count,
writesKm: String(completionBatchPreview.writes_km),
writesAudit: String(completionBatchPreview.writes_governance_audit),
batchWrites: String(completionBatchPreview.batch_writes_allowed),
})}
</p>
<p className="mt-1 break-all font-mono text-[11px] text-[#5f5b52]">
{t("staleCandidates.completionQueue.batchPreview.planFingerprint", {
fingerprint: completionBatchPreview.dry_run_plan_fingerprint,
})}
</p>
<p className="mt-1 text-[#5f5b52]">
{t("staleCandidates.completionQueue.batchPreview.next", {
action: completionBatchPreview.next_action,
})}
</p>
</div>
) : null}
{completionQueue === null ? (
<p className="text-[11px] leading-5 text-[#8a5a08]">
{t("staleCandidates.completionQueue.unavailable")}