diff --git a/apps/api/src/api/v1/ai_governance.py b/apps/api/src/api/v1/ai_governance.py index afd50160..d71c0e60 100644 --- a/apps/api/src/api/v1/ai_governance.py +++ b/apps/api/src/api/v1/ai_governance.py @@ -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 # ============================================================================= diff --git a/apps/api/src/models/governance.py b/apps/api/src/models/governance.py index 41687df8..b52144e5 100644 --- a/apps/api/src/models/governance.py +++ b/apps/api/src/models/governance.py @@ -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, diff --git a/apps/api/src/services/governance_km_stale_review_service.py b/apps/api/src/services/governance_km_stale_review_service.py index 7d0288b8..6646150b 100644 --- a/apps/api/src/services/governance_km_stale_review_service.py +++ b/apps/api/src/services/governance_km_stale_review_service.py @@ -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"]: diff --git a/apps/api/tests/test_ai_governance_endpoints.py b/apps/api/tests/test_ai_governance_endpoints.py index 3255266b..90e58b9e 100644 --- a/apps/api/tests/test_ai_governance_endpoints.py +++ b/apps/api/tests/test_ai_governance_endpoints.py @@ -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", diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 550caee6..7a829fe9 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -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", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index dbfca39e..39c50654 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -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": "需人工排除", diff --git a/apps/web/src/app/[locale]/awooop/work-items/page.tsx b/apps/web/src/app/[locale]/awooop/work-items/page.tsx index 67ff89df..48f4e339 100644 --- a/apps/web/src/app/[locale]/awooop/work-items/page.tsx +++ b/apps/web/src/app/[locale]/awooop/work-items/page.tsx @@ -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>({}); + const [completionReadinessFilter, setCompletionReadinessFilter] = + useState<"all" | "ready" | "blocked" | "completed" | "failed" | "pending">("ready"); + const [completionPriorityFilter, setCompletionPriorityFilter] = + useState<"all" | "P0" | "P1" | "P2">("all"); + const [completionBatchPreviewAction, setCompletionBatchPreviewAction] = + useState({ + 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( + `${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 (
@@ -3216,6 +3310,84 @@ function KnowledgeGovernancePanel({ +
+
+ {(["ready", "blocked", "completed", "failed", "pending", "all"] as const).map((filter) => ( + + ))} +
+
+ {(["all", "P0", "P1", "P2"] as const).map((filter) => ( + + ))} +
+ +
+ {completionBatchPreviewAction.error ? ( +

+ {completionBatchPreviewAction.error} +

+ ) : null} + {completionBatchPreview ? ( +
+

+ {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), + })} +

+

+ {t("staleCandidates.completionQueue.batchPreview.planFingerprint", { + fingerprint: completionBatchPreview.dry_run_plan_fingerprint, + })} +

+

+ {t("staleCandidates.completionQueue.batchPreview.next", { + action: completionBatchPreview.next_action, + })} +

+
+ ) : null} {completionQueue === null ? (

{t("staleCandidates.completionQueue.unavailable")}