From 0c447acb196f70e647ea9dcf8ab4aeeb6e08e34e Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 24 May 2026 21:32:29 +0800 Subject: [PATCH] feat(governance): surface stale km owner review inbox --- apps/api/src/api/v1/ai_governance.py | 40 +++ apps/api/src/models/governance.py | 45 ++++ .../governance_km_stale_review_service.py | 221 +++++++++++++++++ .../api/tests/test_ai_governance_endpoints.py | 121 ++++++++++ apps/web/messages/en.json | 11 + apps/web/messages/zh-TW.json | 11 + .../app/[locale]/awooop/work-items/page.tsx | 227 ++++++++++++++++++ 7 files changed, 676 insertions(+) diff --git a/apps/api/src/api/v1/ai_governance.py b/apps/api/src/api/v1/ai_governance.py index e1906a3d..4aa86674 100644 --- a/apps/api/src/api/v1/ai_governance.py +++ b/apps/api/src/api/v1/ai_governance.py @@ -36,6 +36,7 @@ from src.models.governance import ( KnowledgeStaleOwnerReviewBatchQueueResponse, KnowledgeStaleOwnerReviewCompleteRequest, KnowledgeStaleOwnerReviewCompleteResponse, + KnowledgeStaleOwnerReviewInboxResponse, KnowledgeStaleOwnerReviewRequest, KnowledgeStaleOwnerReviewResponse, ) @@ -47,6 +48,7 @@ from src.services.governance_km_stale_review_service import ( KmStaleOwnerReviewError, batch_queue_km_stale_owner_reviews, complete_km_stale_owner_review, + query_km_stale_owner_review_inbox, queue_km_stale_owner_review, ) from src.services.governance_query_service import ( @@ -233,6 +235,44 @@ async def get_km_stale_candidates( return await query_km_stale_candidates(project_id=project_id, limit=limit) +# ============================================================================= +# GET /api/v1/ai/governance/km-stale-owner-reviews +# ============================================================================= + +@router.get( + "/ai/governance/km-stale-owner-reviews", + response_model=KnowledgeStaleOwnerReviewInboxResponse, +) +async def get_km_stale_owner_reviews( + project_id: Annotated[str, Query(min_length=1, max_length=64)] = "awoooi", + dispatch_status: Annotated[ + str, + Query(pattern="^(all|pending|dispatched|executing|succeeded|failed|skipped|cancelled)$"), + ] = "pending", + limit: Annotated[int, Query(ge=5, le=100)] = 20, +) -> KnowledgeStaleOwnerReviewInboxResponse: + """ + 查詢 stale KM owner-review 工作台。 + + 這是 read-only inbox:把 dispatch trail 與 KM priority context 合併, + 讓 operator 可以依 P0/P1、score、batch 來源與流程階段逐筆 completion。 + """ + logger.debug( + "km_stale_owner_reviews_request", + project_id=project_id, + dispatch_status=dispatch_status, + limit=limit, + ) + try: + return await query_km_stale_owner_review_inbox( + project_id=project_id, + dispatch_status=dispatch_status, + 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-candidates/batch-queue-review # ============================================================================= diff --git a/apps/api/src/models/governance.py b/apps/api/src/models/governance.py index 7cbb0b90..4c251549 100644 --- a/apps/api/src/models/governance.py +++ b/apps/api/src/models/governance.py @@ -331,6 +331,51 @@ class KnowledgeStaleOwnerReviewBatchQueueResponse(BaseModel): generated_at: datetime +class KnowledgeStaleOwnerReviewInboxItem(BaseModel): + dispatch_id: str + governance_event_id: str + entry_id: str + project_id: str + title: str + dispatch_status: str + workflow_stage: str + next_action: str | None = None + 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 KnowledgeStaleOwnerReviewInboxResponse(BaseModel): + schema_version: str = "km_stale_owner_review_inbox_v1" + project_id: str + dispatch_status: str + total: int + returned: int + writes_on_read: bool = False + manual_review_required: bool = True + items: list[KnowledgeStaleOwnerReviewInboxItem] = 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 6f0d82d7..70708d7d 100644 --- a/apps/api/src/services/governance_km_stale_review_service.py +++ b/apps/api/src/services/governance_km_stale_review_service.py @@ -35,6 +35,8 @@ from src.models.governance import ( KnowledgeStaleOwnerReviewBatchQueueResponse, KnowledgeStaleOwnerReviewCompleteRequest, KnowledgeStaleOwnerReviewCompleteResponse, + KnowledgeStaleOwnerReviewInboxItem, + KnowledgeStaleOwnerReviewInboxResponse, KnowledgeStaleOwnerReviewRequest, KnowledgeStaleOwnerReviewResponse, ) @@ -53,6 +55,15 @@ _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"}) +_OWNER_REVIEW_DISPATCH_STATUSES = frozenset({ + "pending", + "dispatched", + "executing", + "succeeded", + "failed", + "skipped", + "cancelled", +}) class KmStaleOwnerReviewError(Exception): @@ -64,6 +75,115 @@ class KmStaleOwnerReviewError(Exception): self.detail = detail +async def query_km_stale_owner_review_inbox( + *, + project_id: str = "awoooi", + dispatch_status: str = "pending", + limit: int = 20, +) -> KnowledgeStaleOwnerReviewInboxResponse: + """Read owner-review dispatches with KM priority context for the operator console.""" + if dispatch_status != "all" and dispatch_status not in _OWNER_REVIEW_DISPATCH_STATUSES: + raise KmStaleOwnerReviewError(422, "unsupported stale KM owner-review dispatch_status") + + generated_at = now_taipei() + status_filter = ( + "TRUE" + if dispatch_status == "all" + else "d.dispatch_status::text = :dispatch_status" + ) + sql = text(f""" + SELECT + d.id, + d.governance_event_id, + d.dispatch_status, + d.decision_context, + d.dispatched_at, + d.started_at, + d.completed_at, + COALESCE( + d.decision_context -> 'workflow' ->> 'entry_id', + d.decision_context ->> 'entry_id' + ) AS entry_id, + COALESCE( + d.decision_context -> 'workflow' ->> 'project_id', + d.decision_context ->> 'project_id', + d.decision_context -> 'candidate' ->> 'project_id' + ) AS project_id + FROM governance_remediation_dispatch d + WHERE d.executor_type = :executor_type + AND {status_filter} + AND COALESCE( + d.decision_context -> 'workflow' ->> 'project_id', + d.decision_context ->> 'project_id', + d.decision_context -> 'candidate' ->> 'project_id' + ) = :project_id + ORDER BY d.dispatched_at DESC + """) + params: dict[str, Any] = { + "executor_type": _EXECUTOR_TYPE, + "project_id": project_id, + } + if dispatch_status != "all": + params["dispatch_status"] = dispatch_status + + async with get_db_context() as db: + result = await db.execute(sql, params) + rows = result.fetchall() + entry_ids = [ + str(row.entry_id) + for row in rows + if isinstance(row.entry_id, str) and row.entry_id + ] + records_by_id: dict[str, KnowledgeEntryRecord] = {} + if entry_ids: + record_result = await db.execute( + select(KnowledgeEntryRecord).where(KnowledgeEntryRecord.id.in_(entry_ids)) + ) + records_by_id = { + str(record.id): record + for record in record_result.scalars().all() + } + + items: list[KnowledgeStaleOwnerReviewInboxItem] = [] + for row in rows: + entry_id = str(row.entry_id or "") + record = records_by_id.get(entry_id) + if record is None: + continue + candidate = _build_km_stale_candidate( + record, + now=generated_at, + threshold_days=KM_STALE_DAYS, + ) + decision_context = row.decision_context if isinstance(row.decision_context, dict) else {} + items.append( + _build_owner_review_inbox_item( + row=row, + candidate=candidate, + 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, + ) + + async def queue_km_stale_owner_review( *, entry_id: str, @@ -648,6 +768,107 @@ def _count_batch_items( return sum(1 for item in items if item.status == status) +def _build_owner_review_inbox_item( + *, + row: Any, + candidate: KnowledgeStaleCandidate, + decision_context: dict[str, Any], +) -> KnowledgeStaleOwnerReviewInboxItem: + dispatch_status = str(row.dispatch_status) + workflow = decision_context.get("workflow") if isinstance(decision_context.get("workflow"), dict) else {} + batch = decision_context.get("batch") if isinstance(decision_context.get("batch"), dict) else {} + return KnowledgeStaleOwnerReviewInboxItem( + dispatch_id=str(row.id), + governance_event_id=str(row.governance_event_id), + entry_id=candidate.entry_id, + project_id=candidate.project_id, + title=candidate.title, + dispatch_status=dispatch_status, + workflow_stage=_extract_owner_review_workflow_stage(decision_context, dispatch_status), + next_action=_extract_owner_review_next_action(decision_context), + owner=_extract_owner_review_owner(decision_context), + owner_note=_extract_owner_review_owner_note(decision_context), + batch_governance_event_id=_first_non_empty_string( + workflow.get("batch_governance_event_id"), + batch.get("batch_governance_event_id"), + ), + batch_dispatch_id=_first_non_empty_string( + workflow.get("batch_dispatch_id"), + batch.get("batch_dispatch_id"), + ), + priority_tier=candidate.priority_tier, + priority_score=candidate.priority_score, + recommended_action=candidate.recommended_action, + stale_days=candidate.stale_days, + view_count=candidate.view_count, + correlation_sources=candidate.correlation_sources, + reasons=candidate.reasons, + related_incident_id=candidate.related_incident_id, + related_playbook_id=candidate.related_playbook_id, + related_approval_id=candidate.related_approval_id, + dry_run_plan_fingerprint=_extract_plan_fingerprint(decision_context), + queued_at=row.dispatched_at, + started_at=row.started_at, + completed_at=row.completed_at, + ) + + +def _priority_rank(priority_tier: str) -> int: + return {"P0": 3, "P1": 2, "P2": 1}.get(priority_tier, 0) + + +def _extract_owner_review_workflow_stage( + context: dict[str, Any], + dispatch_status: str, +) -> str: + workflow = context.get("workflow") + if isinstance(workflow, dict): + stages = workflow.get("stage_by_dispatch_status") + if isinstance(stages, dict): + value = stages.get(dispatch_status) + if isinstance(value, str) and value: + return value + value = workflow.get("current_stage") + if isinstance(value, str) and value: + return value + return { + "pending": "waiting_owner_review", + "dispatched": "waiting_owner_review", + "executing": "owner_review_in_progress", + "succeeded": "km_candidate_reviewed", + "failed": "needs_manual_km_triage", + "skipped": "waiting_owner_review", + "cancelled": "cancelled", + }.get(dispatch_status, "unknown") + + +def _extract_owner_review_next_action(context: dict[str, Any]) -> str | None: + workflow = context.get("workflow") + if isinstance(workflow, dict): + value = workflow.get("next_action") + if isinstance(value, str) and value: + return value + value = context.get("next_action") + return value if isinstance(value, str) and value else None + + +def _extract_owner_review_owner(context: dict[str, Any]) -> str | None: + value = context.get("owner") + return value if isinstance(value, str) and value else None + + +def _extract_owner_review_owner_note(context: dict[str, Any]) -> str | None: + value = context.get("owner_note") + return value if isinstance(value, str) and value else None + + +def _first_non_empty_string(*values: Any) -> str | None: + for value in values: + if isinstance(value, str) and value: + return value + return None + + async def complete_km_stale_owner_review( *, entry_id: str, diff --git a/apps/api/tests/test_ai_governance_endpoints.py b/apps/api/tests/test_ai_governance_endpoints.py index 97ec013b..efc76c61 100644 --- a/apps/api/tests/test_ai_governance_endpoints.py +++ b/apps/api/tests/test_ai_governance_endpoints.py @@ -42,6 +42,8 @@ from src.models.governance import ( KnowledgeStaleOwnerReviewBatchQueueResponse, KnowledgeStaleOwnerReviewCompleteRequest, KnowledgeStaleOwnerReviewCompleteResponse, + KnowledgeStaleOwnerReviewInboxItem, + KnowledgeStaleOwnerReviewInboxResponse, KnowledgeStaleOwnerReviewRequest, KnowledgeStaleOwnerReviewResponse, map_severity, @@ -60,6 +62,7 @@ from src.services.governance_km_stale_review_service import ( _build_batch_queue_plan_fingerprint, _build_completion_plan_fingerprint, _build_owner_review_completion_audit_context, + _build_owner_review_inbox_item, _build_stale_owner_review_decision_context, _completion_stage_for_outcome, ) @@ -878,6 +881,124 @@ class TestKmReviewDraftDedupe: assert r.status_code == 409 assert "batch queue plan" in r.json()["detail"] + def test_owner_review_inbox_endpoint_returns_sorted_work_items(self, client): + """Owner-review inbox 應回傳 pending dispatch 與 KM priority context。""" + fake = KnowledgeStaleOwnerReviewInboxResponse( + project_id="awoooi", + dispatch_status="pending", + total=1, + returned=1, + items=[ + KnowledgeStaleOwnerReviewInboxItem( + dispatch_id="dispatch-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", + batch_governance_event_id="event-batch-001", + batch_dispatch_id="dispatch-batch-001", + 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", + related_playbook_id="pb:auto-repair-canary", + queued_at=NOW, + ) + ], + generated_at=NOW, + ) + captured: dict = {} + + async def mock_inbox(**kwargs): + captured.update(kwargs) + return fake + + with patch( + "src.api.v1.ai_governance.query_km_stale_owner_review_inbox", + new=mock_inbox, + ): + r = client.get( + "/api/v1/ai/governance/km-stale-owner-reviews" + "?project_id=awoooi&dispatch_status=pending&limit=20" + ) + + assert r.status_code == 200 + assert captured == { + "project_id": "awoooi", + "dispatch_status": "pending", + "limit": 20, + } + data = r.json() + assert data["schema_version"] == "km_stale_owner_review_inbox_v1" + assert data["total"] == 1 + assert data["writes_on_read"] is False + assert data["manual_review_required"] is True + assert data["items"][0]["dispatch_id"] == "dispatch-001" + assert data["items"][0]["workflow_stage"] == "waiting_owner_review" + assert data["items"][0]["batch_dispatch_id"] == "dispatch-batch-001" + + def test_owner_review_inbox_context_keeps_batch_and_priority_visible(self): + record = KnowledgeEntryRecord( + id="km-001", + project_id="awoooi", + title="Sentry checkout failure repair", + content="Use Sentry and SigNoz evidence before writeback.", + entry_type=EntryType.AUTO_RUNBOOK, + category="AI系統", + tags=["sentry", "signoz"], + source=EntrySource.AI_EXTRACTED, + status=EntryStatus.REVIEW, + related_incident_id="INC-20260513-79ED5E", + related_playbook_id="pb:auto-repair-canary", + view_count=7, + updated_at=NOW - timedelta(days=35), + ) + candidate = _build_km_stale_candidate(record, now=NOW, threshold_days=7) + row = type("Row", (), { + "id": "dispatch-001", + "governance_event_id": "event-001", + "dispatch_status": "pending", + "dispatched_at": NOW, + "started_at": None, + "completed_at": None, + })() + ctx = { + "owner": "operator_console", + "owner_note": "p0_p1_stale_km_batch", + "next_action": "owner_review_stale_km_candidate", + "workflow": { + "entry_id": "km-001", + "project_id": "awoooi", + "batch_governance_event_id": "event-batch-001", + "batch_dispatch_id": "dispatch-batch-001", + "current_stage": "waiting_owner_review", + "stage_by_dispatch_status": {"pending": "waiting_owner_review"}, + }, + } + + item = _build_owner_review_inbox_item( + row=row, + candidate=candidate, + decision_context=ctx, + ) + + assert item.dispatch_id == "dispatch-001" + assert item.batch_dispatch_id == "dispatch-batch-001" + assert item.priority_tier == "P0" + assert item.priority_score > 200 + assert item.workflow_stage == "waiting_owner_review" + assert item.recommended_action == "refresh_with_evidence" + assert item.correlation_sources == ["incident", "playbook", "sentry", "signoz"] + 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 79ce6642..e7888b03 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -2099,6 +2099,17 @@ "queued": "Queued for owner review", "already_queued": "Already in owner review" }, + "ownerReviewInbox": { + "title": "Owner Review Inbox", + "subtitle": "Shows P0/P1 KM already waiting for owner review, with per-item dry-run and completion.", + "total": "Pending {count}", + "returned": "Shown {count}", + "unavailable": "The owner-review inbox API has not responded; use the candidate list for single-item actions.", + "empty": "No pending owner-review KM.", + "meta": "Stale {days}d; score {score}; views {views}", + "state": "Status: {status}; stage: {stage}", + "batch": "Batch: {batch}" + }, "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 6457792a..66217002 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -2100,6 +2100,17 @@ "queued": "已排入 owner review", "already_queued": "已在 owner review" }, + "ownerReviewInbox": { + "title": "Owner review 工作台", + "subtitle": "顯示已排入 waiting_owner_review 的 P0/P1 KM,逐筆乾跑與確認完成。", + "total": "待審 {count}", + "returned": "顯示 {count}", + "unavailable": "owner-review inbox API 尚未回應;目前只能從候選清單逐筆操作。", + "empty": "目前沒有 pending owner-review KM。", + "meta": "陳舊 {days} 天;分數 {score};瀏覽 {views}", + "state": "狀態:{status};階段:{stage}", + "batch": "Batch:{batch}" + }, "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 bef06f77..8170ffb0 100644 --- a/apps/web/src/app/[locale]/awooop/work-items/page.tsx +++ b/apps/web/src/app/[locale]/awooop/work-items/page.tsx @@ -489,6 +489,47 @@ type KnowledgeStaleOwnerReviewBatchAction = { error: string | null; }; +type KnowledgeStaleOwnerReviewInboxItem = { + dispatch_id: string; + governance_event_id: string; + entry_id: string; + project_id: string; + title: string; + dispatch_status: string; + workflow_stage: string; + next_action?: string | null; + 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 KnowledgeStaleOwnerReviewInboxResponse = { + schema_version?: string; + project_id: string; + dispatch_status: string; + total: number; + returned: number; + writes_on_read: boolean; + manual_review_required: boolean; + items: KnowledgeStaleOwnerReviewInboxItem[]; + generated_at?: string | null; +}; + type KnowledgeStaleOwnerReviewCompleteResponse = { schema_version?: string; entry_id: string; @@ -608,6 +649,7 @@ type Telemetry = { knowledgeReviewDrafts: KnowledgeListResponse | null; knowledgeReviewDedupe: KnowledgeReviewDraftDedupeResponse | null; knowledgeStaleCandidates: KnowledgeStaleCandidatesResponse | null; + knowledgeStaleOwnerReviews: KnowledgeStaleOwnerReviewInboxResponse | null; channelEvents: RecentEventsResponse | null; eventRecurrence: RecurrenceResponse | null; slo: SloResponse | null; @@ -1247,6 +1289,33 @@ function kmStaleReviewOutcomeForCandidate( return "refresh_with_evidence"; } +function staleCandidateFromOwnerReviewItem( + item: KnowledgeStaleOwnerReviewInboxItem +): KnowledgeStaleCandidate { + return { + entry_id: item.entry_id, + project_id: item.project_id, + title: item.title, + entry_type: "auto_runbook", + status: "review", + stale_days: item.stale_days, + view_count: item.view_count, + priority_score: item.priority_score, + priority_tier: item.priority_tier, + recommended_action: item.recommended_action, + reasons: item.reasons, + correlation_sources: item.correlation_sources, + related_incident_id: item.related_incident_id, + related_playbook_id: item.related_playbook_id, + related_approval_id: item.related_approval_id, + tags: [], + owner_review_dispatch_id: item.dispatch_id, + owner_review_status: item.dispatch_status, + owner_review_stage: item.workflow_stage, + owner_review_next_action: item.next_action, + }; +} + function kmCorrelationSourceKey(value: string | null | undefined) { switch (value) { case "incident": @@ -2236,12 +2305,14 @@ function KnowledgeGovernancePanel({ reviewDrafts, dedupe, staleCandidates, + ownerReviewInbox, onArchived, }: { queue: GovernanceQueueResponse | null; reviewDrafts: KnowledgeListResponse | null; dedupe: KnowledgeReviewDraftDedupeResponse | null; staleCandidates: KnowledgeStaleCandidatesResponse | null; + ownerReviewInbox: KnowledgeStaleOwnerReviewInboxResponse | null; onArchived: () => void; }) { const t = useTranslations("awooop.workItems.knowledgeGovernance"); @@ -2264,6 +2335,7 @@ function KnowledgeGovernancePanel({ ?? draftGroups.reduce((sum, group) => sum + group.duplicateCount, 0) ); const staleCandidateItems = staleCandidates?.items ?? []; + const ownerReviewItems = ownerReviewInbox?.items ?? []; const draftTotal = dedupe?.total_review_drafts ?? reviewDrafts?.total ?? 0; const activeCount = items.filter((item) => ["pending", "dispatched", "executing"].includes(item.dispatch_status) @@ -2829,6 +2901,155 @@ function KnowledgeGovernancePanel({ ) : null} +
+
+
+
+
+ + {t("staleCandidates.ownerReviewInbox.total", { + count: ownerReviewInbox?.total ?? 0, + })} + + + {t("staleCandidates.ownerReviewInbox.returned", { + count: ownerReviewInbox?.returned ?? 0, + })} + +
+
+ {ownerReviewInbox === null ? ( +

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

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

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

+ ) : ( +
+ {ownerReviewItems.slice(0, 6).map((item) => { + const inboxCandidate = staleCandidateFromOwnerReviewItem(item); + const completionAction = staleReviewCompletionActions[item.entry_id]; + const completionPreview = completionAction?.previewResult ?? null; + const completionResult = completionAction?.result ?? null; + const completionPreviewReady = Boolean(completionPreview?.dry_run_plan_fingerprint); + const completionPreviewStatusKey = kmStaleReviewCompleteStatusKey(completionPreview?.status); + const completionResultStatusKey = kmStaleReviewCompleteStatusKey(completionResult?.status); + return ( +
+
+
+

+ {item.title} +

+

+ {item.dispatch_id} +

+
+ + {item.priority_tier} + +
+
+

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

+

+ {t("staleCandidates.ownerReviewInbox.state", { + status: t(`statuses.${governanceKmDispatchStatusKey(item.dispatch_status)}` as never), + stage: t(`stages.${governanceKmStageKey(item.workflow_stage)}` as never), + })} +

+

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

+

+ {t("staleCandidates.refs", { + incident: item.related_incident_id ?? "--", + playbook: item.related_playbook_id ?? "--", + approval: item.related_approval_id ?? "--", + })} +

+
+
+ + +
+ {completionAction?.error ? ( +

+ {completionAction.error} +

+ ) : null} + {completionPreview ? ( +

+ {t( + `staleCandidates.completeActions.statuses.${completionPreviewStatusKey}` as never + )}{" "} + {t("staleCandidates.completeActions.planFingerprint", { + fingerprint: completionPreview.dry_run_plan_fingerprint ?? "--", + })} +

+ ) : null} + {completionResult ? ( +

+ {t( + `staleCandidates.completeActions.statuses.${completionResultStatusKey}` as never + )}{" "} + {t("staleCandidates.completeActions.result", { + audit: completionResult.audit_dispatch_id ?? "--", + recheck: completionResult.stale_ratio_recheck_dispatch_id ?? "--", + })} +

+ ) : null} +
+ ); + })} +
+ )} +
{staleCandidates === null ? (

{t("staleCandidates.unavailable")} @@ -3577,6 +3798,7 @@ export default function AwoooPWorkItemsPage() { knowledgeReviewDrafts: null, knowledgeReviewDedupe: null, knowledgeStaleCandidates: null, + knowledgeStaleOwnerReviews: null, channelEvents: null, eventRecurrence: null, slo: null, @@ -3597,6 +3819,7 @@ export default function AwoooPWorkItemsPage() { const knowledgeReviewDraftsUrl = `${API_BASE}/api/v1/knowledge?entry_type=auto_runbook&status=review&q=${encodeURIComponent("KM healthcheck")}&limit=100`; const knowledgeReviewDedupeUrl = `${API_BASE}/api/v1/ai/governance/km-review-drafts/dedupe?limit=100`; 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 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`; @@ -3611,6 +3834,7 @@ export default function AwoooPWorkItemsPage() { knowledgeReviewDrafts, knowledgeReviewDedupe, knowledgeStaleCandidates, + knowledgeStaleOwnerReviews, channelEvents, eventRecurrence, slo, @@ -3624,6 +3848,7 @@ export default function AwoooPWorkItemsPage() { fetchJson(knowledgeReviewDraftsUrl), fetchJson(knowledgeReviewDedupeUrl), fetchJson(knowledgeStaleCandidatesUrl), + fetchJson(knowledgeStaleOwnerReviewsUrl), fetchJson(channelEventsUrl), fetchJson(recurrenceUrl), fetchJson(sloUrl), @@ -3654,6 +3879,7 @@ export default function AwoooPWorkItemsPage() { knowledgeReviewDrafts, knowledgeReviewDedupe, knowledgeStaleCandidates, + knowledgeStaleOwnerReviews, channelEvents, eventRecurrence, slo, @@ -3781,6 +4007,7 @@ export default function AwoooPWorkItemsPage() { reviewDrafts={telemetry.knowledgeReviewDrafts} dedupe={telemetry.knowledgeReviewDedupe} staleCandidates={telemetry.knowledgeStaleCandidates} + ownerReviewInbox={telemetry.knowledgeStaleOwnerReviews} onArchived={fetchTelemetry} />