diff --git a/apps/api/src/api/v1/ai_governance.py b/apps/api/src/api/v1/ai_governance.py index a0641702..92985f50 100644 --- a/apps/api/src/api/v1/ai_governance.py +++ b/apps/api/src/api/v1/ai_governance.py @@ -32,11 +32,17 @@ from src.models.governance import ( KnowledgeReviewDraftArchiveResponse, KnowledgeReviewDraftDedupeResponse, KnowledgeStaleCandidatesResponse, + KnowledgeStaleOwnerReviewRequest, + KnowledgeStaleOwnerReviewResponse, ) from src.services.governance_km_review_service import ( KmReviewDraftArchiveError, archive_km_review_draft_duplicates, ) +from src.services.governance_km_stale_review_service import ( + KmStaleOwnerReviewError, + queue_km_stale_owner_review, +) from src.services.governance_query_service import ( query_governance_events, query_governance_queue, @@ -221,6 +227,36 @@ async def get_km_stale_candidates( return await query_km_stale_candidates(project_id=project_id, limit=limit) +# ============================================================================= +# POST /api/v1/ai/governance/km-stale-candidates/{entry_id}/queue-review +# ============================================================================= + +@router.post( + "/ai/governance/km-stale-candidates/{entry_id}/queue-review", + response_model=KnowledgeStaleOwnerReviewResponse, +) +async def post_km_stale_candidate_queue_review( + entry_id: str, + request: KnowledgeStaleOwnerReviewRequest, +) -> KnowledgeStaleOwnerReviewResponse: + """ + 將單筆 stale KM candidate 排入 owner review。 + + 這個 endpoint 只建立治理事件與 dispatch work item,不修改 KM 內容。 + 實際 refresh / archive / supersede 仍需 owner 在後續流程確認。 + """ + logger.info( + "km_stale_candidate_queue_review_request", + entry_id=entry_id, + owner=request.owner, + dry_run=request.dry_run, + ) + try: + return await queue_km_stale_owner_review(entry_id=entry_id, request=request) + except KmStaleOwnerReviewError as exc: + raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc + + # ============================================================================= # GET /api/v1/ai/governance/summary # ============================================================================= diff --git a/apps/api/src/models/governance.py b/apps/api/src/models/governance.py index 09e7aead..91fe8ef8 100644 --- a/apps/api/src/models/governance.py +++ b/apps/api/src/models/governance.py @@ -241,6 +241,33 @@ class KnowledgeStaleCandidatesResponse(BaseModel): generated_at: datetime +class KnowledgeStaleOwnerReviewRequest(BaseModel): + owner: str = Field(default="operator_console", min_length=1, max_length=100) + owner_note: str | None = Field(default=None, max_length=240) + dry_run: bool = False + + +class KnowledgeStaleOwnerReviewResponse(BaseModel): + schema_version: str = "km_stale_owner_review_v1" + entry_id: str + project_id: str + status: Literal["dry_run", "queued", "already_queued"] + governance_event_id: str | None = None + dispatch_id: str | None = None + workflow_stage: str + recommended_action: Literal[ + "refresh_with_evidence", + "owner_review", + "archive_or_supersede", + ] + owner: str + owner_note: str | None = None + writes_km: bool = False + writes_governance_audit: bool + next_action: str = "owner_review_stale_km_candidate" + generated_at: datetime + + # ============================================================================= # Endpoint 3: summary # ============================================================================= diff --git a/apps/api/src/services/governance_km_stale_review_service.py b/apps/api/src/services/governance_km_stale_review_service.py new file mode 100644 index 00000000..92b0c5d3 --- /dev/null +++ b/apps/api/src/services/governance_km_stale_review_service.py @@ -0,0 +1,343 @@ +""" +Governance KM Stale Review Service +================================== + +Owner-review intake for stale KM priority candidates. + +這層只把 stale KM 候選排入治理工作項與 audit trail,不改寫 KM 內容。 +真正 refresh / archive / supersede 仍需 owner 後續審核。 +""" + +from __future__ import annotations + +from datetime import timedelta +from typing import Any, Literal + +import structlog +from sqlalchemy import select, text + +from src.db.base import get_db_context +from src.db.models import ( + AiGovernanceEvent, + GovernanceRemediationDispatch, + KnowledgeEntryRecord, + generate_uuid, + taipei_now, +) +from src.models.governance import ( + KnowledgeStaleOwnerReviewRequest, + KnowledgeStaleOwnerReviewResponse, +) +from src.models.knowledge import EntryStatus +from src.services.governance_agent import KM_STALE_DAYS, KM_STALE_RATIO +from src.services.governance_query_service import _build_km_stale_candidate +from src.utils.timezone import now_taipei + +logger = structlog.get_logger(__name__) + +_EXECUTOR_TYPE = "hermes_km_stale_owner_review" + + +class KmStaleOwnerReviewError(Exception): + """KM stale owner-review request failed validation.""" + + def __init__(self, status_code: int, detail: str) -> None: + super().__init__(detail) + self.status_code = status_code + self.detail = detail + + +async def queue_km_stale_owner_review( + *, + entry_id: str, + request: KnowledgeStaleOwnerReviewRequest, +) -> KnowledgeStaleOwnerReviewResponse: + """Queue a stale KM candidate for owner review without modifying KM content.""" + record = await _load_stale_candidate_record(entry_id) + candidate = _build_km_stale_candidate( + record, + now=now_taipei(), + threshold_days=KM_STALE_DAYS, + ) + + existing = await _load_active_owner_review_dispatch(entry_id) + if existing is not None: + return _build_response( + entry_id=entry_id, + project_id=candidate.project_id, + status="already_queued", + governance_event_id=str(existing.governance_event_id), + dispatch_id=str(existing.id), + recommended_action=candidate.recommended_action, + owner=request.owner, + owner_note=request.owner_note, + writes_governance_audit=False, + ) + + if request.dry_run: + return _build_response( + entry_id=entry_id, + project_id=candidate.project_id, + status="dry_run", + recommended_action=candidate.recommended_action, + owner=request.owner, + owner_note=request.owner_note, + writes_governance_audit=False, + ) + + now = taipei_now() + event_id = generate_uuid() + dispatch_id = generate_uuid() + event_details = _build_stale_owner_review_event_details( + entry_id=entry_id, + candidate=candidate.model_dump(mode="json"), + owner=request.owner, + owner_note=request.owner_note, + ) + decision_context = _build_stale_owner_review_decision_context( + governance_event_id=event_id, + entry_id=entry_id, + candidate=candidate.model_dump(mode="json"), + owner=request.owner, + owner_note=request.owner_note, + ) + + async with get_db_context() as db: + event = AiGovernanceEvent( + id=event_id, + event_type="knowledge_degradation", + triggered_at=now, + details=event_details, + resolved=False, + ) + dispatch = GovernanceRemediationDispatch( + id=dispatch_id, + governance_event_id=event_id, + event_type="knowledge_degradation", + dispatch_status="pending", + playbook_id=None, + incident_id=None, + approval_id=None, + decision_context=decision_context, + executor_type=_EXECUTOR_TYPE, + attempt_count=0, + max_attempts=1, + dispatched_at=now, + created_by=request.owner[:100], + ) + db.add(event) + db.add(dispatch) + await db.flush() + + logger.info( + "km_stale_owner_review_queued", + entry_id=entry_id, + project_id=candidate.project_id, + governance_event_id=event_id, + dispatch_id=dispatch_id, + recommended_action=candidate.recommended_action, + ) + return _build_response( + entry_id=entry_id, + project_id=candidate.project_id, + status="queued", + governance_event_id=event_id, + dispatch_id=dispatch_id, + recommended_action=candidate.recommended_action, + owner=request.owner, + owner_note=request.owner_note, + writes_governance_audit=True, + ) + + +async def _load_stale_candidate_record(entry_id: str) -> KnowledgeEntryRecord: + cutoff = now_taipei() - timedelta(days=KM_STALE_DAYS) + async with get_db_context() as db: + result = await db.execute( + select(KnowledgeEntryRecord).where(KnowledgeEntryRecord.id == entry_id) + ) + record = result.scalar_one_or_none() + + if record is None: + raise KmStaleOwnerReviewError(404, "KM entry not found") + if _enum_value(record.status) == EntryStatus.ARCHIVED.value: + raise KmStaleOwnerReviewError(409, "archived KM entries cannot be queued for stale review") + updated_at = record.updated_at + if updated_at is not None and updated_at.tzinfo is None: + updated_at = updated_at.replace(tzinfo=cutoff.tzinfo) + if updated_at is None or updated_at >= cutoff: + raise KmStaleOwnerReviewError(409, "KM entry is no longer past the stale threshold") + return record + + +async def _load_active_owner_review_dispatch( + entry_id: str, +) -> GovernanceRemediationDispatch | None: + sql = text(""" + SELECT * + FROM governance_remediation_dispatch d + WHERE d.executor_type = :executor_type + AND d.dispatch_status::text IN ('pending', 'dispatched', 'executing') + AND ( + d.decision_context -> 'workflow' ->> 'entry_id' = :entry_id + OR d.decision_context ->> 'entry_id' = :entry_id + ) + ORDER BY d.dispatched_at DESC + LIMIT 1 + """) + async with get_db_context() as db: + result = await db.execute( + select(GovernanceRemediationDispatch).from_statement(sql), + {"executor_type": _EXECUTOR_TYPE, "entry_id": entry_id}, + ) + return result.scalar_one_or_none() + + +def _build_stale_owner_review_event_details( + *, + entry_id: str, + candidate: dict[str, Any], + owner: str, + owner_note: str | None, +) -> dict[str, Any]: + return { + "schema_version": "km_stale_owner_review_event_v1", + "trigger_source": "stale_km_priority_queue", + "next_action": "owner_review_stale_km_candidate", + "impact": { + "status": "waiting_owner_review", + "metric": "stale_days", + "value": candidate.get("stale_days"), + "entry_id": entry_id, + "priority_tier": candidate.get("priority_tier"), + "priority_score": candidate.get("priority_score"), + "stale_ratio_threshold": KM_STALE_RATIO, + "stale_days_threshold": KM_STALE_DAYS, + }, + "remediation": { + "next_action": "owner_review_stale_km_candidate", + "items": [ + "review_current_incident_sentry_signoz_playbook_evidence", + "refresh_archive_or_supersede_after_owner_approval", + "run_stale_ratio_recheck_after_writeback", + ], + }, + "ownership": _stale_owner_review_ownership(), + "candidate": candidate, + "owner": owner, + "owner_note": owner_note, + } + + +def _build_stale_owner_review_decision_context( + *, + governance_event_id: str, + entry_id: str, + candidate: dict[str, Any], + owner: str, + owner_note: str | None, +) -> dict[str, Any]: + recommended_action = str(candidate.get("recommended_action") or "owner_review") + return { + "schema_version": "km_stale_owner_review_dispatch_v1", + "version": "v1", + "trigger_source": "stale_km_priority_queue", + "triggered_metric": "knowledge_degradation", + "metric_value": candidate.get("stale_days"), + "threshold": KM_STALE_DAYS, + "suggested_action": recommended_action, + "next_action": "owner_review_stale_km_candidate", + "decision_path": "pending_owner_review", + "ownership": _stale_owner_review_ownership(), + "entry_id": entry_id, + "candidate": candidate, + "workflow": { + "work_item_id": ( + "governance:knowledge_degradation:" + f"{governance_event_id}:km_stale_owner_review:{entry_id}" + ), + "work_kind": "km_stale_owner_review", + "current_stage": "waiting_owner_review", + "entry_id": entry_id, + "project_id": candidate.get("project_id"), + "priority_tier": candidate.get("priority_tier"), + "priority_score": candidate.get("priority_score"), + "recommended_action": recommended_action, + "steps": [ + "detected", + "prioritized_stale_candidate", + "waiting_owner_review", + "owner_updates_or_archives_km", + "stale_ratio_recheck", + ], + "stage_by_dispatch_status": { + "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", + }, + "next_action": "owner_review_stale_km_candidate", + "needs_human_review": True, + "writes_km_without_approval": False, + "writes_km": False, + "stale_ratio_recheck_after_writeback": True, + }, + "worker_result": { + "status": "queued_owner_review", + "entry_id": entry_id, + "recommended_action": recommended_action, + "writes_km": False, + }, + "owner": owner, + "owner_note": owner_note, + } + + +def _stale_owner_review_ownership() -> dict[str, Any]: + return { + "lead_agent": "Hermes", + "support_agents": [ + "OpenClaw:補 Incident / 規則 / PlayBook 脈絡,不直接批量改寫 KM。", + "ElephantAlpha:read-only 稽核 owner review 草稿與風險。", + ], + "human_owner": "KM owner / SRE owner", + } + + +def _build_response( + *, + entry_id: str, + project_id: str, + status: Literal["dry_run", "queued", "already_queued"], + recommended_action: Literal[ + "refresh_with_evidence", + "owner_review", + "archive_or_supersede", + ], + owner: str, + writes_governance_audit: bool, + owner_note: str | None = None, + governance_event_id: str | None = None, + dispatch_id: str | None = None, +) -> KnowledgeStaleOwnerReviewResponse: + return KnowledgeStaleOwnerReviewResponse( + entry_id=entry_id, + project_id=project_id, + status=status, + governance_event_id=governance_event_id, + dispatch_id=dispatch_id, + workflow_stage="waiting_owner_review", + recommended_action=recommended_action, + owner=owner, + owner_note=owner_note, + writes_km=False, + writes_governance_audit=writes_governance_audit, + generated_at=now_taipei(), + ) + + +def _enum_value(value: Any) -> str: + return str(value.value if hasattr(value, "value") else value) diff --git a/apps/api/tests/test_ai_governance_endpoints.py b/apps/api/tests/test_ai_governance_endpoints.py index b4295b77..d274a693 100644 --- a/apps/api/tests/test_ai_governance_endpoints.py +++ b/apps/api/tests/test_ai_governance_endpoints.py @@ -37,6 +37,8 @@ from src.models.governance import ( KnowledgeReviewDraftStaleRatioSnapshot, KnowledgeStaleCandidate, KnowledgeStaleCandidatesResponse, + KnowledgeStaleOwnerReviewRequest, + KnowledgeStaleOwnerReviewResponse, map_severity, ) from src.models.knowledge import EntrySource, EntryStatus, EntryType @@ -47,6 +49,10 @@ from src.services.governance_km_review_service import ( _validate_archive_request_against_plan, _validate_dry_run_plan_fingerprint, ) +from src.services.governance_km_stale_review_service import ( + KmStaleOwnerReviewError, + _build_stale_owner_review_decision_context, +) from src.services.governance_query_service import ( _build_km_review_draft_dedupe_groups, _build_km_stale_candidate, @@ -689,6 +695,102 @@ class TestKmReviewDraftDedupe: assert "sentry_context" in candidate.reasons assert "signoz_context" in candidate.reasons + def test_queue_stale_candidate_endpoint_returns_owner_review_dispatch(self, client): + """單筆 stale KM 可以排入 owner review,但不直接改寫 KM。""" + fake = KnowledgeStaleOwnerReviewResponse( + entry_id="km-001", + project_id="awoooi", + status="queued", + governance_event_id="event-001", + dispatch_id="dispatch-001", + workflow_stage="waiting_owner_review", + recommended_action="refresh_with_evidence", + owner="operator_console", + owner_note="prioritize P0", + writes_km=False, + writes_governance_audit=True, + generated_at=NOW, + ) + captured: dict = {} + + async def mock_queue(**kwargs): + captured.update(kwargs) + return fake + + with patch( + "src.api.v1.ai_governance.queue_km_stale_owner_review", + new=mock_queue, + ): + r = client.post( + "/api/v1/ai/governance/km-stale-candidates/km-001/queue-review", + json={ + "owner": "operator_console", + "owner_note": "prioritize P0", + "dry_run": False, + }, + ) + + assert r.status_code == 200 + assert captured["entry_id"] == "km-001" + assert isinstance(captured["request"], KnowledgeStaleOwnerReviewRequest) + data = r.json() + assert data["schema_version"] == "km_stale_owner_review_v1" + assert data["status"] == "queued" + assert data["dispatch_id"] == "dispatch-001" + assert data["workflow_stage"] == "waiting_owner_review" + assert data["writes_km"] is False + assert data["writes_governance_audit"] is True + + def test_queue_stale_candidate_endpoint_maps_validation_error(self, client): + async def mock_queue(**kwargs): + raise KmStaleOwnerReviewError(409, "KM entry is no longer past the stale threshold") + + with patch( + "src.api.v1.ai_governance.queue_km_stale_owner_review", + new=mock_queue, + ): + r = client.post( + "/api/v1/ai/governance/km-stale-candidates/km-001/queue-review", + json={"owner": "operator_console"}, + ) + + assert r.status_code == 409 + assert r.json()["detail"] == "KM entry is no longer past the stale threshold" + + def test_stale_owner_review_context_is_operator_visible(self): + candidate = { + "entry_id": "km-001", + "project_id": "awoooi", + "title": "Sentry checkout failure repair", + "stale_days": 35, + "priority_tier": "P0", + "priority_score": 265, + "recommended_action": "refresh_with_evidence", + "correlation_sources": ["incident", "playbook", "sentry", "signoz"], + "related_incident_id": "INC-20260513-79ED5E", + "related_playbook_id": "pb:auto-repair-canary", + "related_approval_id": "approval-001", + } + + ctx = _build_stale_owner_review_decision_context( + governance_event_id="event-001", + entry_id="km-001", + candidate=candidate, + owner="operator_console", + owner_note="prioritize P0", + ) + + assert ctx["decision_path"] == "pending_owner_review" + assert ctx["next_action"] == "owner_review_stale_km_candidate" + assert ctx["workflow"]["work_kind"] == "km_stale_owner_review" + assert ctx["workflow"]["current_stage"] == "waiting_owner_review" + assert ctx["workflow"]["entry_id"] == "km-001" + assert ctx["workflow"]["writes_km"] is False + assert ctx["workflow"]["writes_km_without_approval"] is False + assert ctx["workflow"]["stage_by_dispatch_status"]["pending"] == "waiting_owner_review" + assert ctx["worker_result"]["status"] == "queued_owner_review" + assert ctx["ownership"]["lead_agent"] == "Hermes" + def test_archive_endpoint_requires_owner_shape_and_returns_audit_result(self, client): """Owner 批准後的 archive endpoint 應回傳 KM write 與 audit write 結果。""" fake = KnowledgeReviewDraftArchiveResponse( diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 03c91791..51527b58 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -2088,7 +2088,16 @@ "refs": "Incident: {incident}; PlayBook: {playbook}; Approval: {approval}", "noSources": "No Incident / Sentry / SigNoz / PlayBook link yet", "openKnowledge": "Open KM", + "queueReview": "Queue review", + "queueingReview": "Queueing", + "queueFailed": "Could not queue owner review; refresh and confirm this KM is still stale.", + "queueResult": "Review status: {status}; Dispatch: {dispatch}; Event: {event}", "guardrail": "Guardrail: writes on read={writes}; manual review={review}", + "queueStatuses": { + "dry_run": "Dry-run", + "queued": "Queued for owner review", + "already_queued": "Already in owner review" + }, "actions": { "refresh_with_evidence": "Refresh with Incident / Sentry / SigNoz / PlayBook evidence", "owner_review": "Route to owner review", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 0d7115ae..e84d48bb 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -2089,7 +2089,16 @@ "refs": "Incident:{incident};PlayBook:{playbook};Approval:{approval}", "noSources": "尚無 Incident / Sentry / SigNoz / PlayBook 關聯", "openKnowledge": "開啟 KM", + "queueReview": "排入審核", + "queueingReview": "排入中", + "queueFailed": "排入 owner review 失敗;請重新整理後再確認此 KM 是否仍為陳舊候選。", + "queueResult": "審核狀態:{status};Dispatch:{dispatch};Event:{event}", "guardrail": "防護:讀取不寫入={writes};人工覆核={review}", + "queueStatuses": { + "dry_run": "乾跑", + "queued": "已排入 owner review", + "already_queued": "已在 owner review" + }, "actions": { "refresh_with_evidence": "依 Incident / Sentry / SigNoz / PlayBook 證據刷新", "owner_review": "交由 owner 審核內容", 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 94ac1b6b..ccad82bc 100644 --- a/apps/web/src/app/[locale]/awooop/work-items/page.tsx +++ b/apps/web/src/app/[locale]/awooop/work-items/page.tsx @@ -411,6 +411,29 @@ type KnowledgeStaleCandidatesResponse = { generated_at?: string | null; }; +type KnowledgeStaleOwnerReviewResponse = { + schema_version?: string; + entry_id: string; + project_id: string; + status: "dry_run" | "queued" | "already_queued"; + governance_event_id?: string | null; + dispatch_id?: string | null; + workflow_stage: string; + recommended_action: "refresh_with_evidence" | "owner_review" | "archive_or_supersede"; + owner: string; + owner_note?: string | null; + writes_km: boolean; + writes_governance_audit: boolean; + next_action: string; + generated_at?: string | null; +}; + +type KnowledgeStaleOwnerReviewAction = { + loading: boolean; + result: KnowledgeStaleOwnerReviewResponse | null; + error: string | null; +}; + type DriftFingerprintState = { schema_version?: string; namespace?: string; @@ -1075,6 +1098,17 @@ function kmStaleActionKey(value: string | null | undefined) { } } +function kmStaleReviewStatusKey(value: string | null | undefined) { + switch (value) { + case "dry_run": + case "queued": + case "already_queued": + return value; + default: + return "queued"; + } +} + function kmCorrelationSourceKey(value: string | null | undefined) { switch (value) { case "incident": @@ -2074,6 +2108,7 @@ function KnowledgeGovernancePanel({ }) { const t = useTranslations("awooop.workItems.knowledgeGovernance"); const [archiveActions, setArchiveActions] = useState>({}); + const [staleReviewActions, setStaleReviewActions] = useState>({}); const items = queue?.items ?? []; const draftGroups = groupKnowledgeReviewDrafts(reviewDrafts, items); const dedupeGroups = dedupe?.groups ?? []; @@ -2177,6 +2212,37 @@ function KnowledgeGovernancePanel({ } }, [archiveActions, onArchived, t]); + const queueStaleOwnerReview = useCallback(async (candidate: KnowledgeStaleCandidate) => { + setStaleReviewActions((current) => ({ + ...current, + [candidate.entry_id]: { + loading: true, + result: current[candidate.entry_id]?.result ?? null, + error: null, + }, + })); + const result = await postJson( + `${API_BASE}/api/v1/ai/governance/km-stale-candidates/${encodeURIComponent(candidate.entry_id)}/queue-review`, + { + owner: "operator_console", + owner_note: candidate.priority_tier, + dry_run: false, + }, + 15000 + ); + setStaleReviewActions((current) => ({ + ...current, + [candidate.entry_id]: { + loading: false, + result, + error: result ? null : t("staleCandidates.queueFailed"), + }, + })); + if (result?.status === "queued" || result?.status === "already_queued") { + onArchived(); + } + }, [onArchived, t]); + return (
@@ -2325,77 +2391,109 @@ function KnowledgeGovernancePanel({

) : (
- {staleCandidateItems.slice(0, 6).map((candidate) => ( -
-
-
-

- {candidate.title} + {staleCandidateItems.slice(0, 6).map((candidate) => { + const reviewAction = staleReviewActions[candidate.entry_id]; + const reviewResult = reviewAction?.result ?? null; + const reviewStatusKey = kmStaleReviewStatusKey(reviewResult?.status); + return ( +

+
+
+

+ {candidate.title} +

+

+ {candidate.entry_id} +

+
+ + {candidate.priority_tier} + +
+
+

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

-

- {candidate.entry_id} +

+ {t("staleCandidates.action", { + action: t( + `staleCandidates.actions.${kmStaleActionKey(candidate.recommended_action)}` as never + ), + })} +

+

+ {t("staleCandidates.sources", { + sources: candidate.correlation_sources.length + ? candidate.correlation_sources + .map((source) => t( + `staleCandidates.correlationSources.${kmCorrelationSourceKey(source)}` as never + )) + .join(" / ") + : t("staleCandidates.noSources"), + })} +

+

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

- - {candidate.priority_tier} - -
-
-

- {t("staleCandidates.meta", { - days: candidate.stale_days, - score: candidate.priority_score, - views: candidate.view_count, - })} -

-

- {t("staleCandidates.action", { - action: t( - `staleCandidates.actions.${kmStaleActionKey(candidate.recommended_action)}` as never - ), - })} -

-

- {t("staleCandidates.sources", { - sources: candidate.correlation_sources.length - ? candidate.correlation_sources - .map((source) => t( - `staleCandidates.correlationSources.${kmCorrelationSourceKey(source)}` as never - )) - .join(" / ") - : t("staleCandidates.noSources"), - })} -

-

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

-
-
- {candidate.reasons.slice(0, 5).map((reason) => ( - + {candidate.reasons.slice(0, 5).map((reason) => ( + + {t(`staleCandidates.reasons.${kmStaleReasonKey(reason)}` as never)} + + ))} +
+
+
- -
- ))} +
+ {reviewAction?.error ? ( +

+ {reviewAction.error} +

+ ) : null} + {reviewResult ? ( +

+ {t("staleCandidates.queueResult", { + status: t(`staleCandidates.queueStatuses.${reviewStatusKey}` as never), + dispatch: reviewResult.dispatch_id ?? "--", + event: reviewResult.governance_event_id ?? "--", + })} +

+ ) : null} + + ); + })}
)}