diff --git a/apps/api/src/api/v1/ai_governance.py b/apps/api/src/api/v1/ai_governance.py index d2e6a48c2..afd501607 100644 --- a/apps/api/src/api/v1/ai_governance.py +++ b/apps/api/src/api/v1/ai_governance.py @@ -37,6 +37,7 @@ from src.models.governance import ( KnowledgeStaleOwnerReviewBurnDownResponse, KnowledgeStaleOwnerReviewCompleteRequest, KnowledgeStaleOwnerReviewCompleteResponse, + KnowledgeStaleOwnerReviewCompletionQueueResponse, KnowledgeStaleOwnerReviewInboxResponse, KnowledgeStaleOwnerReviewRequest, KnowledgeStaleOwnerReviewResponse, @@ -50,6 +51,7 @@ from src.services.governance_km_stale_review_service import ( batch_queue_km_stale_owner_reviews, complete_km_stale_owner_review, query_km_stale_owner_review_burndown, + query_km_stale_owner_review_completion_queue, query_km_stale_owner_review_inbox, queue_km_stale_owner_review, ) @@ -304,6 +306,41 @@ async def get_km_stale_owner_review_burndown( ) +# ============================================================================= +# GET /api/v1/ai/governance/km-stale-owner-review-completion-queue +# ============================================================================= + +@router.get( + "/ai/governance/km-stale-owner-review-completion-queue", + response_model=KnowledgeStaleOwnerReviewCompletionQueueResponse, +) +async def get_km_stale_owner_review_completion_queue( + project_id: Annotated[str, Query(min_length=1, max_length=64)] = "awoooi", + status_bucket: Annotated[ + str, + Query(pattern="^(all|ready|blocked|completed|failed|pending)$"), + ] = "all", + limit: Annotated[int, Query(ge=1, le=100)] = 20, +) -> KnowledgeStaleOwnerReviewCompletionQueueResponse: + """ + 查詢 stale KM owner-review completion 分流。 + + 這是 read-only queue:把 active / completed / failed dispatch 拆成 + ready、blocked、completed、failed,讓前端呈現下一步卡點;打開頁面不寫 KM。 + """ + logger.debug( + "km_stale_owner_review_completion_queue_request", + project_id=project_id, + status_bucket=status_bucket, + limit=limit, + ) + return await query_km_stale_owner_review_completion_queue( + project_id=project_id, + status_bucket=status_bucket, + limit=limit, + ) + + # ============================================================================= # POST /api/v1/ai/governance/km-stale-candidates/batch-queue-review # ============================================================================= diff --git a/apps/api/src/models/governance.py b/apps/api/src/models/governance.py index 42176dd4b..41687df86 100644 --- a/apps/api/src/models/governance.py +++ b/apps/api/src/models/governance.py @@ -417,6 +417,68 @@ class KnowledgeStaleOwnerReviewBurnDownResponse(BaseModel): generated_at: datetime +class KnowledgeStaleOwnerReviewCompletionQueueItem(BaseModel): + dispatch_id: str + governance_event_id: str + entry_id: str + project_id: str + title: str + dispatch_status: str + workflow_stage: str + readiness: Literal["ready", "blocked", "completed", "failed"] + recommended_completion_outcome: Literal[ + "refresh_with_evidence", + "archive", + "supersede", + ] + next_action: str + blockers: list[str] = Field(default_factory=list) + required_owner_fields: list[str] = Field(default_factory=list) + can_preview: bool + can_confirm_after_preview: bool + writes_km_on_confirm: bool + owner: str | None = None + owner_note: str | None = None + batch_governance_event_id: str | None = None + batch_dispatch_id: str | None = None + priority_tier: Literal["P0", "P1", "P2"] + priority_score: int + recommended_action: Literal[ + "refresh_with_evidence", + "owner_review", + "archive_or_supersede", + ] + stale_days: int + view_count: int + correlation_sources: list[str] = Field(default_factory=list) + reasons: list[str] = Field(default_factory=list) + related_incident_id: str | None = None + related_playbook_id: str | None = None + related_approval_id: str | None = None + dry_run_plan_fingerprint: str | None = None + queued_at: datetime | None = None + started_at: datetime | None = None + completed_at: datetime | None = None + + +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"] + total: int + returned: int + pending_count: int + ready_count: int + blocked_count: int + completed_count: int + failed_count: int + writes_on_read: bool = False + manual_review_required: bool = True + batch_writes_allowed: bool = False + 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 4ab6f15c5..7d0288b81 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, + KnowledgeStaleOwnerReviewCompletionQueueItem, + KnowledgeStaleOwnerReviewCompletionQueueResponse, KnowledgeStaleOwnerReviewInboxItem, KnowledgeStaleOwnerReviewInboxResponse, KnowledgeStaleOwnerReviewRequest, @@ -57,6 +59,7 @@ _BATCH_EXECUTOR_TYPE = "hermes_km_stale_owner_review_batch" _COMPLETE_EXECUTOR_TYPE = "hermes_km_stale_owner_review_complete" _RECHECK_EXECUTOR_TYPE = "hermes_km_stale_ratio_recheck" _ACTIVE_DISPATCH_STATUSES = frozenset({"pending", "dispatched", "executing"}) +_TERMINAL_FAILED_DISPATCH_STATUSES = frozenset({"failed", "cancelled"}) _OWNER_REVIEW_DISPATCH_STATUSES = frozenset({ "pending", "dispatched", @@ -88,6 +91,88 @@ async def query_km_stale_owner_review_inbox( raise KmStaleOwnerReviewError(422, "unsupported stale KM owner-review dispatch_status") generated_at = now_taipei() + items = await _load_owner_review_inbox_items( + project_id=project_id, + dispatch_status=dispatch_status, + generated_at=generated_at, + ) + items.sort( + key=lambda item: ( + _priority_rank(item.priority_tier), + item.priority_score, + item.stale_days, + item.queued_at.isoformat() if item.queued_at else "", + ), + reverse=True, + ) + limited = items[:limit] + return KnowledgeStaleOwnerReviewInboxResponse( + project_id=project_id, + dispatch_status=dispatch_status, + total=len(items), + returned=len(limited), + items=limited, + generated_at=generated_at, + ) + + +async def query_km_stale_owner_review_completion_queue( + *, + project_id: str = "awoooi", + status_bucket: Literal["all", "ready", "blocked", "completed", "failed", "pending"] = "all", + limit: int = 20, +) -> KnowledgeStaleOwnerReviewCompletionQueueResponse: + """Read owner-review completion readiness without writing KM content.""" + generated_at = now_taipei() + inbox_items = await _load_owner_review_inbox_items( + project_id=project_id, + dispatch_status="all", + generated_at=generated_at, + ) + queue_items = [ + _build_completion_queue_item(item) + for item in inbox_items + ] + queue_items.sort( + key=lambda item: ( + item.readiness == "ready", + _priority_rank(item.priority_tier), + item.priority_score, + item.stale_days, + item.queued_at.isoformat() if item.queued_at else "", + ), + reverse=True, + ) + + filtered_items = [ + item for item in queue_items + if _completion_queue_bucket_matches(item, status_bucket=status_bucket) + ] + limited = filtered_items[:limit] + return KnowledgeStaleOwnerReviewCompletionQueueResponse( + project_id=project_id, + status_bucket=status_bucket, + total=len(filtered_items), + returned=len(limited), + pending_count=sum( + 1 for item in queue_items + if item.dispatch_status in _ACTIVE_DISPATCH_STATUSES + ), + ready_count=sum(1 for item in queue_items if item.readiness == "ready"), + blocked_count=sum(1 for item in queue_items if item.readiness == "blocked"), + completed_count=sum(1 for item in queue_items if item.readiness == "completed"), + failed_count=sum(1 for item in queue_items if item.readiness == "failed"), + items=limited, + generated_at=generated_at, + ) + + +async def _load_owner_review_inbox_items( + *, + project_id: str, + dispatch_status: str, + generated_at: Any, +) -> list[KnowledgeStaleOwnerReviewInboxItem]: status_filter = ( "TRUE" if dispatch_status == "all" @@ -165,25 +250,7 @@ async def query_km_stale_owner_review_inbox( decision_context=decision_context, ) ) - - items.sort( - key=lambda item: ( - _priority_rank(item.priority_tier), - item.priority_score, - item.stale_days, - item.queued_at.isoformat() if item.queued_at else "", - ), - reverse=True, - ) - limited = items[:limit] - return KnowledgeStaleOwnerReviewInboxResponse( - project_id=project_id, - dispatch_status=dispatch_status, - total=len(items), - returned=len(limited), - items=limited, - generated_at=generated_at, - ) + return items async def query_km_stale_owner_review_burndown( @@ -850,6 +917,128 @@ def _build_owner_review_inbox_item( ) +def _build_completion_queue_item( + item: KnowledgeStaleOwnerReviewInboxItem, +) -> KnowledgeStaleOwnerReviewCompletionQueueItem: + outcome = _completion_outcome_for_owner_review_item(item) + blockers = _completion_queue_blockers(item, outcome=outcome) + readiness = _completion_queue_readiness(item, blockers=blockers) + can_preview = readiness == "ready" + return KnowledgeStaleOwnerReviewCompletionQueueItem( + dispatch_id=item.dispatch_id, + governance_event_id=item.governance_event_id, + entry_id=item.entry_id, + project_id=item.project_id, + title=item.title, + dispatch_status=item.dispatch_status, + workflow_stage=item.workflow_stage, + readiness=readiness, + recommended_completion_outcome=outcome, + next_action=_completion_queue_next_action(readiness), + blockers=blockers, + required_owner_fields=_completion_required_owner_fields(outcome), + can_preview=can_preview, + can_confirm_after_preview=can_preview, + writes_km_on_confirm=can_preview, + owner=item.owner, + owner_note=item.owner_note, + batch_governance_event_id=item.batch_governance_event_id, + batch_dispatch_id=item.batch_dispatch_id, + priority_tier=item.priority_tier, + priority_score=item.priority_score, + recommended_action=item.recommended_action, + stale_days=item.stale_days, + view_count=item.view_count, + correlation_sources=item.correlation_sources, + reasons=item.reasons, + related_incident_id=item.related_incident_id, + related_playbook_id=item.related_playbook_id, + related_approval_id=item.related_approval_id, + dry_run_plan_fingerprint=item.dry_run_plan_fingerprint, + queued_at=item.queued_at, + started_at=item.started_at, + completed_at=item.completed_at, + ) + + +def _completion_queue_bucket_matches( + item: KnowledgeStaleOwnerReviewCompletionQueueItem, + *, + status_bucket: str, +) -> bool: + if status_bucket == "all": + return True + if status_bucket == "pending": + return item.dispatch_status in _ACTIVE_DISPATCH_STATUSES + return item.readiness == status_bucket + + +def _completion_outcome_for_owner_review_item( + item: KnowledgeStaleOwnerReviewInboxItem, +) -> Literal["refresh_with_evidence", "archive", "supersede"]: + if item.recommended_action == "archive_or_supersede": + return "archive" + return "refresh_with_evidence" + + +def _completion_required_owner_fields( + outcome: Literal["refresh_with_evidence", "archive", "supersede"], +) -> list[str]: + if outcome == "refresh_with_evidence": + return ["owner_note_or_updated_content"] + if outcome == "supersede": + return ["superseded_by_entry_id", "owner_note"] + return ["owner_note"] + + +def _completion_queue_blockers( + item: KnowledgeStaleOwnerReviewInboxItem, + *, + outcome: Literal["refresh_with_evidence", "archive", "supersede"], +) -> list[str]: + blockers: list[str] = [] + if not item.dispatch_id: + blockers.append("missing_dispatch_id") + if not item.entry_id: + blockers.append("missing_entry_id") + if item.dispatch_status == "skipped": + blockers.append("owner_review_skipped") + if item.dispatch_status not in ( + _ACTIVE_DISPATCH_STATUSES + | _TERMINAL_FAILED_DISPATCH_STATUSES + | frozenset({"succeeded", "skipped"}) + ): + blockers.append("unsupported_dispatch_status") + if outcome == "supersede": + blockers.append("superseded_by_entry_id_required") + return blockers + + +def _completion_queue_readiness( + item: KnowledgeStaleOwnerReviewInboxItem, + *, + blockers: list[str], +) -> Literal["ready", "blocked", "completed", "failed"]: + if item.dispatch_status == "succeeded": + return "completed" + if item.dispatch_status in _TERMINAL_FAILED_DISPATCH_STATUSES: + return "failed" + if item.dispatch_status in _ACTIVE_DISPATCH_STATUSES and not blockers: + return "ready" + return "blocked" + + +def _completion_queue_next_action( + readiness: Literal["ready", "blocked", "completed", "failed"], +) -> str: + return { + "ready": "preview_stale_km_review_completion", + "blocked": "inspect_owner_review_blocker", + "completed": "view_stale_km_completion_audit", + "failed": "manual_km_owner_review_triage", + }[readiness] + + async def _load_owner_review_burndown_counts( db: AsyncSession, *, diff --git a/apps/api/tests/test_ai_governance_endpoints.py b/apps/api/tests/test_ai_governance_endpoints.py index 688489b2f..3255266bd 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, + KnowledgeStaleOwnerReviewCompletionQueueItem, + KnowledgeStaleOwnerReviewCompletionQueueResponse, KnowledgeStaleOwnerReviewInboxItem, KnowledgeStaleOwnerReviewInboxResponse, KnowledgeStaleOwnerReviewRequest, @@ -64,6 +66,7 @@ from src.services.governance_km_stale_review_service import ( _build_batch_owner_review_decision_context, _build_batch_queue_plan_fingerprint, _build_completion_plan_fingerprint, + _build_completion_queue_item, _build_owner_review_burndown_items, _build_owner_review_completion_audit_context, _build_owner_review_inbox_item, @@ -1144,6 +1147,160 @@ class TestKmReviewDraftDedupe: assert items[1].stale_count_delta is None assert _entries_to_stale_threshold(items[0].stale_ratio_snapshot) == 78 + def test_owner_review_completion_queue_endpoint_returns_readiness_split(self, client): + """Completion queue 應把 ready / blocked / completed / failed 分流給前端。""" + fake = KnowledgeStaleOwnerReviewCompletionQueueResponse( + project_id="awoooi", + status_bucket="all", + total=2, + returned=2, + pending_count=1, + ready_count=1, + blocked_count=0, + completed_count=1, + failed_count=0, + 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", "linked_playbook"], + related_incident_id="INC-20260513-79ED5E", + queued_at=NOW, + ), + KnowledgeStaleOwnerReviewCompletionQueueItem( + dispatch_id="dispatch-done-001", + governance_event_id="event-001", + entry_id="km-002", + project_id="awoooi", + title="Auto repair canary runbook", + dispatch_status="succeeded", + workflow_stage="km_candidate_reviewed", + readiness="completed", + recommended_completion_outcome="archive", + next_action="view_stale_km_completion_audit", + required_owner_fields=["owner_note"], + can_preview=False, + can_confirm_after_preview=False, + writes_km_on_confirm=False, + priority_tier="P1", + priority_score=120, + recommended_action="archive_or_supersede", + stale_days=18, + view_count=2, + correlation_sources=["playbook"], + reasons=["high_stale_days"], + completed_at=NOW, + ), + ], + generated_at=NOW, + ) + captured: dict = {} + + async def mock_completion_queue(**kwargs): + captured.update(kwargs) + return fake + + with patch( + "src.api.v1.ai_governance.query_km_stale_owner_review_completion_queue", + new=mock_completion_queue, + ): + r = client.get( + "/api/v1/ai/governance/km-stale-owner-review-completion-queue" + "?project_id=awoooi&status_bucket=all&limit=12" + ) + + assert r.status_code == 200 + assert captured == {"project_id": "awoooi", "status_bucket": "all", "limit": 12} + data = r.json() + assert data["schema_version"] == "km_stale_owner_review_completion_queue_v1" + assert data["writes_on_read"] is False + assert data["manual_review_required"] is True + assert data["batch_writes_allowed"] is False + assert data["pending_count"] == 1 + assert data["completed_count"] == 1 + assert data["items"][0]["readiness"] == "ready" + assert data["items"][0]["can_preview"] is True + assert data["items"][1]["writes_km_on_confirm"] is False + + def test_completion_queue_item_marks_active_review_ready(self): + inbox_item = KnowledgeStaleOwnerReviewInboxItem( + 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", + next_action="owner_review_stale_km_candidate", + owner="operator_console", + owner_note="p0_p1_stale_km_batch", + 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"], + related_incident_id="INC-20260513-79ED5E", + queued_at=NOW, + ) + + item = _build_completion_queue_item(inbox_item) + + assert item.readiness == "ready" + assert item.next_action == "preview_stale_km_review_completion" + assert item.recommended_completion_outcome == "refresh_with_evidence" + assert item.required_owner_fields == ["owner_note_or_updated_content"] + assert item.can_preview is True + assert item.can_confirm_after_preview is True + assert item.writes_km_on_confirm is True + + def test_completion_queue_item_marks_terminal_states_non_writable(self): + inbox_item = KnowledgeStaleOwnerReviewInboxItem( + dispatch_id="dispatch-completed-001", + governance_event_id="event-001", + entry_id="km-001", + project_id="awoooi", + title="Old auto repair note", + dispatch_status="succeeded", + workflow_stage="km_candidate_reviewed", + priority_tier="P1", + priority_score=120, + recommended_action="archive_or_supersede", + stale_days=20, + view_count=2, + correlation_sources=["playbook"], + reasons=["high_stale_days"], + completed_at=NOW, + ) + + item = _build_completion_queue_item(inbox_item) + + assert item.readiness == "completed" + assert item.next_action == "view_stale_km_completion_audit" + assert item.recommended_completion_outcome == "archive" + assert item.can_preview is False + assert item.can_confirm_after_preview is False + assert item.writes_km_on_confirm is False + 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 9876ff02f..550caee6a 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -2134,6 +2134,28 @@ "itemState": "stage: {stage}; outcome: {outcome}", "itemRefs": "Source: {source}; Recheck: {recheck}" }, + "completionQueue": { + "title": "Completion queue", + "subtitle": "Splits owner review into ready, blocked, completed, and failed work so the next step is visible.", + "ready": "Ready {count}", + "blocked": "Blocked {count}", + "completed": "Completed {count}", + "failed": "Failed {count}", + "pending": "Pending dispatch {count}", + "guardrail": "writes on read={writes}; manual review={review}; batch writes={batch}", + "unavailable": "The completion queue API has not responded; use the owner-review inbox for single-item confirmation.", + "empty": "No owner-review completion work items.", + "state": "Split: {readiness}; stage: {stage}", + "next": "Next: {action}; outcome: {outcome}", + "required": "Required fields: {fields}", + "blockers": "Blockers: {blockers}", + "readiness": { + "ready": "Ready to dry-run", + "blocked": "Needs manual unblock", + "completed": "Completed", + "failed": "Failed" + } + }, "batchActions": { "title": "Batch P0 / P1 stale KM", "subtitle": "Dry-run the latest P0 / P1 candidates first, then create owner-review dispatches in batch; KM is not written directly.", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 8302f29ac..dbfca39ea 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -2135,6 +2135,28 @@ "itemState": "階段:{stage};結果:{outcome}", "itemRefs": "Source:{source};Recheck:{recheck}" }, + "completionQueue": { + "title": "Completion 分流佇列", + "subtitle": "把 owner review 拆成可乾跑、卡住、已完成、失敗,避免只看到告警卻不知道下一步。", + "ready": "可處理 {count}", + "blocked": "卡住 {count}", + "completed": "完成 {count}", + "failed": "失敗 {count}", + "pending": "待處理 dispatch {count}", + "guardrail": "讀取不寫入={writes};人工覆核={review};批次寫入={batch}", + "unavailable": "completion queue API 尚未回應;目前只能從 owner review 工作台逐筆確認。", + "empty": "目前沒有 owner-review completion 工作項。", + "state": "分流:{readiness};階段:{stage}", + "next": "下一步:{action};結果:{outcome}", + "required": "需要欄位:{fields}", + "blockers": "卡點:{blockers}", + "readiness": { + "ready": "可乾跑", + "blocked": "需人工排除", + "completed": "已完成", + "failed": "失敗待處理" + } + }, "batchActions": { "title": "批次處理 P0 / P1 陳舊 KM", "subtitle": "先乾跑鎖定最新 P0 / P1 候選,再批次建立 owner-review dispatch;不會直接寫入 KM。", 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 cd96a0824..67ff89df1 100644 --- a/apps/web/src/app/[locale]/awooop/work-items/page.tsx +++ b/apps/web/src/app/[locale]/awooop/work-items/page.tsx @@ -12,6 +12,7 @@ import { Activity, Archive, ArrowRight, + CheckCircle2, ClipboardList, Database, Fingerprint, @@ -575,6 +576,60 @@ type KnowledgeStaleOwnerReviewBurnDownResponse = { generated_at?: string | null; }; +type KnowledgeStaleOwnerReviewCompletionQueueItem = { + dispatch_id: string; + governance_event_id: string; + entry_id: string; + project_id: string; + title: string; + dispatch_status: string; + workflow_stage: string; + readiness: "ready" | "blocked" | "completed" | "failed"; + recommended_completion_outcome: "refresh_with_evidence" | "archive" | "supersede"; + next_action: string; + blockers: string[]; + required_owner_fields: string[]; + can_preview: boolean; + can_confirm_after_preview: boolean; + writes_km_on_confirm: boolean; + owner?: string | null; + owner_note?: string | null; + batch_governance_event_id?: string | null; + batch_dispatch_id?: string | null; + priority_tier: "P0" | "P1" | "P2"; + priority_score: number; + recommended_action: "refresh_with_evidence" | "owner_review" | "archive_or_supersede"; + stale_days: number; + view_count: number; + correlation_sources: string[]; + reasons: string[]; + related_incident_id?: string | null; + related_playbook_id?: string | null; + related_approval_id?: string | null; + dry_run_plan_fingerprint?: string | null; + queued_at?: string | null; + started_at?: string | null; + completed_at?: string | null; +}; + +type KnowledgeStaleOwnerReviewCompletionQueueResponse = { + schema_version?: string; + project_id: string; + status_bucket: "all" | "ready" | "blocked" | "completed" | "failed" | "pending"; + total: number; + returned: number; + pending_count: number; + ready_count: number; + blocked_count: number; + completed_count: number; + failed_count: number; + writes_on_read: boolean; + manual_review_required: boolean; + batch_writes_allowed: boolean; + items: KnowledgeStaleOwnerReviewCompletionQueueItem[]; + generated_at?: string | null; +}; + type KnowledgeStaleOwnerReviewCompleteResponse = { schema_version?: string; entry_id: string; @@ -690,6 +745,7 @@ type Telemetry = { knowledgeStaleCandidates: KnowledgeStaleCandidatesResponse | null; knowledgeStaleOwnerReviews: KnowledgeStaleOwnerReviewInboxResponse | null; knowledgeStaleOwnerReviewBurnDown: KnowledgeStaleOwnerReviewBurnDownResponse | null; + knowledgeStaleOwnerReviewCompletionQueue: KnowledgeStaleOwnerReviewCompletionQueueResponse | null; channelEvents: RecentEventsResponse | null; eventRecurrence: RecurrenceResponse | null; slo: SloResponse | null; @@ -1324,6 +1380,18 @@ function kmStaleBatchItemStatusKey(value: string | null | undefined) { } } +function kmStaleCompletionReadinessKey(value: string | null | undefined) { + switch (value) { + case "ready": + case "blocked": + case "completed": + case "failed": + return value; + default: + return "blocked"; + } +} + function kmStaleReviewCompleteStatusKey(value: string | null | undefined) { switch (value) { case "dry_run": @@ -1343,7 +1411,7 @@ function kmStaleReviewOutcomeForCandidate( } function staleCandidateFromOwnerReviewItem( - item: KnowledgeStaleOwnerReviewInboxItem + item: KnowledgeStaleOwnerReviewInboxItem | KnowledgeStaleOwnerReviewCompletionQueueItem ): KnowledgeStaleCandidate { return { entry_id: item.entry_id, @@ -2360,6 +2428,7 @@ function KnowledgeGovernancePanel({ staleCandidates, ownerReviewInbox, burnDown, + completionQueue, onArchived, }: { queue: GovernanceQueueResponse | null; @@ -2368,6 +2437,7 @@ function KnowledgeGovernancePanel({ staleCandidates: KnowledgeStaleCandidatesResponse | null; ownerReviewInbox: KnowledgeStaleOwnerReviewInboxResponse | null; burnDown: KnowledgeStaleOwnerReviewBurnDownResponse | null; + completionQueue: KnowledgeStaleOwnerReviewCompletionQueueResponse | null; onArchived: () => void; }) { const t = useTranslations("awooop.workItems.knowledgeGovernance"); @@ -2393,6 +2463,7 @@ function KnowledgeGovernancePanel({ const ownerReviewItems = ownerReviewInbox?.items ?? []; const burnDownItems = burnDown?.items ?? []; const burnDownSnapshot = burnDown?.current_snapshot ?? null; + const completionQueueItems = completionQueue?.items ?? []; const draftTotal = dedupe?.total_review_drafts ?? reviewDrafts?.total ?? 0; const activeCount = items.filter((item) => ["pending", "dispatched", "executing"].includes(item.dispatch_status) @@ -3109,6 +3180,185 @@ function KnowledgeGovernancePanel({ )} +
+
+
+
+
+ + {t("staleCandidates.completionQueue.ready", { + count: completionQueue?.ready_count ?? 0, + })} + + + {t("staleCandidates.completionQueue.blocked", { + count: completionQueue?.blocked_count ?? 0, + })} + + + {t("staleCandidates.completionQueue.completed", { + count: completionQueue?.completed_count ?? 0, + })} + + + {t("staleCandidates.completionQueue.failed", { + count: completionQueue?.failed_count ?? 0, + })} + +
+
+ {completionQueue === null ? ( +

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

+ ) : completionQueueItems.length === 0 ? ( +

+ {t("staleCandidates.completionQueue.empty")} +

+ ) : ( + <> +
+ + {t("staleCandidates.completionQueue.pending", { + count: completionQueue.pending_count, + })} + + + {t("staleCandidates.completionQueue.guardrail", { + writes: String(completionQueue.writes_on_read), + review: String(completionQueue.manual_review_required), + batch: String(completionQueue.batch_writes_allowed), + })} + +
+
+ {completionQueueItems.slice(0, 6).map((item) => { + const completionCandidate = staleCandidateFromOwnerReviewItem(item); + const completionAction = staleReviewCompletionActions[item.entry_id]; + const completionPreview = completionAction?.previewResult ?? null; + const completionResult = completionAction?.result ?? null; + const readinessKey = kmStaleCompletionReadinessKey(item.readiness); + return ( +
+
+
+

+ {item.title} +

+

+ {item.entry_id} +

+
+ + {item.priority_tier} + +
+

+ {t("staleCandidates.completionQueue.state", { + readiness: t(`staleCandidates.completionQueue.readiness.${readinessKey}` as never), + stage: t(`stages.${governanceKmStageKey(item.workflow_stage)}` as never), + })} +

+

+ {t("staleCandidates.completionQueue.next", { + action: item.next_action, + outcome: t(`staleCandidates.completeActions.outcomes.${item.recommended_completion_outcome}` as never), + })} +

+

+ {t("staleCandidates.ownerReviewInbox.meta", { + days: item.stale_days, + score: item.priority_score, + views: item.view_count, + })} +

+

+ {t("staleCandidates.ownerReviewInbox.batch", { + batch: item.batch_dispatch_id ?? "--", + })} +

+

+ {t("staleCandidates.completionQueue.required", { + fields: item.required_owner_fields.length > 0 + ? item.required_owner_fields.join(", ") + : "--", + })} +

+ {item.blockers.length > 0 ? ( +

+ {t("staleCandidates.completionQueue.blockers", { + blockers: item.blockers.join(", "), + })} +

+ ) : null} + {item.can_preview ? ( +
+ + +
+ ) : null} + {completionAction?.error ? ( +

+ {completionAction.error} +

+ ) : null} + {completionPreview ? ( +

+ {t("staleCandidates.completeActions.planFingerprint", { + fingerprint: completionPreview.dry_run_plan_fingerprint ?? "--", + })} +

+ ) : null} + {completionResult ? ( +

+ {t("staleCandidates.completeActions.result", { + audit: completionResult.audit_dispatch_id ?? "--", + recheck: completionResult.stale_ratio_recheck_dispatch_id ?? "--", + })} +

+ ) : null} +
+ ); + })} +
+ + )} +
@@ -4008,6 +4258,7 @@ export default function AwoooPWorkItemsPage() { knowledgeStaleCandidates: null, knowledgeStaleOwnerReviews: null, knowledgeStaleOwnerReviewBurnDown: null, + knowledgeStaleOwnerReviewCompletionQueue: null, channelEvents: null, eventRecurrence: null, slo: null, @@ -4030,6 +4281,7 @@ export default function AwoooPWorkItemsPage() { const knowledgeStaleCandidatesUrl = `${API_BASE}/api/v1/ai/governance/km-stale-candidates?project_id=${encodedProjectId}&limit=20`; const knowledgeStaleOwnerReviewsUrl = `${API_BASE}/api/v1/ai/governance/km-stale-owner-reviews?project_id=${encodedProjectId}&dispatch_status=pending&limit=30`; const knowledgeStaleOwnerReviewBurnDownUrl = `${API_BASE}/api/v1/ai/governance/km-stale-owner-review-burndown?project_id=${encodedProjectId}&limit=20`; + const knowledgeStaleOwnerReviewCompletionQueueUrl = `${API_BASE}/api/v1/ai/governance/km-stale-owner-review-completion-queue?project_id=${encodedProjectId}&status_bucket=all&limit=30`; const channelEventsUrl = `${API_BASE}/api/v1/platform/events/recent?project_id=${encodedProjectId}&provider_prefix=alertmanager&limit=20`; const recurrenceUrl = `${API_BASE}/api/v1/platform/events/dossier/recurrence?project_id=${encodedProjectId}&limit=100`; const sloUrl = `${API_BASE}/api/v1/ai/slo`; @@ -4046,6 +4298,7 @@ export default function AwoooPWorkItemsPage() { knowledgeStaleCandidates, knowledgeStaleOwnerReviews, knowledgeStaleOwnerReviewBurnDown, + knowledgeStaleOwnerReviewCompletionQueue, channelEvents, eventRecurrence, slo, @@ -4061,6 +4314,7 @@ export default function AwoooPWorkItemsPage() { fetchJson(knowledgeStaleCandidatesUrl), fetchJson(knowledgeStaleOwnerReviewsUrl), fetchJson(knowledgeStaleOwnerReviewBurnDownUrl), + fetchJson(knowledgeStaleOwnerReviewCompletionQueueUrl), fetchJson(channelEventsUrl), fetchJson(recurrenceUrl), fetchJson(sloUrl), @@ -4093,6 +4347,7 @@ export default function AwoooPWorkItemsPage() { knowledgeStaleCandidates, knowledgeStaleOwnerReviews, knowledgeStaleOwnerReviewBurnDown, + knowledgeStaleOwnerReviewCompletionQueue, channelEvents, eventRecurrence, slo, @@ -4222,6 +4477,7 @@ export default function AwoooPWorkItemsPage() { staleCandidates={telemetry.knowledgeStaleCandidates} ownerReviewInbox={telemetry.knowledgeStaleOwnerReviews} burnDown={telemetry.knowledgeStaleOwnerReviewBurnDown} + completionQueue={telemetry.knowledgeStaleOwnerReviewCompletionQueue} onArchived={fetchTelemetry} />