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:
@@ -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
|
||||
# =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user