feat(governance): queue stale km owner review
All checks were successful
CD Pipeline / tests (push) Successful in 5m28s
Code Review / ai-code-review (push) Successful in 14s
Type Sync Check / check-type-sync (push) Successful in 27s
CD Pipeline / build-and-deploy (push) Successful in 4m19s
CD Pipeline / post-deploy-checks (push) Successful in 1m39s
All checks were successful
CD Pipeline / tests (push) Successful in 5m28s
Code Review / ai-code-review (push) Successful in 14s
Type Sync Check / check-type-sync (push) Successful in 27s
CD Pipeline / build-and-deploy (push) Successful in 4m19s
CD Pipeline / post-deploy-checks (push) Successful in 1m39s
This commit is contained in:
@@ -32,11 +32,17 @@ from src.models.governance import (
|
||||
KnowledgeReviewDraftArchiveResponse,
|
||||
KnowledgeReviewDraftDedupeResponse,
|
||||
KnowledgeStaleCandidatesResponse,
|
||||
KnowledgeStaleOwnerReviewRequest,
|
||||
KnowledgeStaleOwnerReviewResponse,
|
||||
)
|
||||
from src.services.governance_km_review_service import (
|
||||
KmReviewDraftArchiveError,
|
||||
archive_km_review_draft_duplicates,
|
||||
)
|
||||
from src.services.governance_km_stale_review_service import (
|
||||
KmStaleOwnerReviewError,
|
||||
queue_km_stale_owner_review,
|
||||
)
|
||||
from src.services.governance_query_service import (
|
||||
query_governance_events,
|
||||
query_governance_queue,
|
||||
@@ -221,6 +227,36 @@ async def get_km_stale_candidates(
|
||||
return await query_km_stale_candidates(project_id=project_id, limit=limit)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# POST /api/v1/ai/governance/km-stale-candidates/{entry_id}/queue-review
|
||||
# =============================================================================
|
||||
|
||||
@router.post(
|
||||
"/ai/governance/km-stale-candidates/{entry_id}/queue-review",
|
||||
response_model=KnowledgeStaleOwnerReviewResponse,
|
||||
)
|
||||
async def post_km_stale_candidate_queue_review(
|
||||
entry_id: str,
|
||||
request: KnowledgeStaleOwnerReviewRequest,
|
||||
) -> KnowledgeStaleOwnerReviewResponse:
|
||||
"""
|
||||
將單筆 stale KM candidate 排入 owner review。
|
||||
|
||||
這個 endpoint 只建立治理事件與 dispatch work item,不修改 KM 內容。
|
||||
實際 refresh / archive / supersede 仍需 owner 在後續流程確認。
|
||||
"""
|
||||
logger.info(
|
||||
"km_stale_candidate_queue_review_request",
|
||||
entry_id=entry_id,
|
||||
owner=request.owner,
|
||||
dry_run=request.dry_run,
|
||||
)
|
||||
try:
|
||||
return await queue_km_stale_owner_review(entry_id=entry_id, request=request)
|
||||
except KmStaleOwnerReviewError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# GET /api/v1/ai/governance/summary
|
||||
# =============================================================================
|
||||
|
||||
@@ -241,6 +241,33 @@ class KnowledgeStaleCandidatesResponse(BaseModel):
|
||||
generated_at: datetime
|
||||
|
||||
|
||||
class KnowledgeStaleOwnerReviewRequest(BaseModel):
|
||||
owner: str = Field(default="operator_console", min_length=1, max_length=100)
|
||||
owner_note: str | None = Field(default=None, max_length=240)
|
||||
dry_run: bool = False
|
||||
|
||||
|
||||
class KnowledgeStaleOwnerReviewResponse(BaseModel):
|
||||
schema_version: str = "km_stale_owner_review_v1"
|
||||
entry_id: str
|
||||
project_id: str
|
||||
status: Literal["dry_run", "queued", "already_queued"]
|
||||
governance_event_id: str | None = None
|
||||
dispatch_id: str | None = None
|
||||
workflow_stage: str
|
||||
recommended_action: Literal[
|
||||
"refresh_with_evidence",
|
||||
"owner_review",
|
||||
"archive_or_supersede",
|
||||
]
|
||||
owner: str
|
||||
owner_note: str | None = None
|
||||
writes_km: bool = False
|
||||
writes_governance_audit: bool
|
||||
next_action: str = "owner_review_stale_km_candidate"
|
||||
generated_at: datetime
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Endpoint 3: summary
|
||||
# =============================================================================
|
||||
|
||||
343
apps/api/src/services/governance_km_stale_review_service.py
Normal file
343
apps/api/src/services/governance_km_stale_review_service.py
Normal file
@@ -0,0 +1,343 @@
|
||||
"""
|
||||
Governance KM Stale Review Service
|
||||
==================================
|
||||
|
||||
Owner-review intake for stale KM priority candidates.
|
||||
|
||||
這層只把 stale KM 候選排入治理工作項與 audit trail,不改寫 KM 內容。
|
||||
真正 refresh / archive / supersede 仍需 owner 後續審核。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Any, Literal
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select, text
|
||||
|
||||
from src.db.base import get_db_context
|
||||
from src.db.models import (
|
||||
AiGovernanceEvent,
|
||||
GovernanceRemediationDispatch,
|
||||
KnowledgeEntryRecord,
|
||||
generate_uuid,
|
||||
taipei_now,
|
||||
)
|
||||
from src.models.governance import (
|
||||
KnowledgeStaleOwnerReviewRequest,
|
||||
KnowledgeStaleOwnerReviewResponse,
|
||||
)
|
||||
from src.models.knowledge import EntryStatus
|
||||
from src.services.governance_agent import KM_STALE_DAYS, KM_STALE_RATIO
|
||||
from src.services.governance_query_service import _build_km_stale_candidate
|
||||
from src.utils.timezone import now_taipei
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
_EXECUTOR_TYPE = "hermes_km_stale_owner_review"
|
||||
|
||||
|
||||
class KmStaleOwnerReviewError(Exception):
|
||||
"""KM stale owner-review request failed validation."""
|
||||
|
||||
def __init__(self, status_code: int, detail: str) -> None:
|
||||
super().__init__(detail)
|
||||
self.status_code = status_code
|
||||
self.detail = detail
|
||||
|
||||
|
||||
async def queue_km_stale_owner_review(
|
||||
*,
|
||||
entry_id: str,
|
||||
request: KnowledgeStaleOwnerReviewRequest,
|
||||
) -> KnowledgeStaleOwnerReviewResponse:
|
||||
"""Queue a stale KM candidate for owner review without modifying KM content."""
|
||||
record = await _load_stale_candidate_record(entry_id)
|
||||
candidate = _build_km_stale_candidate(
|
||||
record,
|
||||
now=now_taipei(),
|
||||
threshold_days=KM_STALE_DAYS,
|
||||
)
|
||||
|
||||
existing = await _load_active_owner_review_dispatch(entry_id)
|
||||
if existing is not None:
|
||||
return _build_response(
|
||||
entry_id=entry_id,
|
||||
project_id=candidate.project_id,
|
||||
status="already_queued",
|
||||
governance_event_id=str(existing.governance_event_id),
|
||||
dispatch_id=str(existing.id),
|
||||
recommended_action=candidate.recommended_action,
|
||||
owner=request.owner,
|
||||
owner_note=request.owner_note,
|
||||
writes_governance_audit=False,
|
||||
)
|
||||
|
||||
if request.dry_run:
|
||||
return _build_response(
|
||||
entry_id=entry_id,
|
||||
project_id=candidate.project_id,
|
||||
status="dry_run",
|
||||
recommended_action=candidate.recommended_action,
|
||||
owner=request.owner,
|
||||
owner_note=request.owner_note,
|
||||
writes_governance_audit=False,
|
||||
)
|
||||
|
||||
now = taipei_now()
|
||||
event_id = generate_uuid()
|
||||
dispatch_id = generate_uuid()
|
||||
event_details = _build_stale_owner_review_event_details(
|
||||
entry_id=entry_id,
|
||||
candidate=candidate.model_dump(mode="json"),
|
||||
owner=request.owner,
|
||||
owner_note=request.owner_note,
|
||||
)
|
||||
decision_context = _build_stale_owner_review_decision_context(
|
||||
governance_event_id=event_id,
|
||||
entry_id=entry_id,
|
||||
candidate=candidate.model_dump(mode="json"),
|
||||
owner=request.owner,
|
||||
owner_note=request.owner_note,
|
||||
)
|
||||
|
||||
async with get_db_context() as db:
|
||||
event = AiGovernanceEvent(
|
||||
id=event_id,
|
||||
event_type="knowledge_degradation",
|
||||
triggered_at=now,
|
||||
details=event_details,
|
||||
resolved=False,
|
||||
)
|
||||
dispatch = GovernanceRemediationDispatch(
|
||||
id=dispatch_id,
|
||||
governance_event_id=event_id,
|
||||
event_type="knowledge_degradation",
|
||||
dispatch_status="pending",
|
||||
playbook_id=None,
|
||||
incident_id=None,
|
||||
approval_id=None,
|
||||
decision_context=decision_context,
|
||||
executor_type=_EXECUTOR_TYPE,
|
||||
attempt_count=0,
|
||||
max_attempts=1,
|
||||
dispatched_at=now,
|
||||
created_by=request.owner[:100],
|
||||
)
|
||||
db.add(event)
|
||||
db.add(dispatch)
|
||||
await db.flush()
|
||||
|
||||
logger.info(
|
||||
"km_stale_owner_review_queued",
|
||||
entry_id=entry_id,
|
||||
project_id=candidate.project_id,
|
||||
governance_event_id=event_id,
|
||||
dispatch_id=dispatch_id,
|
||||
recommended_action=candidate.recommended_action,
|
||||
)
|
||||
return _build_response(
|
||||
entry_id=entry_id,
|
||||
project_id=candidate.project_id,
|
||||
status="queued",
|
||||
governance_event_id=event_id,
|
||||
dispatch_id=dispatch_id,
|
||||
recommended_action=candidate.recommended_action,
|
||||
owner=request.owner,
|
||||
owner_note=request.owner_note,
|
||||
writes_governance_audit=True,
|
||||
)
|
||||
|
||||
|
||||
async def _load_stale_candidate_record(entry_id: str) -> KnowledgeEntryRecord:
|
||||
cutoff = now_taipei() - timedelta(days=KM_STALE_DAYS)
|
||||
async with get_db_context() as db:
|
||||
result = await db.execute(
|
||||
select(KnowledgeEntryRecord).where(KnowledgeEntryRecord.id == entry_id)
|
||||
)
|
||||
record = result.scalar_one_or_none()
|
||||
|
||||
if record is None:
|
||||
raise KmStaleOwnerReviewError(404, "KM entry not found")
|
||||
if _enum_value(record.status) == EntryStatus.ARCHIVED.value:
|
||||
raise KmStaleOwnerReviewError(409, "archived KM entries cannot be queued for stale review")
|
||||
updated_at = record.updated_at
|
||||
if updated_at is not None and updated_at.tzinfo is None:
|
||||
updated_at = updated_at.replace(tzinfo=cutoff.tzinfo)
|
||||
if updated_at is None or updated_at >= cutoff:
|
||||
raise KmStaleOwnerReviewError(409, "KM entry is no longer past the stale threshold")
|
||||
return record
|
||||
|
||||
|
||||
async def _load_active_owner_review_dispatch(
|
||||
entry_id: str,
|
||||
) -> GovernanceRemediationDispatch | None:
|
||||
sql = text("""
|
||||
SELECT *
|
||||
FROM governance_remediation_dispatch d
|
||||
WHERE d.executor_type = :executor_type
|
||||
AND d.dispatch_status::text IN ('pending', 'dispatched', 'executing')
|
||||
AND (
|
||||
d.decision_context -> 'workflow' ->> 'entry_id' = :entry_id
|
||||
OR d.decision_context ->> 'entry_id' = :entry_id
|
||||
)
|
||||
ORDER BY d.dispatched_at DESC
|
||||
LIMIT 1
|
||||
""")
|
||||
async with get_db_context() as db:
|
||||
result = await db.execute(
|
||||
select(GovernanceRemediationDispatch).from_statement(sql),
|
||||
{"executor_type": _EXECUTOR_TYPE, "entry_id": entry_id},
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
def _build_stale_owner_review_event_details(
|
||||
*,
|
||||
entry_id: str,
|
||||
candidate: dict[str, Any],
|
||||
owner: str,
|
||||
owner_note: str | None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": "km_stale_owner_review_event_v1",
|
||||
"trigger_source": "stale_km_priority_queue",
|
||||
"next_action": "owner_review_stale_km_candidate",
|
||||
"impact": {
|
||||
"status": "waiting_owner_review",
|
||||
"metric": "stale_days",
|
||||
"value": candidate.get("stale_days"),
|
||||
"entry_id": entry_id,
|
||||
"priority_tier": candidate.get("priority_tier"),
|
||||
"priority_score": candidate.get("priority_score"),
|
||||
"stale_ratio_threshold": KM_STALE_RATIO,
|
||||
"stale_days_threshold": KM_STALE_DAYS,
|
||||
},
|
||||
"remediation": {
|
||||
"next_action": "owner_review_stale_km_candidate",
|
||||
"items": [
|
||||
"review_current_incident_sentry_signoz_playbook_evidence",
|
||||
"refresh_archive_or_supersede_after_owner_approval",
|
||||
"run_stale_ratio_recheck_after_writeback",
|
||||
],
|
||||
},
|
||||
"ownership": _stale_owner_review_ownership(),
|
||||
"candidate": candidate,
|
||||
"owner": owner,
|
||||
"owner_note": owner_note,
|
||||
}
|
||||
|
||||
|
||||
def _build_stale_owner_review_decision_context(
|
||||
*,
|
||||
governance_event_id: str,
|
||||
entry_id: str,
|
||||
candidate: dict[str, Any],
|
||||
owner: str,
|
||||
owner_note: str | None,
|
||||
) -> dict[str, Any]:
|
||||
recommended_action = str(candidate.get("recommended_action") or "owner_review")
|
||||
return {
|
||||
"schema_version": "km_stale_owner_review_dispatch_v1",
|
||||
"version": "v1",
|
||||
"trigger_source": "stale_km_priority_queue",
|
||||
"triggered_metric": "knowledge_degradation",
|
||||
"metric_value": candidate.get("stale_days"),
|
||||
"threshold": KM_STALE_DAYS,
|
||||
"suggested_action": recommended_action,
|
||||
"next_action": "owner_review_stale_km_candidate",
|
||||
"decision_path": "pending_owner_review",
|
||||
"ownership": _stale_owner_review_ownership(),
|
||||
"entry_id": entry_id,
|
||||
"candidate": candidate,
|
||||
"workflow": {
|
||||
"work_item_id": (
|
||||
"governance:knowledge_degradation:"
|
||||
f"{governance_event_id}:km_stale_owner_review:{entry_id}"
|
||||
),
|
||||
"work_kind": "km_stale_owner_review",
|
||||
"current_stage": "waiting_owner_review",
|
||||
"entry_id": entry_id,
|
||||
"project_id": candidate.get("project_id"),
|
||||
"priority_tier": candidate.get("priority_tier"),
|
||||
"priority_score": candidate.get("priority_score"),
|
||||
"recommended_action": recommended_action,
|
||||
"steps": [
|
||||
"detected",
|
||||
"prioritized_stale_candidate",
|
||||
"waiting_owner_review",
|
||||
"owner_updates_or_archives_km",
|
||||
"stale_ratio_recheck",
|
||||
],
|
||||
"stage_by_dispatch_status": {
|
||||
"pending": "waiting_owner_review",
|
||||
"dispatched": "waiting_owner_review",
|
||||
"executing": "owner_review_in_progress",
|
||||
"succeeded": "km_candidate_reviewed",
|
||||
"failed": "needs_manual_km_triage",
|
||||
"skipped": "waiting_owner_review",
|
||||
"cancelled": "cancelled",
|
||||
},
|
||||
"next_action": "owner_review_stale_km_candidate",
|
||||
"needs_human_review": True,
|
||||
"writes_km_without_approval": False,
|
||||
"writes_km": False,
|
||||
"stale_ratio_recheck_after_writeback": True,
|
||||
},
|
||||
"worker_result": {
|
||||
"status": "queued_owner_review",
|
||||
"entry_id": entry_id,
|
||||
"recommended_action": recommended_action,
|
||||
"writes_km": False,
|
||||
},
|
||||
"owner": owner,
|
||||
"owner_note": owner_note,
|
||||
}
|
||||
|
||||
|
||||
def _stale_owner_review_ownership() -> dict[str, Any]:
|
||||
return {
|
||||
"lead_agent": "Hermes",
|
||||
"support_agents": [
|
||||
"OpenClaw:補 Incident / 規則 / PlayBook 脈絡,不直接批量改寫 KM。",
|
||||
"ElephantAlpha:read-only 稽核 owner review 草稿與風險。",
|
||||
],
|
||||
"human_owner": "KM owner / SRE owner",
|
||||
}
|
||||
|
||||
|
||||
def _build_response(
|
||||
*,
|
||||
entry_id: str,
|
||||
project_id: str,
|
||||
status: Literal["dry_run", "queued", "already_queued"],
|
||||
recommended_action: Literal[
|
||||
"refresh_with_evidence",
|
||||
"owner_review",
|
||||
"archive_or_supersede",
|
||||
],
|
||||
owner: str,
|
||||
writes_governance_audit: bool,
|
||||
owner_note: str | None = None,
|
||||
governance_event_id: str | None = None,
|
||||
dispatch_id: str | None = None,
|
||||
) -> KnowledgeStaleOwnerReviewResponse:
|
||||
return KnowledgeStaleOwnerReviewResponse(
|
||||
entry_id=entry_id,
|
||||
project_id=project_id,
|
||||
status=status,
|
||||
governance_event_id=governance_event_id,
|
||||
dispatch_id=dispatch_id,
|
||||
workflow_stage="waiting_owner_review",
|
||||
recommended_action=recommended_action,
|
||||
owner=owner,
|
||||
owner_note=owner_note,
|
||||
writes_km=False,
|
||||
writes_governance_audit=writes_governance_audit,
|
||||
generated_at=now_taipei(),
|
||||
)
|
||||
|
||||
|
||||
def _enum_value(value: Any) -> str:
|
||||
return str(value.value if hasattr(value, "value") else value)
|
||||
@@ -37,6 +37,8 @@ from src.models.governance import (
|
||||
KnowledgeReviewDraftStaleRatioSnapshot,
|
||||
KnowledgeStaleCandidate,
|
||||
KnowledgeStaleCandidatesResponse,
|
||||
KnowledgeStaleOwnerReviewRequest,
|
||||
KnowledgeStaleOwnerReviewResponse,
|
||||
map_severity,
|
||||
)
|
||||
from src.models.knowledge import EntrySource, EntryStatus, EntryType
|
||||
@@ -47,6 +49,10 @@ from src.services.governance_km_review_service import (
|
||||
_validate_archive_request_against_plan,
|
||||
_validate_dry_run_plan_fingerprint,
|
||||
)
|
||||
from src.services.governance_km_stale_review_service import (
|
||||
KmStaleOwnerReviewError,
|
||||
_build_stale_owner_review_decision_context,
|
||||
)
|
||||
from src.services.governance_query_service import (
|
||||
_build_km_review_draft_dedupe_groups,
|
||||
_build_km_stale_candidate,
|
||||
@@ -689,6 +695,102 @@ class TestKmReviewDraftDedupe:
|
||||
assert "sentry_context" in candidate.reasons
|
||||
assert "signoz_context" in candidate.reasons
|
||||
|
||||
def test_queue_stale_candidate_endpoint_returns_owner_review_dispatch(self, client):
|
||||
"""單筆 stale KM 可以排入 owner review,但不直接改寫 KM。"""
|
||||
fake = KnowledgeStaleOwnerReviewResponse(
|
||||
entry_id="km-001",
|
||||
project_id="awoooi",
|
||||
status="queued",
|
||||
governance_event_id="event-001",
|
||||
dispatch_id="dispatch-001",
|
||||
workflow_stage="waiting_owner_review",
|
||||
recommended_action="refresh_with_evidence",
|
||||
owner="operator_console",
|
||||
owner_note="prioritize P0",
|
||||
writes_km=False,
|
||||
writes_governance_audit=True,
|
||||
generated_at=NOW,
|
||||
)
|
||||
captured: dict = {}
|
||||
|
||||
async def mock_queue(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return fake
|
||||
|
||||
with patch(
|
||||
"src.api.v1.ai_governance.queue_km_stale_owner_review",
|
||||
new=mock_queue,
|
||||
):
|
||||
r = client.post(
|
||||
"/api/v1/ai/governance/km-stale-candidates/km-001/queue-review",
|
||||
json={
|
||||
"owner": "operator_console",
|
||||
"owner_note": "prioritize P0",
|
||||
"dry_run": False,
|
||||
},
|
||||
)
|
||||
|
||||
assert r.status_code == 200
|
||||
assert captured["entry_id"] == "km-001"
|
||||
assert isinstance(captured["request"], KnowledgeStaleOwnerReviewRequest)
|
||||
data = r.json()
|
||||
assert data["schema_version"] == "km_stale_owner_review_v1"
|
||||
assert data["status"] == "queued"
|
||||
assert data["dispatch_id"] == "dispatch-001"
|
||||
assert data["workflow_stage"] == "waiting_owner_review"
|
||||
assert data["writes_km"] is False
|
||||
assert data["writes_governance_audit"] is True
|
||||
|
||||
def test_queue_stale_candidate_endpoint_maps_validation_error(self, client):
|
||||
async def mock_queue(**kwargs):
|
||||
raise KmStaleOwnerReviewError(409, "KM entry is no longer past the stale threshold")
|
||||
|
||||
with patch(
|
||||
"src.api.v1.ai_governance.queue_km_stale_owner_review",
|
||||
new=mock_queue,
|
||||
):
|
||||
r = client.post(
|
||||
"/api/v1/ai/governance/km-stale-candidates/km-001/queue-review",
|
||||
json={"owner": "operator_console"},
|
||||
)
|
||||
|
||||
assert r.status_code == 409
|
||||
assert r.json()["detail"] == "KM entry is no longer past the stale threshold"
|
||||
|
||||
def test_stale_owner_review_context_is_operator_visible(self):
|
||||
candidate = {
|
||||
"entry_id": "km-001",
|
||||
"project_id": "awoooi",
|
||||
"title": "Sentry checkout failure repair",
|
||||
"stale_days": 35,
|
||||
"priority_tier": "P0",
|
||||
"priority_score": 265,
|
||||
"recommended_action": "refresh_with_evidence",
|
||||
"correlation_sources": ["incident", "playbook", "sentry", "signoz"],
|
||||
"related_incident_id": "INC-20260513-79ED5E",
|
||||
"related_playbook_id": "pb:auto-repair-canary",
|
||||
"related_approval_id": "approval-001",
|
||||
}
|
||||
|
||||
ctx = _build_stale_owner_review_decision_context(
|
||||
governance_event_id="event-001",
|
||||
entry_id="km-001",
|
||||
candidate=candidate,
|
||||
owner="operator_console",
|
||||
owner_note="prioritize P0",
|
||||
)
|
||||
|
||||
assert ctx["decision_path"] == "pending_owner_review"
|
||||
assert ctx["next_action"] == "owner_review_stale_km_candidate"
|
||||
assert ctx["workflow"]["work_kind"] == "km_stale_owner_review"
|
||||
assert ctx["workflow"]["current_stage"] == "waiting_owner_review"
|
||||
assert ctx["workflow"]["entry_id"] == "km-001"
|
||||
assert ctx["workflow"]["writes_km"] is False
|
||||
assert ctx["workflow"]["writes_km_without_approval"] is False
|
||||
assert ctx["workflow"]["stage_by_dispatch_status"]["pending"] == "waiting_owner_review"
|
||||
assert ctx["worker_result"]["status"] == "queued_owner_review"
|
||||
assert ctx["ownership"]["lead_agent"] == "Hermes"
|
||||
|
||||
def test_archive_endpoint_requires_owner_shape_and_returns_audit_result(self, client):
|
||||
"""Owner 批准後的 archive endpoint 應回傳 KM write 與 audit write 結果。"""
|
||||
fake = KnowledgeReviewDraftArchiveResponse(
|
||||
|
||||
@@ -2088,7 +2088,16 @@
|
||||
"refs": "Incident: {incident}; PlayBook: {playbook}; Approval: {approval}",
|
||||
"noSources": "No Incident / Sentry / SigNoz / PlayBook link yet",
|
||||
"openKnowledge": "Open KM",
|
||||
"queueReview": "Queue review",
|
||||
"queueingReview": "Queueing",
|
||||
"queueFailed": "Could not queue owner review; refresh and confirm this KM is still stale.",
|
||||
"queueResult": "Review status: {status}; Dispatch: {dispatch}; Event: {event}",
|
||||
"guardrail": "Guardrail: writes on read={writes}; manual review={review}",
|
||||
"queueStatuses": {
|
||||
"dry_run": "Dry-run",
|
||||
"queued": "Queued for owner review",
|
||||
"already_queued": "Already in owner review"
|
||||
},
|
||||
"actions": {
|
||||
"refresh_with_evidence": "Refresh with Incident / Sentry / SigNoz / PlayBook evidence",
|
||||
"owner_review": "Route to owner review",
|
||||
|
||||
@@ -2089,7 +2089,16 @@
|
||||
"refs": "Incident:{incident};PlayBook:{playbook};Approval:{approval}",
|
||||
"noSources": "尚無 Incident / Sentry / SigNoz / PlayBook 關聯",
|
||||
"openKnowledge": "開啟 KM",
|
||||
"queueReview": "排入審核",
|
||||
"queueingReview": "排入中",
|
||||
"queueFailed": "排入 owner review 失敗;請重新整理後再確認此 KM 是否仍為陳舊候選。",
|
||||
"queueResult": "審核狀態:{status};Dispatch:{dispatch};Event:{event}",
|
||||
"guardrail": "防護:讀取不寫入={writes};人工覆核={review}",
|
||||
"queueStatuses": {
|
||||
"dry_run": "乾跑",
|
||||
"queued": "已排入 owner review",
|
||||
"already_queued": "已在 owner review"
|
||||
},
|
||||
"actions": {
|
||||
"refresh_with_evidence": "依 Incident / Sentry / SigNoz / PlayBook 證據刷新",
|
||||
"owner_review": "交由 owner 審核內容",
|
||||
|
||||
@@ -411,6 +411,29 @@ type KnowledgeStaleCandidatesResponse = {
|
||||
generated_at?: string | null;
|
||||
};
|
||||
|
||||
type KnowledgeStaleOwnerReviewResponse = {
|
||||
schema_version?: string;
|
||||
entry_id: string;
|
||||
project_id: string;
|
||||
status: "dry_run" | "queued" | "already_queued";
|
||||
governance_event_id?: string | null;
|
||||
dispatch_id?: string | null;
|
||||
workflow_stage: string;
|
||||
recommended_action: "refresh_with_evidence" | "owner_review" | "archive_or_supersede";
|
||||
owner: string;
|
||||
owner_note?: string | null;
|
||||
writes_km: boolean;
|
||||
writes_governance_audit: boolean;
|
||||
next_action: string;
|
||||
generated_at?: string | null;
|
||||
};
|
||||
|
||||
type KnowledgeStaleOwnerReviewAction = {
|
||||
loading: boolean;
|
||||
result: KnowledgeStaleOwnerReviewResponse | null;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
type DriftFingerprintState = {
|
||||
schema_version?: string;
|
||||
namespace?: string;
|
||||
@@ -1075,6 +1098,17 @@ function kmStaleActionKey(value: string | null | undefined) {
|
||||
}
|
||||
}
|
||||
|
||||
function kmStaleReviewStatusKey(value: string | null | undefined) {
|
||||
switch (value) {
|
||||
case "dry_run":
|
||||
case "queued":
|
||||
case "already_queued":
|
||||
return value;
|
||||
default:
|
||||
return "queued";
|
||||
}
|
||||
}
|
||||
|
||||
function kmCorrelationSourceKey(value: string | null | undefined) {
|
||||
switch (value) {
|
||||
case "incident":
|
||||
@@ -2074,6 +2108,7 @@ function KnowledgeGovernancePanel({
|
||||
}) {
|
||||
const t = useTranslations("awooop.workItems.knowledgeGovernance");
|
||||
const [archiveActions, setArchiveActions] = useState<Record<string, KnowledgeReviewDraftArchiveAction>>({});
|
||||
const [staleReviewActions, setStaleReviewActions] = useState<Record<string, KnowledgeStaleOwnerReviewAction>>({});
|
||||
const items = queue?.items ?? [];
|
||||
const draftGroups = groupKnowledgeReviewDrafts(reviewDrafts, items);
|
||||
const dedupeGroups = dedupe?.groups ?? [];
|
||||
@@ -2177,6 +2212,37 @@ function KnowledgeGovernancePanel({
|
||||
}
|
||||
}, [archiveActions, onArchived, t]);
|
||||
|
||||
const queueStaleOwnerReview = useCallback(async (candidate: KnowledgeStaleCandidate) => {
|
||||
setStaleReviewActions((current) => ({
|
||||
...current,
|
||||
[candidate.entry_id]: {
|
||||
loading: true,
|
||||
result: current[candidate.entry_id]?.result ?? null,
|
||||
error: null,
|
||||
},
|
||||
}));
|
||||
const result = await postJson<KnowledgeStaleOwnerReviewResponse>(
|
||||
`${API_BASE}/api/v1/ai/governance/km-stale-candidates/${encodeURIComponent(candidate.entry_id)}/queue-review`,
|
||||
{
|
||||
owner: "operator_console",
|
||||
owner_note: candidate.priority_tier,
|
||||
dry_run: false,
|
||||
},
|
||||
15000
|
||||
);
|
||||
setStaleReviewActions((current) => ({
|
||||
...current,
|
||||
[candidate.entry_id]: {
|
||||
loading: false,
|
||||
result,
|
||||
error: result ? null : t("staleCandidates.queueFailed"),
|
||||
},
|
||||
}));
|
||||
if (result?.status === "queued" || result?.status === "already_queued") {
|
||||
onArchived();
|
||||
}
|
||||
}, [onArchived, t]);
|
||||
|
||||
return (
|
||||
<section className="border border-[#e0ddd4] bg-white">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[#e0ddd4] bg-[#faf9f3] px-4 py-3">
|
||||
@@ -2325,77 +2391,109 @@ function KnowledgeGovernancePanel({
|
||||
</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}
|
||||
{staleCandidateItems.slice(0, 6).map((candidate) => {
|
||||
const reviewAction = staleReviewActions[candidate.entry_id];
|
||||
const reviewResult = reviewAction?.result ?? null;
|
||||
const reviewStatusKey = kmStaleReviewStatusKey(reviewResult?.status);
|
||||
return (
|
||||
<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 className="mt-1 truncate font-mono text-[11px] text-[#77736a]">
|
||||
{candidate.entry_id}
|
||||
<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>
|
||||
<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"
|
||||
<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>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => queueStaleOwnerReview(candidate)}
|
||||
disabled={Boolean(reviewAction?.loading)}
|
||||
className="inline-flex items-center gap-1.5 border border-[#d8d3c7] bg-white px-2 py-1 text-xs font-semibold text-[#2e2b26] hover:border-[#9bc7a4] hover:bg-[#f0faf2] hover:text-[#17602a] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{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>
|
||||
))}
|
||||
<ClipboardList className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{reviewAction?.loading
|
||||
? t("staleCandidates.queueingReview")
|
||||
: t("staleCandidates.queueReview")}
|
||||
</button>
|
||||
<Link
|
||||
href={`/knowledge-base?q=${encodeURIComponent(candidate.title)}`}
|
||||
className="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>
|
||||
</div>
|
||||
{reviewAction?.error ? (
|
||||
<p className="mt-2 border border-[#e2a29b] bg-[#fff0ef] px-2 py-1 text-[11px] leading-5 text-[#9f2f25]">
|
||||
{reviewAction.error}
|
||||
</p>
|
||||
) : null}
|
||||
{reviewResult ? (
|
||||
<p className="mt-2 border border-[#9bc7a4] bg-[#f0faf2] px-2 py-1 text-[11px] leading-5 text-[#17602a]">
|
||||
{t("staleCandidates.queueResult", {
|
||||
status: t(`staleCandidates.queueStatuses.${reviewStatusKey}` as never),
|
||||
dispatch: reviewResult.dispatch_id ?? "--",
|
||||
event: reviewResult.governance_event_id ?? "--",
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-3 text-[11px] leading-5 text-[#77736a]">
|
||||
|
||||
Reference in New Issue
Block a user