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.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.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} />