feat(governance): surface stale km priority queue
Some checks failed
CD Pipeline / tests (push) Successful in 5m29s
Code Review / ai-code-review (push) Successful in 11s
Type Sync Check / check-type-sync (push) Successful in 32s
CD Pipeline / build-and-deploy (push) Failing after 5m43s
CD Pipeline / post-deploy-checks (push) Has been skipped
Some checks failed
CD Pipeline / tests (push) Successful in 5m29s
Code Review / ai-code-review (push) Successful in 11s
Type Sync Check / check-type-sync (push) Successful in 32s
CD Pipeline / build-and-deploy (push) Failing after 5m43s
CD Pipeline / post-deploy-checks (push) Has been skipped
This commit is contained in:
@@ -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
|
||||
# =============================================================================
|
||||
|
||||
@@ -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
|
||||
# =============================================================================
|
||||
|
||||
@@ -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
|
||||
# =============================================================================
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-[#e0ddd4] bg-[#faf9f3] px-4 py-3">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-4 w-4 text-[#5f5b52]" aria-hidden="true" />
|
||||
<h4 className="text-xs font-semibold uppercase tracking-[0.08em] text-[#5f5b52]">
|
||||
{t("staleCandidates.title")}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 font-mono text-[11px] text-[#5f5b52]">
|
||||
<span className="border border-[#d8d3c7] bg-white px-2 py-0.5">
|
||||
{t("staleCandidates.total", {
|
||||
count: staleCandidates?.total_stale ?? 0,
|
||||
})}
|
||||
</span>
|
||||
<span className="border border-[#d8d3c7] bg-white px-2 py-0.5">
|
||||
{t("staleCandidates.returned", {
|
||||
count: staleCandidates?.returned ?? 0,
|
||||
})}
|
||||
</span>
|
||||
<span className="border border-[#d8d3c7] bg-white px-2 py-0.5">
|
||||
{t("staleCandidates.threshold", {
|
||||
days: staleCandidates?.threshold_days ?? 7,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{staleCandidates === null ? (
|
||||
<p className="text-xs text-[#8a5a08]">
|
||||
{t("staleCandidates.unavailable")}
|
||||
</p>
|
||||
) : staleCandidateItems.length === 0 ? (
|
||||
<p className="text-xs text-[#5f5b52]">
|
||||
{t("staleCandidates.empty")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
{staleCandidateItems.slice(0, 6).map((candidate) => (
|
||||
<article
|
||||
key={candidate.entry_id}
|
||||
className="border border-[#e0ddd4] bg-white px-3 py-2 text-xs text-[#5f5b52]"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold text-[#141413]">
|
||||
{candidate.title}
|
||||
</p>
|
||||
<p className="mt-1 truncate font-mono text-[11px] text-[#77736a]">
|
||||
{candidate.entry_id}
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 border border-[#d9b36f] bg-[#fff7e8] px-2 py-0.5 font-mono font-semibold text-[#8a5a08]">
|
||||
{candidate.priority_tier}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 grid gap-1 leading-5">
|
||||
<p>
|
||||
{t("staleCandidates.meta", {
|
||||
days: candidate.stale_days,
|
||||
score: candidate.priority_score,
|
||||
views: candidate.view_count,
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
{t("staleCandidates.action", {
|
||||
action: t(
|
||||
`staleCandidates.actions.${kmStaleActionKey(candidate.recommended_action)}` as never
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
{t("staleCandidates.sources", {
|
||||
sources: candidate.correlation_sources.length
|
||||
? candidate.correlation_sources
|
||||
.map((source) => t(
|
||||
`staleCandidates.correlationSources.${kmCorrelationSourceKey(source)}` as never
|
||||
))
|
||||
.join(" / ")
|
||||
: t("staleCandidates.noSources"),
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
{t("staleCandidates.refs", {
|
||||
incident: candidate.related_incident_id ?? "--",
|
||||
playbook: candidate.related_playbook_id ?? "--",
|
||||
approval: candidate.related_approval_id ?? "--",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{candidate.reasons.slice(0, 5).map((reason) => (
|
||||
<span
|
||||
key={`${candidate.entry_id}-${reason}`}
|
||||
className="border border-[#d8d3c7] bg-[#faf9f3] px-2 py-0.5 leading-5"
|
||||
>
|
||||
{t(`staleCandidates.reasons.${kmStaleReasonKey(reason)}` as never)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<Link
|
||||
href={`/knowledge-base?q=${encodeURIComponent(candidate.title)}`}
|
||||
className="mt-2 inline-flex items-center gap-1.5 border border-[#d8d3c7] bg-white px-2 py-1 text-xs font-semibold text-[#2e2b26] hover:border-[#1f6feb] hover:bg-[#edf4ff] hover:text-[#0f4fa8]"
|
||||
>
|
||||
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t("staleCandidates.openKnowledge")}
|
||||
</Link>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-3 text-[11px] leading-5 text-[#77736a]">
|
||||
{t("staleCandidates.guardrail", {
|
||||
writes: String(staleCandidates?.writes_on_read ?? false),
|
||||
review: String(staleCandidates?.manual_review_required ?? true),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{dedupe === null && reviewDrafts === null ? (
|
||||
<div className="border-t border-[#e0ddd4] px-4 py-3 text-xs text-[#8a5a08]">
|
||||
{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<GovernanceQueueResponse>(governanceKnowledgeQueueUrl),
|
||||
fetchJson<KnowledgeListResponse>(knowledgeReviewDraftsUrl),
|
||||
fetchJson<KnowledgeReviewDraftDedupeResponse>(knowledgeReviewDedupeUrl),
|
||||
fetchJson<KnowledgeStaleCandidatesResponse>(knowledgeStaleCandidatesUrl),
|
||||
fetchJson<RecentEventsResponse>(channelEventsUrl),
|
||||
fetchJson<RecurrenceResponse>(recurrenceUrl),
|
||||
fetchJson<SloResponse>(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}
|
||||
/>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user