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

This commit is contained in:
Your Name
2026-05-24 16:46:14 +08:00
parent b87090be01
commit 841b057ada
7 changed files with 680 additions and 1 deletions

View File

@@ -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
# =============================================================================

View File

@@ -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
# =============================================================================

View File

@@ -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
# =============================================================================

View File

@@ -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(