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.title")}
+
+
+ {t("staleCandidates.ownerReviewInbox.subtitle")}
+
+
+
+
+
+ {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}
/>