diff --git a/apps/api/src/api/v1/ai_governance.py b/apps/api/src/api/v1/ai_governance.py
index 2620baa1..a0641702 100644
--- a/apps/api/src/api/v1/ai_governance.py
+++ b/apps/api/src/api/v1/ai_governance.py
@@ -31,6 +31,7 @@ from src.models.governance import (
KnowledgeReviewDraftArchiveRequest,
KnowledgeReviewDraftArchiveResponse,
KnowledgeReviewDraftDedupeResponse,
+ KnowledgeStaleCandidatesResponse,
)
from src.services.governance_km_review_service import (
KmReviewDraftArchiveError,
@@ -41,6 +42,7 @@ from src.services.governance_query_service import (
query_governance_queue,
query_governance_summary,
query_km_review_draft_dedupe,
+ query_km_stale_candidates,
)
logger = structlog.get_logger(__name__)
@@ -193,6 +195,32 @@ async def post_km_review_draft_archive_duplicates(
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
+# =============================================================================
+# GET /api/v1/ai/governance/km-stale-candidates
+# =============================================================================
+
+@router.get(
+ "/ai/governance/km-stale-candidates",
+ response_model=KnowledgeStaleCandidatesResponse,
+)
+async def get_km_stale_candidates(
+ project_id: Annotated[str, Query(min_length=1, max_length=64)] = "awoooi",
+ limit: Annotated[int, Query(ge=5, le=100)] = 20,
+) -> KnowledgeStaleCandidatesResponse:
+ """
+ 查詢 stale KM 的 read-only 優先處理清單。
+
+ Hermes 可以用這個 read model 產生 KM 更新草稿;owner console 則能先看
+ 哪些條目有 Incident / Sentry / SigNoz / PlayBook 脈絡,避免只看到總數。
+ """
+ logger.debug(
+ "km_stale_candidates_request",
+ project_id=project_id,
+ limit=limit,
+ )
+ return await query_km_stale_candidates(project_id=project_id, limit=limit)
+
+
# =============================================================================
# GET /api/v1/ai/governance/summary
# =============================================================================
diff --git a/apps/api/src/models/governance.py b/apps/api/src/models/governance.py
index 344983dd..09e7aead 100644
--- a/apps/api/src/models/governance.py
+++ b/apps/api/src/models/governance.py
@@ -199,6 +199,48 @@ class KnowledgeReviewDraftArchiveResponse(BaseModel):
generated_at: datetime
+# =============================================================================
+# Endpoint 2C: KM stale candidates
+# =============================================================================
+
+class KnowledgeStaleCandidate(BaseModel):
+ entry_id: str
+ project_id: str
+ title: str
+ entry_type: str
+ category: str | None = None
+ status: str
+ source: str | None = None
+ updated_at: datetime | None = None
+ stale_days: int
+ view_count: int
+ priority_score: int
+ priority_tier: Literal["P0", "P1", "P2"]
+ recommended_action: Literal[
+ "refresh_with_evidence",
+ "owner_review",
+ "archive_or_supersede",
+ ]
+ reasons: list[str] = Field(default_factory=list)
+ correlation_sources: list[str] = Field(default_factory=list)
+ related_incident_id: str | None = None
+ related_playbook_id: str | None = None
+ related_approval_id: str | None = None
+ tags: list[str] = Field(default_factory=list)
+
+
+class KnowledgeStaleCandidatesResponse(BaseModel):
+ schema_version: str = "km_stale_candidates_v1"
+ project_id: str
+ total_stale: int
+ returned: int
+ threshold_days: int
+ writes_on_read: bool = False
+ manual_review_required: bool = True
+ items: list[KnowledgeStaleCandidate]
+ generated_at: datetime
+
+
# =============================================================================
# Endpoint 3: summary
# =============================================================================
diff --git a/apps/api/src/services/governance_query_service.py b/apps/api/src/services/governance_query_service.py
index 00cbbe35..3fde138b 100644
--- a/apps/api/src/services/governance_query_service.py
+++ b/apps/api/src/services/governance_query_service.py
@@ -37,6 +37,8 @@ from src.models.governance import (
GovernanceSummaryResponse,
KnowledgeReviewDraftDedupeGroup,
KnowledgeReviewDraftDedupeResponse,
+ KnowledgeStaleCandidate,
+ KnowledgeStaleCandidatesResponse,
map_severity,
)
from src.models.knowledge import EntryStatus, EntryType
@@ -49,6 +51,7 @@ logger = structlog.get_logger(__name__)
# =============================================================================
_TAIPEI = timezone(timedelta(hours=8))
+_KM_STALE_DAYS = 7
# =============================================================================
@@ -869,6 +872,209 @@ def _build_km_review_draft_dedupe_groups(
)
+# =============================================================================
+# Endpoint 2C: KM stale candidates
+# =============================================================================
+
+async def query_km_stale_candidates(
+ *,
+ project_id: str = "awoooi",
+ limit: int = 20,
+ threshold_days: int = _KM_STALE_DAYS,
+) -> KnowledgeStaleCandidatesResponse:
+ """
+ 產生 stale KM 的 read-only 優先處理清單。
+
+ 這個 endpoint 只讀 knowledge_entries,將已陳舊的 KM 依 incident /
+ approval / playbook 反查鏈、Sentry / SigNoz 線索、view_count 與陳舊天數排序。
+ 它不自動改寫 KM,避免把錯誤知識固化到 production。
+ """
+ cutoff = now_taipei() - timedelta(days=threshold_days)
+
+ async with get_db_context() as db:
+ stmt = (
+ select(KnowledgeEntryRecord)
+ .where(
+ KnowledgeEntryRecord.project_id == project_id,
+ KnowledgeEntryRecord.status != EntryStatus.ARCHIVED,
+ KnowledgeEntryRecord.updated_at < cutoff,
+ )
+ .order_by(KnowledgeEntryRecord.updated_at.asc())
+ )
+ result = await db.execute(stmt)
+ records = result.scalars().all()
+
+ generated_at = now_taipei()
+ candidates = [
+ _build_km_stale_candidate(
+ record,
+ now=generated_at,
+ threshold_days=threshold_days,
+ )
+ for record in records
+ ]
+ candidates.sort(
+ key=lambda item: (
+ item.priority_score,
+ item.stale_days,
+ item.view_count,
+ item.updated_at.isoformat() if item.updated_at else "",
+ ),
+ reverse=True,
+ )
+ limited = candidates[:limit]
+
+ return KnowledgeStaleCandidatesResponse(
+ project_id=project_id,
+ total_stale=len(candidates),
+ returned=len(limited),
+ threshold_days=threshold_days,
+ items=limited,
+ generated_at=generated_at,
+ )
+
+
+def _build_km_stale_candidate(
+ record: KnowledgeEntryRecord,
+ *,
+ now: datetime,
+ threshold_days: int = _KM_STALE_DAYS,
+) -> KnowledgeStaleCandidate:
+ """將一筆 KnowledgeEntryRecord 轉成 owner 可處理的 stale candidate。"""
+ updated_at = record.updated_at
+ stale_days = threshold_days
+ if updated_at is not None:
+ comparable_updated_at = updated_at
+ if comparable_updated_at.tzinfo is None:
+ comparable_updated_at = comparable_updated_at.replace(tzinfo=_TAIPEI)
+ stale_days = max((now - comparable_updated_at).days, threshold_days)
+
+ entry_type = _enum_value(record.entry_type)
+ status = _enum_value(record.status)
+ source = _enum_value(record.source)
+ tags = [str(tag) for tag in (record.tags or []) if tag is not None]
+ evidence_text = " ".join([
+ record.title or "",
+ record.content or "",
+ " ".join(tags),
+ ]).lower()
+
+ reasons: list[str] = []
+ correlation_sources: list[str] = []
+ score = stale_days
+
+ if record.related_incident_id:
+ score += 80
+ reasons.append("linked_incident")
+ correlation_sources.append("incident")
+ if record.related_approval_id:
+ score += 70
+ reasons.append("linked_approval")
+ correlation_sources.append("approval")
+ if record.related_playbook_id:
+ score += 70
+ reasons.append("linked_playbook")
+ correlation_sources.append("playbook")
+ if "sentry" in evidence_text:
+ score += 30
+ reasons.append("sentry_context")
+ correlation_sources.append("sentry")
+ if "signoz" in evidence_text:
+ score += 30
+ reasons.append("signoz_context")
+ correlation_sources.append("signoz")
+ if entry_type == EntryType.ANTI_PATTERN.value:
+ score += 45
+ reasons.append("anti_pattern_priority")
+ if entry_type == EntryType.AUTO_RUNBOOK.value:
+ score += 25
+ reasons.append("auto_runbook_review_needed")
+ if source == "ai_extracted":
+ score += 20
+ reasons.append("ai_extracted_needs_owner_check")
+ if status == EntryStatus.REVIEW.value:
+ score += 20
+ reasons.append("already_waiting_review")
+
+ view_count = int(record.view_count or 0)
+ if view_count > 0:
+ score += min(view_count, 50)
+ reasons.append("viewed_by_operator")
+ if stale_days >= 30:
+ score += 25
+ reasons.append("older_than_30_days")
+
+ if not reasons:
+ reasons.append("stale_by_age")
+
+ priority_tier = _km_priority_tier(score, record, stale_days)
+ recommended_action = _km_recommended_action(record, stale_days, view_count)
+
+ return KnowledgeStaleCandidate(
+ entry_id=str(record.id),
+ project_id=str(record.project_id),
+ title=str(record.title),
+ entry_type=entry_type,
+ category=str(record.category) if record.category else None,
+ status=status,
+ source=source,
+ updated_at=updated_at,
+ stale_days=stale_days,
+ view_count=view_count,
+ priority_score=score,
+ priority_tier=priority_tier,
+ recommended_action=recommended_action,
+ reasons=list(dict.fromkeys(reasons)),
+ correlation_sources=list(dict.fromkeys(correlation_sources)),
+ related_incident_id=record.related_incident_id,
+ related_playbook_id=record.related_playbook_id,
+ related_approval_id=record.related_approval_id,
+ tags=tags,
+ )
+
+
+def _km_priority_tier(
+ score: int,
+ record: KnowledgeEntryRecord,
+ stale_days: int,
+) -> str:
+ """把排序分數壓成 owner 易懂的 P0/P1/P2 分層。"""
+ if score >= 160:
+ return "P0"
+ if record.related_incident_id and (
+ record.related_approval_id or record.related_playbook_id or stale_days >= 30
+ ):
+ return "P0"
+ if score >= 90:
+ return "P1"
+ return "P2"
+
+
+def _km_recommended_action(
+ record: KnowledgeEntryRecord,
+ stale_days: int,
+ view_count: int,
+) -> str:
+ """決定 owner 下一步:刷新、審核、或封存/合併。"""
+ status = _enum_value(record.status)
+ if record.related_incident_id or record.related_playbook_id or record.related_approval_id:
+ return "refresh_with_evidence"
+ if status == EntryStatus.REVIEW.value or _enum_value(record.source) == "ai_extracted":
+ return "owner_review"
+ if stale_days >= 30 and view_count == 0:
+ return "archive_or_supersede"
+ return "owner_review"
+
+
+def _enum_value(value: Any) -> str:
+ """將 SQLAlchemy enum / plain string 正規化為 API 字串。"""
+ if value is None:
+ return ""
+ if hasattr(value, "value"):
+ return str(value.value)
+ return str(value)
+
+
# =============================================================================
# Endpoint 3: summary
# =============================================================================
diff --git a/apps/api/tests/test_ai_governance_endpoints.py b/apps/api/tests/test_ai_governance_endpoints.py
index 6346bd9f..b4295b77 100644
--- a/apps/api/tests/test_ai_governance_endpoints.py
+++ b/apps/api/tests/test_ai_governance_endpoints.py
@@ -22,6 +22,7 @@ from fastapi import FastAPI
from fastapi.testclient import TestClient
from src.api.v1.ai_governance import router
+from src.db.models import KnowledgeEntryRecord
from src.models.governance import (
DailyCount,
DispatchItem,
@@ -34,8 +35,11 @@ from src.models.governance import (
KnowledgeReviewDraftDedupeGroup,
KnowledgeReviewDraftDedupeResponse,
KnowledgeReviewDraftStaleRatioSnapshot,
+ KnowledgeStaleCandidate,
+ KnowledgeStaleCandidatesResponse,
map_severity,
)
+from src.models.knowledge import EntrySource, EntryStatus, EntryType
from src.services.governance_km_review_service import (
KmReviewDraftArchiveError,
_build_dry_run_plan_fingerprint,
@@ -45,6 +49,7 @@ from src.services.governance_km_review_service import (
)
from src.services.governance_query_service import (
_build_km_review_draft_dedupe_groups,
+ _build_km_stale_candidate,
_extract_archived_count,
_extract_dry_run_plan_fingerprint,
_extract_governance_event_id_from_tags,
@@ -593,6 +598,97 @@ class TestKmReviewDraftDedupe:
assert first.archive_history[0].executor_type == "hermes_km_stale_ratio_recheck"
assert first.archive_history[0].stale_ratio_snapshot["stale_ratio"] == pytest.approx(0.1)
+ def test_km_stale_candidates_endpoint_returns_read_only_priority_queue(self, client):
+ """stale KM endpoint 應回傳 owner 可排序處理的 read-only 清單。"""
+ fake = KnowledgeStaleCandidatesResponse(
+ project_id="awoooi",
+ total_stale=1490,
+ returned=1,
+ threshold_days=7,
+ items=[
+ KnowledgeStaleCandidate(
+ entry_id="km-001",
+ project_id="awoooi",
+ title="Sentry / SigNoz checkout repair runbook",
+ entry_type="auto_runbook",
+ category="AI系統",
+ status="review",
+ source="ai_extracted",
+ updated_at=NOW - timedelta(days=21),
+ stale_days=21,
+ view_count=9,
+ priority_score=265,
+ priority_tier="P0",
+ recommended_action="refresh_with_evidence",
+ reasons=[
+ "linked_incident",
+ "linked_playbook",
+ "sentry_context",
+ "signoz_context",
+ ],
+ correlation_sources=["incident", "playbook", "sentry", "signoz"],
+ related_incident_id="INC-20260513-79ED5E",
+ related_playbook_id="pb:auto-repair-canary",
+ tags=["sentry", "signoz"],
+ )
+ ],
+ generated_at=NOW,
+ )
+ captured: dict = {}
+
+ async def mock_query(**kwargs):
+ captured.update(kwargs)
+ return fake
+
+ with patch("src.api.v1.ai_governance.query_km_stale_candidates", new=mock_query):
+ r = client.get(
+ "/api/v1/ai/governance/km-stale-candidates"
+ "?project_id=awoooi&limit=25"
+ )
+
+ assert r.status_code == 200
+ assert captured == {"project_id": "awoooi", "limit": 25}
+ data = r.json()
+ assert data["writes_on_read"] is False
+ assert data["manual_review_required"] is True
+ assert data["total_stale"] == 1490
+ assert data["items"][0]["priority_tier"] == "P0"
+ assert data["items"][0]["correlation_sources"] == [
+ "incident",
+ "playbook",
+ "sentry",
+ "signoz",
+ ]
+
+ def test_build_km_stale_candidate_prioritizes_linked_evidence(self):
+ """有 Incident / PlayBook / Sentry / SigNoz 脈絡的 stale KM 應排前面。"""
+ record = KnowledgeEntryRecord(
+ id="km-001",
+ project_id="awoooi",
+ title="Sentry checkout failure repair",
+ content="Use SigNoz trace and PlayBook verification before KM writeback.",
+ entry_type=EntryType.AUTO_RUNBOOK,
+ category="AI系統",
+ tags=["sentry", "signoz", "workflow:kb_growth_healthcheck"],
+ 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)
+
+ assert candidate.priority_tier == "P0"
+ assert candidate.recommended_action == "refresh_with_evidence"
+ assert candidate.stale_days == 35
+ assert candidate.correlation_sources == ["incident", "playbook", "sentry", "signoz"]
+ assert "linked_incident" in candidate.reasons
+ assert "linked_playbook" in candidate.reasons
+ assert "sentry_context" in candidate.reasons
+ assert "signoz_context" in candidate.reasons
+
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 81e7a617..03c91791 100644
--- a/apps/web/messages/en.json
+++ b/apps/web/messages/en.json
@@ -2021,6 +2021,7 @@
"knowledgeOwner": "Lead: {lead}; human review: {human}",
"knowledgeNext": "Next action: {action}",
"knowledgeDrafts": "KM review drafts: {drafts}; duplicate drafts: {duplicates}",
+ "knowledgeStaleCandidates": "Stale KM priority queue: {total}; top {top} / {tier}",
"knowledgeEmpty": "No recent knowledge_degradation dispatch trail",
"frontendConsole": "This page now reads production APIs instead of a static list",
"mcpReady": "MCP Gateway gate is not currently a top gap",
@@ -2074,6 +2075,49 @@
"archiveProposal": "Archive candidates: {count} duplicate drafts",
"ownerAction": "Owner action: {action}",
"readOnlyPlan": "Writes on read: {writes}; archive blocked before review: {blocked}",
+ "staleCandidates": {
+ "title": "Stale KM Priority Queue",
+ "total": "Stale {count}",
+ "returned": "Shown {count}",
+ "threshold": "Threshold {days}d",
+ "unavailable": "The stale candidates API has not responded; only the aggregate count is visible.",
+ "empty": "No KM entries are currently past the stale threshold.",
+ "meta": "Stale {days}d; score {score}; views {views}",
+ "action": "Recommended: {action}",
+ "sources": "Sources: {sources}",
+ "refs": "Incident: {incident}; PlayBook: {playbook}; Approval: {approval}",
+ "noSources": "No Incident / Sentry / SigNoz / PlayBook link yet",
+ "openKnowledge": "Open KM",
+ "guardrail": "Guardrail: writes on read={writes}; manual review={review}",
+ "actions": {
+ "refresh_with_evidence": "Refresh with Incident / Sentry / SigNoz / PlayBook evidence",
+ "owner_review": "Route to owner review",
+ "archive_or_supersede": "Archive or supersede"
+ },
+ "correlationSources": {
+ "incident": "Incident",
+ "approval": "Approval",
+ "playbook": "PlayBook",
+ "sentry": "Sentry",
+ "signoz": "SigNoz",
+ "unknown": "Unknown source"
+ },
+ "reasons": {
+ "linked_incident": "Linked Incident",
+ "linked_approval": "Linked Approval",
+ "linked_playbook": "Linked PlayBook",
+ "sentry_context": "Sentry context",
+ "signoz_context": "SigNoz context",
+ "anti_pattern_priority": "Anti-Pattern priority",
+ "auto_runbook_review_needed": "Auto-runbook review",
+ "ai_extracted_needs_owner_check": "AI extraction needs review",
+ "already_waiting_review": "Already waiting review",
+ "viewed_by_operator": "Viewed by operator",
+ "older_than_30_days": "Older than 30 days",
+ "stale_by_age": "Past stale threshold",
+ "unknown": "Unknown reason"
+ }
+ },
"openEventHistory": "Open Event History",
"ownerActions": {
"owner_review_canonical_then_archive_duplicates": "Review the canonical draft, then archive duplicates",
diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json
index d3885cbc..0d7115ae 100644
--- a/apps/web/messages/zh-TW.json
+++ b/apps/web/messages/zh-TW.json
@@ -2022,6 +2022,7 @@
"knowledgeOwner": "主責:{lead};人工覆核:{human}",
"knowledgeNext": "下一步:{action}",
"knowledgeDrafts": "KM 審核草稿:{drafts};重複草稿:{duplicates}",
+ "knowledgeStaleCandidates": "陳舊 KM 優先清單:{total} 筆;最高 {top} / {tier}",
"knowledgeEmpty": "近期沒有 knowledge_degradation dispatch trail",
"frontendConsole": "本頁已改讀 production API,而非靜態清單",
"mcpReady": "MCP Gateway gate 目前未列為主要缺口",
@@ -2075,6 +2076,49 @@
"archiveProposal": "封存候選:{count} 份重複草稿",
"ownerAction": "Owner 動作:{action}",
"readOnlyPlan": "讀取不寫入:{writes};未審核不封存:{blocked}",
+ "staleCandidates": {
+ "title": "陳舊 KM 優先處理清單",
+ "total": "陳舊 {count}",
+ "returned": "顯示 {count}",
+ "threshold": "門檻 {days} 天",
+ "unavailable": "stale candidates API 尚未回應;目前只能看到總數,無法排序處理。",
+ "empty": "目前沒有超過門檻的陳舊 KM。",
+ "meta": "陳舊 {days} 天;分數 {score};瀏覽 {views}",
+ "action": "建議:{action}",
+ "sources": "關聯來源:{sources}",
+ "refs": "Incident:{incident};PlayBook:{playbook};Approval:{approval}",
+ "noSources": "尚無 Incident / Sentry / SigNoz / PlayBook 關聯",
+ "openKnowledge": "開啟 KM",
+ "guardrail": "防護:讀取不寫入={writes};人工覆核={review}",
+ "actions": {
+ "refresh_with_evidence": "依 Incident / Sentry / SigNoz / PlayBook 證據刷新",
+ "owner_review": "交由 owner 審核內容",
+ "archive_or_supersede": "封存或以新條目取代"
+ },
+ "correlationSources": {
+ "incident": "Incident",
+ "approval": "Approval",
+ "playbook": "PlayBook",
+ "sentry": "Sentry",
+ "signoz": "SigNoz",
+ "unknown": "未知來源"
+ },
+ "reasons": {
+ "linked_incident": "關聯 Incident",
+ "linked_approval": "關聯 Approval",
+ "linked_playbook": "關聯 PlayBook",
+ "sentry_context": "含 Sentry 脈絡",
+ "signoz_context": "含 SigNoz 脈絡",
+ "anti_pattern_priority": "Anti-Pattern 優先",
+ "auto_runbook_review_needed": "自動 Runbook 待審",
+ "ai_extracted_needs_owner_check": "AI 萃取需覆核",
+ "already_waiting_review": "已在審核狀態",
+ "viewed_by_operator": "近期有人查看",
+ "older_than_30_days": "超過 30 天",
+ "stale_by_age": "超過陳舊門檻",
+ "unknown": "未知原因"
+ }
+ },
"openEventHistory": "開啟事件歷史",
"ownerActions": {
"owner_review_canonical_then_archive_duplicates": "審核 canonical 草稿後封存 duplicates",
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 dbab3613..94ac1b6b 100644
--- a/apps/web/src/app/[locale]/awooop/work-items/page.tsx
+++ b/apps/web/src/app/[locale]/awooop/work-items/page.tsx
@@ -377,6 +377,40 @@ type KnowledgeReviewDraftArchiveAction = {
error: string | null;
};
+type KnowledgeStaleCandidate = {
+ entry_id: string;
+ project_id: string;
+ title: string;
+ entry_type: string;
+ category?: string | null;
+ status: string;
+ source?: string | null;
+ updated_at?: string | null;
+ stale_days: number;
+ view_count: number;
+ priority_score: number;
+ priority_tier: "P0" | "P1" | "P2";
+ recommended_action: "refresh_with_evidence" | "owner_review" | "archive_or_supersede";
+ reasons: string[];
+ correlation_sources: string[];
+ related_incident_id?: string | null;
+ related_playbook_id?: string | null;
+ related_approval_id?: string | null;
+ tags: string[];
+};
+
+type KnowledgeStaleCandidatesResponse = {
+ schema_version?: string;
+ project_id: string;
+ total_stale: number;
+ returned: number;
+ threshold_days: number;
+ writes_on_read: boolean;
+ manual_review_required: boolean;
+ items: KnowledgeStaleCandidate[];
+ generated_at?: string | null;
+};
+
type DriftFingerprintState = {
schema_version?: string;
namespace?: string;
@@ -459,6 +493,7 @@ type Telemetry = {
governanceKnowledgeQueue: GovernanceQueueResponse | null;
knowledgeReviewDrafts: KnowledgeListResponse | null;
knowledgeReviewDedupe: KnowledgeReviewDraftDedupeResponse | null;
+ knowledgeStaleCandidates: KnowledgeStaleCandidatesResponse | null;
channelEvents: RecentEventsResponse | null;
eventRecurrence: RecurrenceResponse | null;
slo: SloResponse | null;
@@ -1009,6 +1044,50 @@ function formatStaleRatio(value: number) {
return `${(value * 100).toFixed(1)}%`;
}
+function kmStaleReasonKey(value: string | null | undefined) {
+ switch (value) {
+ case "linked_incident":
+ case "linked_approval":
+ case "linked_playbook":
+ case "sentry_context":
+ case "signoz_context":
+ case "anti_pattern_priority":
+ case "auto_runbook_review_needed":
+ case "ai_extracted_needs_owner_check":
+ case "already_waiting_review":
+ case "viewed_by_operator":
+ case "older_than_30_days":
+ case "stale_by_age":
+ return value;
+ default:
+ return "unknown";
+ }
+}
+
+function kmStaleActionKey(value: string | null | undefined) {
+ switch (value) {
+ case "refresh_with_evidence":
+ case "owner_review":
+ case "archive_or_supersede":
+ return value;
+ default:
+ return "owner_review";
+ }
+}
+
+function kmCorrelationSourceKey(value: string | null | undefined) {
+ switch (value) {
+ case "incident":
+ case "approval":
+ case "playbook":
+ case "sentry":
+ case "signoz":
+ return value;
+ default:
+ return "unknown";
+ }
+}
+
function governanceEventHistoryHref(eventId: string) {
return `/governance?tab=events&event_id=${encodeURIComponent(eventId)}`;
}
@@ -1034,6 +1113,8 @@ function buildWorkItems(
);
const knowledgeDedupeDuplicates =
telemetry.knowledgeReviewDedupe?.duplicate_draft_total ?? knowledgeDuplicateDrafts;
+ const knowledgeStaleTotal = telemetry.knowledgeStaleCandidates?.total_stale ?? 0;
+ const topKnowledgeStaleCandidate = telemetry.knowledgeStaleCandidates?.items?.[0] ?? null;
const remediationQueue = telemetry.slo?.adr100?.verification_coverage?.remediation_queue;
const remediationTotal = remediationQueue?.total ?? 0;
const remediationReadyForAi = remediationQueue?.ready_for_ai ?? 0;
@@ -1282,8 +1363,20 @@ function buildWorkItems(
?? 0,
duplicates: knowledgeDedupeDuplicates,
}),
+ t("evidence.knowledgeStaleCandidates", {
+ total: knowledgeStaleTotal,
+ top: topKnowledgeStaleCandidate?.entry_id ?? "--",
+ tier: topKnowledgeStaleCandidate?.priority_tier ?? "--",
+ }),
]
- : [t("evidence.knowledgeEmpty")],
+ : [
+ t("evidence.knowledgeEmpty"),
+ t("evidence.knowledgeStaleCandidates", {
+ total: knowledgeStaleTotal,
+ top: topKnowledgeStaleCandidate?.entry_id ?? "--",
+ tier: topKnowledgeStaleCandidate?.priority_tier ?? "--",
+ }),
+ ],
href: "/awooop/work-items",
},
{
@@ -1970,11 +2063,13 @@ function KnowledgeGovernancePanel({
queue,
reviewDrafts,
dedupe,
+ staleCandidates,
onArchived,
}: {
queue: GovernanceQueueResponse | null;
reviewDrafts: KnowledgeListResponse | null;
dedupe: KnowledgeReviewDraftDedupeResponse | null;
+ staleCandidates: KnowledgeStaleCandidatesResponse | null;
onArchived: () => void;
}) {
const t = useTranslations("awooop.workItems.knowledgeGovernance");
@@ -1986,6 +2081,7 @@ function KnowledgeGovernancePanel({
dedupe?.duplicate_draft_total
?? draftGroups.reduce((sum, group) => sum + group.duplicateCount, 0)
);
+ const staleCandidateItems = staleCandidates?.items ?? [];
const draftTotal = dedupe?.total_review_drafts ?? reviewDrafts?.total ?? 0;
const activeCount = items.filter((item) =>
["pending", "dispatched", "executing"].includes(item.dispatch_status)
@@ -2193,6 +2289,123 @@ function KnowledgeGovernancePanel({
)}
+
+
+
+
+
+ {t("staleCandidates.title")}
+
+
+
+
+ {t("staleCandidates.total", {
+ count: staleCandidates?.total_stale ?? 0,
+ })}
+
+
+ {t("staleCandidates.returned", {
+ count: staleCandidates?.returned ?? 0,
+ })}
+
+
+ {t("staleCandidates.threshold", {
+ days: staleCandidates?.threshold_days ?? 7,
+ })}
+
+
+
+ {staleCandidates === null ? (
+
+ {t("staleCandidates.unavailable")}
+
+ ) : staleCandidateItems.length === 0 ? (
+
+ {t("staleCandidates.empty")}
+
+ ) : (
+
+ {staleCandidateItems.slice(0, 6).map((candidate) => (
+
+
+
+
+ {candidate.title}
+
+
+ {candidate.entry_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) => (
+
+ {t(`staleCandidates.reasons.${kmStaleReasonKey(reason)}` as never)}
+
+ ))}
+
+
+
+ {t("staleCandidates.openKnowledge")}
+
+
+ ))}
+
+ )}
+
+ {t("staleCandidates.guardrail", {
+ writes: String(staleCandidates?.writes_on_read ?? false),
+ review: String(staleCandidates?.manual_review_required ?? true),
+ })}
+
+
+
{dedupe === null && reviewDrafts === null ? (
{t("draftsUnavailable")}
@@ -2705,6 +2918,7 @@ export default function AwoooPWorkItemsPage() {
governanceKnowledgeQueue: null,
knowledgeReviewDrafts: null,
knowledgeReviewDedupe: null,
+ knowledgeStaleCandidates: null,
channelEvents: null,
eventRecurrence: null,
slo: null,
@@ -2724,6 +2938,7 @@ export default function AwoooPWorkItemsPage() {
const governanceKnowledgeQueueUrl = `${API_BASE}/api/v1/ai/governance/queue?dispatch_status=all&event_type=knowledge_degradation&size=20`;
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 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`;
@@ -2737,6 +2952,7 @@ export default function AwoooPWorkItemsPage() {
governanceKnowledgeQueue,
knowledgeReviewDrafts,
knowledgeReviewDedupe,
+ knowledgeStaleCandidates,
channelEvents,
eventRecurrence,
slo,
@@ -2749,6 +2965,7 @@ export default function AwoooPWorkItemsPage() {
fetchJson(governanceKnowledgeQueueUrl),
fetchJson(knowledgeReviewDraftsUrl),
fetchJson(knowledgeReviewDedupeUrl),
+ fetchJson(knowledgeStaleCandidatesUrl),
fetchJson(channelEventsUrl),
fetchJson(recurrenceUrl),
fetchJson(sloUrl),
@@ -2778,6 +2995,7 @@ export default function AwoooPWorkItemsPage() {
governanceKnowledgeQueue,
knowledgeReviewDrafts,
knowledgeReviewDedupe,
+ knowledgeStaleCandidates,
channelEvents,
eventRecurrence,
slo,
@@ -2904,6 +3122,7 @@ export default function AwoooPWorkItemsPage() {
queue={telemetry.governanceKnowledgeQueue}
reviewDrafts={telemetry.knowledgeReviewDrafts}
dedupe={telemetry.knowledgeReviewDedupe}
+ staleCandidates={telemetry.knowledgeStaleCandidates}
onArchived={fetchTelemetry}
/>