feat(governance): surface stale km owner review inbox
All checks were successful
CD Pipeline / tests (push) Successful in 5m29s
Code Review / ai-code-review (push) Successful in 16s
Type Sync Check / check-type-sync (push) Successful in 28s
CD Pipeline / build-and-deploy (push) Successful in 4m12s
CD Pipeline / post-deploy-checks (push) Successful in 1m30s

This commit is contained in:
Your Name
2026-05-24 21:32:29 +08:00
parent d04377dd20
commit 0c447acb19
7 changed files with 676 additions and 0 deletions

View File

@@ -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
# =============================================================================

View File

@@ -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,

View File

@@ -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,

View File

@@ -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",

View File

@@ -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.",

View File

@@ -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。",

View File

@@ -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({
</div>
) : null}
</div>
<div className="mb-3 border border-[#e0ddd4] bg-white px-3 py-2 text-xs text-[#5f5b52]">
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<ClipboardList className="h-4 w-4 text-[#5f5b52]" aria-hidden="true" />
<div>
<p className="font-semibold text-[#141413]">
{t("staleCandidates.ownerReviewInbox.title")}
</p>
<p className="mt-1 text-[11px] leading-5 text-[#77736a]">
{t("staleCandidates.ownerReviewInbox.subtitle")}
</p>
</div>
</div>
<div className="flex flex-wrap gap-2 font-mono text-[11px] text-[#5f5b52]">
<span className="border border-[#d8d3c7] bg-[#faf9f3] px-2 py-0.5">
{t("staleCandidates.ownerReviewInbox.total", {
count: ownerReviewInbox?.total ?? 0,
})}
</span>
<span className="border border-[#d8d3c7] bg-[#faf9f3] px-2 py-0.5">
{t("staleCandidates.ownerReviewInbox.returned", {
count: ownerReviewInbox?.returned ?? 0,
})}
</span>
</div>
</div>
{ownerReviewInbox === null ? (
<p className="text-[11px] leading-5 text-[#8a5a08]">
{t("staleCandidates.ownerReviewInbox.unavailable")}
</p>
) : ownerReviewItems.length === 0 ? (
<p className="text-[11px] leading-5 text-[#5f5b52]">
{t("staleCandidates.ownerReviewInbox.empty")}
</p>
) : (
<div className="grid gap-2 md:grid-cols-2">
{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 (
<article
key={item.dispatch_id}
className="border border-[#e0ddd4] bg-[#faf9f3] px-3 py-2 text-[11px] leading-5 text-[#5f5b52]"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate font-semibold text-[#141413]">
{item.title}
</p>
<p className="mt-1 truncate font-mono text-[#77736a]">
{item.dispatch_id}
</p>
</div>
<span className="shrink-0 border border-[#d9b36f] bg-[#fff7e8] px-2 py-0.5 font-mono font-semibold text-[#8a5a08]">
{item.priority_tier}
</span>
</div>
<div className="mt-2 grid gap-1">
<p>
{t("staleCandidates.ownerReviewInbox.meta", {
days: item.stale_days,
score: item.priority_score,
views: item.view_count,
})}
</p>
<p>
{t("staleCandidates.ownerReviewInbox.state", {
status: t(`statuses.${governanceKmDispatchStatusKey(item.dispatch_status)}` as never),
stage: t(`stages.${governanceKmStageKey(item.workflow_stage)}` as never),
})}
</p>
<p className="truncate">
{t("staleCandidates.ownerReviewInbox.batch", {
batch: item.batch_dispatch_id ?? "--",
})}
</p>
<p>
{t("staleCandidates.refs", {
incident: item.related_incident_id ?? "--",
playbook: item.related_playbook_id ?? "--",
approval: item.related_approval_id ?? "--",
})}
</p>
</div>
<div className="mt-2 flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => previewStaleReviewCompletion(inboxCandidate)}
disabled={completionAction?.previewLoading || completionAction?.confirmLoading}
className="inline-flex items-center gap-1.5 border border-[#d8d3c7] bg-white px-2 py-1 text-xs font-semibold text-[#2e2b26] hover:border-[#9bc7a4] hover:bg-[#f0faf2] hover:text-[#17602a] disabled:cursor-not-allowed disabled:opacity-60"
>
<SearchCheck className="h-3.5 w-3.5" aria-hidden="true" />
{completionAction?.previewLoading
? t("staleCandidates.completeActions.previewing")
: t("staleCandidates.completeActions.preview")}
</button>
<button
type="button"
onClick={() => confirmStaleReviewCompletion(inboxCandidate)}
disabled={
!completionPreviewReady ||
completionAction?.previewLoading ||
completionAction?.confirmLoading
}
className="inline-flex items-center gap-1.5 border border-[#d8d3c7] bg-white px-2 py-1 text-xs font-semibold text-[#2e2b26] hover:border-[#d9b36f] hover:bg-[#fff7e8] hover:text-[#8a5a08] disabled:cursor-not-allowed disabled:opacity-60"
>
<ShieldCheck className="h-3.5 w-3.5" aria-hidden="true" />
{completionAction?.confirmLoading
? t("staleCandidates.completeActions.confirming")
: t("staleCandidates.completeActions.confirm")}
</button>
</div>
{completionAction?.error ? (
<p className="mt-2 border border-[#e2a29b] bg-[#fff0ef] px-2 py-1 text-[#9f2f25]">
{completionAction.error}
</p>
) : null}
{completionPreview ? (
<p className="mt-2 border border-[#d9b36f] bg-[#fff7e8] px-2 py-1 text-[#8a5a08]">
{t(
`staleCandidates.completeActions.statuses.${completionPreviewStatusKey}` as never
)}{" "}
{t("staleCandidates.completeActions.planFingerprint", {
fingerprint: completionPreview.dry_run_plan_fingerprint ?? "--",
})}
</p>
) : null}
{completionResult ? (
<p className="mt-2 border border-[#9bc7a4] bg-[#f0faf2] px-2 py-1 text-[#17602a]">
{t(
`staleCandidates.completeActions.statuses.${completionResultStatusKey}` as never
)}{" "}
{t("staleCandidates.completeActions.result", {
audit: completionResult.audit_dispatch_id ?? "--",
recheck: completionResult.stale_ratio_recheck_dispatch_id ?? "--",
})}
</p>
) : null}
</article>
);
})}
</div>
)}
</div>
{staleCandidates === null ? (
<p className="text-xs text-[#8a5a08]">
{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<KnowledgeListResponse>(knowledgeReviewDraftsUrl),
fetchJson<KnowledgeReviewDraftDedupeResponse>(knowledgeReviewDedupeUrl),
fetchJson<KnowledgeStaleCandidatesResponse>(knowledgeStaleCandidatesUrl),
fetchJson<KnowledgeStaleOwnerReviewInboxResponse>(knowledgeStaleOwnerReviewsUrl),
fetchJson<RecentEventsResponse>(channelEventsUrl),
fetchJson<RecurrenceResponse>(recurrenceUrl),
fetchJson<SloResponse>(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}
/>