feat(governance): surface stale km owner review inbox
All checks were successful
CD Pipeline / tests (push) Successful in 5m29s
Code Review / ai-code-review (push) Successful in 16s
Type Sync Check / check-type-sync (push) Successful in 28s
CD Pipeline / build-and-deploy (push) Successful in 4m12s
CD Pipeline / post-deploy-checks (push) Successful in 1m30s
All checks were successful
CD Pipeline / tests (push) Successful in 5m29s
Code Review / ai-code-review (push) Successful in 16s
Type Sync Check / check-type-sync (push) Successful in 28s
CD Pipeline / build-and-deploy (push) Successful in 4m12s
CD Pipeline / post-deploy-checks (push) Successful in 1m30s
This commit is contained in:
@@ -36,6 +36,7 @@ from src.models.governance import (
|
||||
KnowledgeStaleOwnerReviewBatchQueueResponse,
|
||||
KnowledgeStaleOwnerReviewCompleteRequest,
|
||||
KnowledgeStaleOwnerReviewCompleteResponse,
|
||||
KnowledgeStaleOwnerReviewInboxResponse,
|
||||
KnowledgeStaleOwnerReviewRequest,
|
||||
KnowledgeStaleOwnerReviewResponse,
|
||||
)
|
||||
@@ -47,6 +48,7 @@ from src.services.governance_km_stale_review_service import (
|
||||
KmStaleOwnerReviewError,
|
||||
batch_queue_km_stale_owner_reviews,
|
||||
complete_km_stale_owner_review,
|
||||
query_km_stale_owner_review_inbox,
|
||||
queue_km_stale_owner_review,
|
||||
)
|
||||
from src.services.governance_query_service import (
|
||||
@@ -233,6 +235,44 @@ async def get_km_stale_candidates(
|
||||
return await query_km_stale_candidates(project_id=project_id, limit=limit)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# GET /api/v1/ai/governance/km-stale-owner-reviews
|
||||
# =============================================================================
|
||||
|
||||
@router.get(
|
||||
"/ai/governance/km-stale-owner-reviews",
|
||||
response_model=KnowledgeStaleOwnerReviewInboxResponse,
|
||||
)
|
||||
async def get_km_stale_owner_reviews(
|
||||
project_id: Annotated[str, Query(min_length=1, max_length=64)] = "awoooi",
|
||||
dispatch_status: Annotated[
|
||||
str,
|
||||
Query(pattern="^(all|pending|dispatched|executing|succeeded|failed|skipped|cancelled)$"),
|
||||
] = "pending",
|
||||
limit: Annotated[int, Query(ge=5, le=100)] = 20,
|
||||
) -> KnowledgeStaleOwnerReviewInboxResponse:
|
||||
"""
|
||||
查詢 stale KM owner-review 工作台。
|
||||
|
||||
這是 read-only inbox:把 dispatch trail 與 KM priority context 合併,
|
||||
讓 operator 可以依 P0/P1、score、batch 來源與流程階段逐筆 completion。
|
||||
"""
|
||||
logger.debug(
|
||||
"km_stale_owner_reviews_request",
|
||||
project_id=project_id,
|
||||
dispatch_status=dispatch_status,
|
||||
limit=limit,
|
||||
)
|
||||
try:
|
||||
return await query_km_stale_owner_review_inbox(
|
||||
project_id=project_id,
|
||||
dispatch_status=dispatch_status,
|
||||
limit=limit,
|
||||
)
|
||||
except KmStaleOwnerReviewError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# POST /api/v1/ai/governance/km-stale-candidates/batch-queue-review
|
||||
# =============================================================================
|
||||
|
||||
@@ -331,6 +331,51 @@ class KnowledgeStaleOwnerReviewBatchQueueResponse(BaseModel):
|
||||
generated_at: datetime
|
||||
|
||||
|
||||
class KnowledgeStaleOwnerReviewInboxItem(BaseModel):
|
||||
dispatch_id: str
|
||||
governance_event_id: str
|
||||
entry_id: str
|
||||
project_id: str
|
||||
title: str
|
||||
dispatch_status: str
|
||||
workflow_stage: str
|
||||
next_action: str | None = None
|
||||
owner: str | None = None
|
||||
owner_note: str | None = None
|
||||
batch_governance_event_id: str | None = None
|
||||
batch_dispatch_id: str | None = None
|
||||
priority_tier: Literal["P0", "P1", "P2"]
|
||||
priority_score: int
|
||||
recommended_action: Literal[
|
||||
"refresh_with_evidence",
|
||||
"owner_review",
|
||||
"archive_or_supersede",
|
||||
]
|
||||
stale_days: int
|
||||
view_count: int
|
||||
correlation_sources: list[str] = Field(default_factory=list)
|
||||
reasons: list[str] = Field(default_factory=list)
|
||||
related_incident_id: str | None = None
|
||||
related_playbook_id: str | None = None
|
||||
related_approval_id: str | None = None
|
||||
dry_run_plan_fingerprint: str | None = None
|
||||
queued_at: datetime | None = None
|
||||
started_at: datetime | None = None
|
||||
completed_at: datetime | None = None
|
||||
|
||||
|
||||
class KnowledgeStaleOwnerReviewInboxResponse(BaseModel):
|
||||
schema_version: str = "km_stale_owner_review_inbox_v1"
|
||||
project_id: str
|
||||
dispatch_status: str
|
||||
total: int
|
||||
returned: int
|
||||
writes_on_read: bool = False
|
||||
manual_review_required: bool = True
|
||||
items: list[KnowledgeStaleOwnerReviewInboxItem] = Field(default_factory=list)
|
||||
generated_at: datetime
|
||||
|
||||
|
||||
class KnowledgeStaleOwnerReviewCompleteRequest(BaseModel):
|
||||
dispatch_id: str | None = Field(
|
||||
default=None,
|
||||
|
||||
@@ -35,6 +35,8 @@ from src.models.governance import (
|
||||
KnowledgeStaleOwnerReviewBatchQueueResponse,
|
||||
KnowledgeStaleOwnerReviewCompleteRequest,
|
||||
KnowledgeStaleOwnerReviewCompleteResponse,
|
||||
KnowledgeStaleOwnerReviewInboxItem,
|
||||
KnowledgeStaleOwnerReviewInboxResponse,
|
||||
KnowledgeStaleOwnerReviewRequest,
|
||||
KnowledgeStaleOwnerReviewResponse,
|
||||
)
|
||||
@@ -53,6 +55,15 @@ _BATCH_EXECUTOR_TYPE = "hermes_km_stale_owner_review_batch"
|
||||
_COMPLETE_EXECUTOR_TYPE = "hermes_km_stale_owner_review_complete"
|
||||
_RECHECK_EXECUTOR_TYPE = "hermes_km_stale_ratio_recheck"
|
||||
_ACTIVE_DISPATCH_STATUSES = frozenset({"pending", "dispatched", "executing"})
|
||||
_OWNER_REVIEW_DISPATCH_STATUSES = frozenset({
|
||||
"pending",
|
||||
"dispatched",
|
||||
"executing",
|
||||
"succeeded",
|
||||
"failed",
|
||||
"skipped",
|
||||
"cancelled",
|
||||
})
|
||||
|
||||
|
||||
class KmStaleOwnerReviewError(Exception):
|
||||
@@ -64,6 +75,115 @@ class KmStaleOwnerReviewError(Exception):
|
||||
self.detail = detail
|
||||
|
||||
|
||||
async def query_km_stale_owner_review_inbox(
|
||||
*,
|
||||
project_id: str = "awoooi",
|
||||
dispatch_status: str = "pending",
|
||||
limit: int = 20,
|
||||
) -> KnowledgeStaleOwnerReviewInboxResponse:
|
||||
"""Read owner-review dispatches with KM priority context for the operator console."""
|
||||
if dispatch_status != "all" and dispatch_status not in _OWNER_REVIEW_DISPATCH_STATUSES:
|
||||
raise KmStaleOwnerReviewError(422, "unsupported stale KM owner-review dispatch_status")
|
||||
|
||||
generated_at = now_taipei()
|
||||
status_filter = (
|
||||
"TRUE"
|
||||
if dispatch_status == "all"
|
||||
else "d.dispatch_status::text = :dispatch_status"
|
||||
)
|
||||
sql = text(f"""
|
||||
SELECT
|
||||
d.id,
|
||||
d.governance_event_id,
|
||||
d.dispatch_status,
|
||||
d.decision_context,
|
||||
d.dispatched_at,
|
||||
d.started_at,
|
||||
d.completed_at,
|
||||
COALESCE(
|
||||
d.decision_context -> 'workflow' ->> 'entry_id',
|
||||
d.decision_context ->> 'entry_id'
|
||||
) AS entry_id,
|
||||
COALESCE(
|
||||
d.decision_context -> 'workflow' ->> 'project_id',
|
||||
d.decision_context ->> 'project_id',
|
||||
d.decision_context -> 'candidate' ->> 'project_id'
|
||||
) AS project_id
|
||||
FROM governance_remediation_dispatch d
|
||||
WHERE d.executor_type = :executor_type
|
||||
AND {status_filter}
|
||||
AND COALESCE(
|
||||
d.decision_context -> 'workflow' ->> 'project_id',
|
||||
d.decision_context ->> 'project_id',
|
||||
d.decision_context -> 'candidate' ->> 'project_id'
|
||||
) = :project_id
|
||||
ORDER BY d.dispatched_at DESC
|
||||
""")
|
||||
params: dict[str, Any] = {
|
||||
"executor_type": _EXECUTOR_TYPE,
|
||||
"project_id": project_id,
|
||||
}
|
||||
if dispatch_status != "all":
|
||||
params["dispatch_status"] = dispatch_status
|
||||
|
||||
async with get_db_context() as db:
|
||||
result = await db.execute(sql, params)
|
||||
rows = result.fetchall()
|
||||
entry_ids = [
|
||||
str(row.entry_id)
|
||||
for row in rows
|
||||
if isinstance(row.entry_id, str) and row.entry_id
|
||||
]
|
||||
records_by_id: dict[str, KnowledgeEntryRecord] = {}
|
||||
if entry_ids:
|
||||
record_result = await db.execute(
|
||||
select(KnowledgeEntryRecord).where(KnowledgeEntryRecord.id.in_(entry_ids))
|
||||
)
|
||||
records_by_id = {
|
||||
str(record.id): record
|
||||
for record in record_result.scalars().all()
|
||||
}
|
||||
|
||||
items: list[KnowledgeStaleOwnerReviewInboxItem] = []
|
||||
for row in rows:
|
||||
entry_id = str(row.entry_id or "")
|
||||
record = records_by_id.get(entry_id)
|
||||
if record is None:
|
||||
continue
|
||||
candidate = _build_km_stale_candidate(
|
||||
record,
|
||||
now=generated_at,
|
||||
threshold_days=KM_STALE_DAYS,
|
||||
)
|
||||
decision_context = row.decision_context if isinstance(row.decision_context, dict) else {}
|
||||
items.append(
|
||||
_build_owner_review_inbox_item(
|
||||
row=row,
|
||||
candidate=candidate,
|
||||
decision_context=decision_context,
|
||||
)
|
||||
)
|
||||
|
||||
items.sort(
|
||||
key=lambda item: (
|
||||
_priority_rank(item.priority_tier),
|
||||
item.priority_score,
|
||||
item.stale_days,
|
||||
item.queued_at.isoformat() if item.queued_at else "",
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
limited = items[:limit]
|
||||
return KnowledgeStaleOwnerReviewInboxResponse(
|
||||
project_id=project_id,
|
||||
dispatch_status=dispatch_status,
|
||||
total=len(items),
|
||||
returned=len(limited),
|
||||
items=limited,
|
||||
generated_at=generated_at,
|
||||
)
|
||||
|
||||
|
||||
async def queue_km_stale_owner_review(
|
||||
*,
|
||||
entry_id: str,
|
||||
@@ -648,6 +768,107 @@ def _count_batch_items(
|
||||
return sum(1 for item in items if item.status == status)
|
||||
|
||||
|
||||
def _build_owner_review_inbox_item(
|
||||
*,
|
||||
row: Any,
|
||||
candidate: KnowledgeStaleCandidate,
|
||||
decision_context: dict[str, Any],
|
||||
) -> KnowledgeStaleOwnerReviewInboxItem:
|
||||
dispatch_status = str(row.dispatch_status)
|
||||
workflow = decision_context.get("workflow") if isinstance(decision_context.get("workflow"), dict) else {}
|
||||
batch = decision_context.get("batch") if isinstance(decision_context.get("batch"), dict) else {}
|
||||
return KnowledgeStaleOwnerReviewInboxItem(
|
||||
dispatch_id=str(row.id),
|
||||
governance_event_id=str(row.governance_event_id),
|
||||
entry_id=candidate.entry_id,
|
||||
project_id=candidate.project_id,
|
||||
title=candidate.title,
|
||||
dispatch_status=dispatch_status,
|
||||
workflow_stage=_extract_owner_review_workflow_stage(decision_context, dispatch_status),
|
||||
next_action=_extract_owner_review_next_action(decision_context),
|
||||
owner=_extract_owner_review_owner(decision_context),
|
||||
owner_note=_extract_owner_review_owner_note(decision_context),
|
||||
batch_governance_event_id=_first_non_empty_string(
|
||||
workflow.get("batch_governance_event_id"),
|
||||
batch.get("batch_governance_event_id"),
|
||||
),
|
||||
batch_dispatch_id=_first_non_empty_string(
|
||||
workflow.get("batch_dispatch_id"),
|
||||
batch.get("batch_dispatch_id"),
|
||||
),
|
||||
priority_tier=candidate.priority_tier,
|
||||
priority_score=candidate.priority_score,
|
||||
recommended_action=candidate.recommended_action,
|
||||
stale_days=candidate.stale_days,
|
||||
view_count=candidate.view_count,
|
||||
correlation_sources=candidate.correlation_sources,
|
||||
reasons=candidate.reasons,
|
||||
related_incident_id=candidate.related_incident_id,
|
||||
related_playbook_id=candidate.related_playbook_id,
|
||||
related_approval_id=candidate.related_approval_id,
|
||||
dry_run_plan_fingerprint=_extract_plan_fingerprint(decision_context),
|
||||
queued_at=row.dispatched_at,
|
||||
started_at=row.started_at,
|
||||
completed_at=row.completed_at,
|
||||
)
|
||||
|
||||
|
||||
def _priority_rank(priority_tier: str) -> int:
|
||||
return {"P0": 3, "P1": 2, "P2": 1}.get(priority_tier, 0)
|
||||
|
||||
|
||||
def _extract_owner_review_workflow_stage(
|
||||
context: dict[str, Any],
|
||||
dispatch_status: str,
|
||||
) -> str:
|
||||
workflow = context.get("workflow")
|
||||
if isinstance(workflow, dict):
|
||||
stages = workflow.get("stage_by_dispatch_status")
|
||||
if isinstance(stages, dict):
|
||||
value = stages.get(dispatch_status)
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
value = workflow.get("current_stage")
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
return {
|
||||
"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",
|
||||
}.get(dispatch_status, "unknown")
|
||||
|
||||
|
||||
def _extract_owner_review_next_action(context: dict[str, Any]) -> str | None:
|
||||
workflow = context.get("workflow")
|
||||
if isinstance(workflow, dict):
|
||||
value = workflow.get("next_action")
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
value = context.get("next_action")
|
||||
return value if isinstance(value, str) and value else None
|
||||
|
||||
|
||||
def _extract_owner_review_owner(context: dict[str, Any]) -> str | None:
|
||||
value = context.get("owner")
|
||||
return value if isinstance(value, str) and value else None
|
||||
|
||||
|
||||
def _extract_owner_review_owner_note(context: dict[str, Any]) -> str | None:
|
||||
value = context.get("owner_note")
|
||||
return value if isinstance(value, str) and value else None
|
||||
|
||||
|
||||
def _first_non_empty_string(*values: Any) -> str | None:
|
||||
for value in values:
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
async def complete_km_stale_owner_review(
|
||||
*,
|
||||
entry_id: str,
|
||||
|
||||
@@ -42,6 +42,8 @@ from src.models.governance import (
|
||||
KnowledgeStaleOwnerReviewBatchQueueResponse,
|
||||
KnowledgeStaleOwnerReviewCompleteRequest,
|
||||
KnowledgeStaleOwnerReviewCompleteResponse,
|
||||
KnowledgeStaleOwnerReviewInboxItem,
|
||||
KnowledgeStaleOwnerReviewInboxResponse,
|
||||
KnowledgeStaleOwnerReviewRequest,
|
||||
KnowledgeStaleOwnerReviewResponse,
|
||||
map_severity,
|
||||
@@ -60,6 +62,7 @@ from src.services.governance_km_stale_review_service import (
|
||||
_build_batch_queue_plan_fingerprint,
|
||||
_build_completion_plan_fingerprint,
|
||||
_build_owner_review_completion_audit_context,
|
||||
_build_owner_review_inbox_item,
|
||||
_build_stale_owner_review_decision_context,
|
||||
_completion_stage_for_outcome,
|
||||
)
|
||||
@@ -878,6 +881,124 @@ class TestKmReviewDraftDedupe:
|
||||
assert r.status_code == 409
|
||||
assert "batch queue plan" in r.json()["detail"]
|
||||
|
||||
def test_owner_review_inbox_endpoint_returns_sorted_work_items(self, client):
|
||||
"""Owner-review inbox 應回傳 pending dispatch 與 KM priority context。"""
|
||||
fake = KnowledgeStaleOwnerReviewInboxResponse(
|
||||
project_id="awoooi",
|
||||
dispatch_status="pending",
|
||||
total=1,
|
||||
returned=1,
|
||||
items=[
|
||||
KnowledgeStaleOwnerReviewInboxItem(
|
||||
dispatch_id="dispatch-001",
|
||||
governance_event_id="event-001",
|
||||
entry_id="km-001",
|
||||
project_id="awoooi",
|
||||
title="Sentry checkout failure repair",
|
||||
dispatch_status="pending",
|
||||
workflow_stage="waiting_owner_review",
|
||||
next_action="owner_review_stale_km_candidate",
|
||||
owner="operator_console",
|
||||
owner_note="p0_p1_stale_km_batch",
|
||||
batch_governance_event_id="event-batch-001",
|
||||
batch_dispatch_id="dispatch-batch-001",
|
||||
priority_tier="P0",
|
||||
priority_score=265,
|
||||
recommended_action="refresh_with_evidence",
|
||||
stale_days=35,
|
||||
view_count=7,
|
||||
correlation_sources=["incident", "playbook", "sentry"],
|
||||
reasons=["linked_incident", "linked_playbook"],
|
||||
related_incident_id="INC-20260513-79ED5E",
|
||||
related_playbook_id="pb:auto-repair-canary",
|
||||
queued_at=NOW,
|
||||
)
|
||||
],
|
||||
generated_at=NOW,
|
||||
)
|
||||
captured: dict = {}
|
||||
|
||||
async def mock_inbox(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return fake
|
||||
|
||||
with patch(
|
||||
"src.api.v1.ai_governance.query_km_stale_owner_review_inbox",
|
||||
new=mock_inbox,
|
||||
):
|
||||
r = client.get(
|
||||
"/api/v1/ai/governance/km-stale-owner-reviews"
|
||||
"?project_id=awoooi&dispatch_status=pending&limit=20"
|
||||
)
|
||||
|
||||
assert r.status_code == 200
|
||||
assert captured == {
|
||||
"project_id": "awoooi",
|
||||
"dispatch_status": "pending",
|
||||
"limit": 20,
|
||||
}
|
||||
data = r.json()
|
||||
assert data["schema_version"] == "km_stale_owner_review_inbox_v1"
|
||||
assert data["total"] == 1
|
||||
assert data["writes_on_read"] is False
|
||||
assert data["manual_review_required"] is True
|
||||
assert data["items"][0]["dispatch_id"] == "dispatch-001"
|
||||
assert data["items"][0]["workflow_stage"] == "waiting_owner_review"
|
||||
assert data["items"][0]["batch_dispatch_id"] == "dispatch-batch-001"
|
||||
|
||||
def test_owner_review_inbox_context_keeps_batch_and_priority_visible(self):
|
||||
record = KnowledgeEntryRecord(
|
||||
id="km-001",
|
||||
project_id="awoooi",
|
||||
title="Sentry checkout failure repair",
|
||||
content="Use Sentry and SigNoz evidence before writeback.",
|
||||
entry_type=EntryType.AUTO_RUNBOOK,
|
||||
category="AI系統",
|
||||
tags=["sentry", "signoz"],
|
||||
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)
|
||||
row = type("Row", (), {
|
||||
"id": "dispatch-001",
|
||||
"governance_event_id": "event-001",
|
||||
"dispatch_status": "pending",
|
||||
"dispatched_at": NOW,
|
||||
"started_at": None,
|
||||
"completed_at": None,
|
||||
})()
|
||||
ctx = {
|
||||
"owner": "operator_console",
|
||||
"owner_note": "p0_p1_stale_km_batch",
|
||||
"next_action": "owner_review_stale_km_candidate",
|
||||
"workflow": {
|
||||
"entry_id": "km-001",
|
||||
"project_id": "awoooi",
|
||||
"batch_governance_event_id": "event-batch-001",
|
||||
"batch_dispatch_id": "dispatch-batch-001",
|
||||
"current_stage": "waiting_owner_review",
|
||||
"stage_by_dispatch_status": {"pending": "waiting_owner_review"},
|
||||
},
|
||||
}
|
||||
|
||||
item = _build_owner_review_inbox_item(
|
||||
row=row,
|
||||
candidate=candidate,
|
||||
decision_context=ctx,
|
||||
)
|
||||
|
||||
assert item.dispatch_id == "dispatch-001"
|
||||
assert item.batch_dispatch_id == "dispatch-batch-001"
|
||||
assert item.priority_tier == "P0"
|
||||
assert item.priority_score > 200
|
||||
assert item.workflow_stage == "waiting_owner_review"
|
||||
assert item.recommended_action == "refresh_with_evidence"
|
||||
assert item.correlation_sources == ["incident", "playbook", "sentry", "signoz"]
|
||||
|
||||
def test_stale_owner_review_batch_context_is_operator_visible(self):
|
||||
request = KnowledgeStaleOwnerReviewBatchQueueRequest(
|
||||
project_id="awoooi",
|
||||
|
||||
@@ -2099,6 +2099,17 @@
|
||||
"queued": "Queued for owner review",
|
||||
"already_queued": "Already in owner review"
|
||||
},
|
||||
"ownerReviewInbox": {
|
||||
"title": "Owner Review Inbox",
|
||||
"subtitle": "Shows P0/P1 KM already waiting for owner review, with per-item dry-run and completion.",
|
||||
"total": "Pending {count}",
|
||||
"returned": "Shown {count}",
|
||||
"unavailable": "The owner-review inbox API has not responded; use the candidate list for single-item actions.",
|
||||
"empty": "No pending owner-review KM.",
|
||||
"meta": "Stale {days}d; score {score}; views {views}",
|
||||
"state": "Status: {status}; stage: {stage}",
|
||||
"batch": "Batch: {batch}"
|
||||
},
|
||||
"batchActions": {
|
||||
"title": "Batch P0 / P1 stale KM",
|
||||
"subtitle": "Dry-run the latest P0 / P1 candidates first, then create owner-review dispatches in batch; KM is not written directly.",
|
||||
|
||||
@@ -2100,6 +2100,17 @@
|
||||
"queued": "已排入 owner review",
|
||||
"already_queued": "已在 owner review"
|
||||
},
|
||||
"ownerReviewInbox": {
|
||||
"title": "Owner review 工作台",
|
||||
"subtitle": "顯示已排入 waiting_owner_review 的 P0/P1 KM,逐筆乾跑與確認完成。",
|
||||
"total": "待審 {count}",
|
||||
"returned": "顯示 {count}",
|
||||
"unavailable": "owner-review inbox API 尚未回應;目前只能從候選清單逐筆操作。",
|
||||
"empty": "目前沒有 pending owner-review KM。",
|
||||
"meta": "陳舊 {days} 天;分數 {score};瀏覽 {views}",
|
||||
"state": "狀態:{status};階段:{stage}",
|
||||
"batch": "Batch:{batch}"
|
||||
},
|
||||
"batchActions": {
|
||||
"title": "批次處理 P0 / P1 陳舊 KM",
|
||||
"subtitle": "先乾跑鎖定最新 P0 / P1 候選,再批次建立 owner-review dispatch;不會直接寫入 KM。",
|
||||
|
||||
@@ -489,6 +489,47 @@ type KnowledgeStaleOwnerReviewBatchAction = {
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
type KnowledgeStaleOwnerReviewInboxItem = {
|
||||
dispatch_id: string;
|
||||
governance_event_id: string;
|
||||
entry_id: string;
|
||||
project_id: string;
|
||||
title: string;
|
||||
dispatch_status: string;
|
||||
workflow_stage: string;
|
||||
next_action?: string | null;
|
||||
owner?: string | null;
|
||||
owner_note?: string | null;
|
||||
batch_governance_event_id?: string | null;
|
||||
batch_dispatch_id?: string | null;
|
||||
priority_tier: "P0" | "P1" | "P2";
|
||||
priority_score: number;
|
||||
recommended_action: "refresh_with_evidence" | "owner_review" | "archive_or_supersede";
|
||||
stale_days: number;
|
||||
view_count: number;
|
||||
correlation_sources: string[];
|
||||
reasons: string[];
|
||||
related_incident_id?: string | null;
|
||||
related_playbook_id?: string | null;
|
||||
related_approval_id?: string | null;
|
||||
dry_run_plan_fingerprint?: string | null;
|
||||
queued_at?: string | null;
|
||||
started_at?: string | null;
|
||||
completed_at?: string | null;
|
||||
};
|
||||
|
||||
type KnowledgeStaleOwnerReviewInboxResponse = {
|
||||
schema_version?: string;
|
||||
project_id: string;
|
||||
dispatch_status: string;
|
||||
total: number;
|
||||
returned: number;
|
||||
writes_on_read: boolean;
|
||||
manual_review_required: boolean;
|
||||
items: KnowledgeStaleOwnerReviewInboxItem[];
|
||||
generated_at?: string | null;
|
||||
};
|
||||
|
||||
type KnowledgeStaleOwnerReviewCompleteResponse = {
|
||||
schema_version?: string;
|
||||
entry_id: string;
|
||||
@@ -608,6 +649,7 @@ type Telemetry = {
|
||||
knowledgeReviewDrafts: KnowledgeListResponse | null;
|
||||
knowledgeReviewDedupe: KnowledgeReviewDraftDedupeResponse | null;
|
||||
knowledgeStaleCandidates: KnowledgeStaleCandidatesResponse | null;
|
||||
knowledgeStaleOwnerReviews: KnowledgeStaleOwnerReviewInboxResponse | null;
|
||||
channelEvents: RecentEventsResponse | null;
|
||||
eventRecurrence: RecurrenceResponse | null;
|
||||
slo: SloResponse | null;
|
||||
@@ -1247,6 +1289,33 @@ function kmStaleReviewOutcomeForCandidate(
|
||||
return "refresh_with_evidence";
|
||||
}
|
||||
|
||||
function staleCandidateFromOwnerReviewItem(
|
||||
item: KnowledgeStaleOwnerReviewInboxItem
|
||||
): KnowledgeStaleCandidate {
|
||||
return {
|
||||
entry_id: item.entry_id,
|
||||
project_id: item.project_id,
|
||||
title: item.title,
|
||||
entry_type: "auto_runbook",
|
||||
status: "review",
|
||||
stale_days: item.stale_days,
|
||||
view_count: item.view_count,
|
||||
priority_score: item.priority_score,
|
||||
priority_tier: item.priority_tier,
|
||||
recommended_action: item.recommended_action,
|
||||
reasons: item.reasons,
|
||||
correlation_sources: item.correlation_sources,
|
||||
related_incident_id: item.related_incident_id,
|
||||
related_playbook_id: item.related_playbook_id,
|
||||
related_approval_id: item.related_approval_id,
|
||||
tags: [],
|
||||
owner_review_dispatch_id: item.dispatch_id,
|
||||
owner_review_status: item.dispatch_status,
|
||||
owner_review_stage: item.workflow_stage,
|
||||
owner_review_next_action: item.next_action,
|
||||
};
|
||||
}
|
||||
|
||||
function kmCorrelationSourceKey(value: string | null | undefined) {
|
||||
switch (value) {
|
||||
case "incident":
|
||||
@@ -2236,12 +2305,14 @@ function KnowledgeGovernancePanel({
|
||||
reviewDrafts,
|
||||
dedupe,
|
||||
staleCandidates,
|
||||
ownerReviewInbox,
|
||||
onArchived,
|
||||
}: {
|
||||
queue: GovernanceQueueResponse | null;
|
||||
reviewDrafts: KnowledgeListResponse | null;
|
||||
dedupe: KnowledgeReviewDraftDedupeResponse | null;
|
||||
staleCandidates: KnowledgeStaleCandidatesResponse | null;
|
||||
ownerReviewInbox: KnowledgeStaleOwnerReviewInboxResponse | null;
|
||||
onArchived: () => void;
|
||||
}) {
|
||||
const t = useTranslations("awooop.workItems.knowledgeGovernance");
|
||||
@@ -2264,6 +2335,7 @@ function KnowledgeGovernancePanel({
|
||||
?? draftGroups.reduce((sum, group) => sum + group.duplicateCount, 0)
|
||||
);
|
||||
const staleCandidateItems = staleCandidates?.items ?? [];
|
||||
const ownerReviewItems = ownerReviewInbox?.items ?? [];
|
||||
const draftTotal = dedupe?.total_review_drafts ?? reviewDrafts?.total ?? 0;
|
||||
const activeCount = items.filter((item) =>
|
||||
["pending", "dispatched", "executing"].includes(item.dispatch_status)
|
||||
@@ -2829,6 +2901,155 @@ function KnowledgeGovernancePanel({
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mb-3 border border-[#e0ddd4] bg-white px-3 py-2 text-xs text-[#5f5b52]">
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardList className="h-4 w-4 text-[#5f5b52]" aria-hidden="true" />
|
||||
<div>
|
||||
<p className="font-semibold text-[#141413]">
|
||||
{t("staleCandidates.ownerReviewInbox.title")}
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] leading-5 text-[#77736a]">
|
||||
{t("staleCandidates.ownerReviewInbox.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 font-mono text-[11px] text-[#5f5b52]">
|
||||
<span className="border border-[#d8d3c7] bg-[#faf9f3] px-2 py-0.5">
|
||||
{t("staleCandidates.ownerReviewInbox.total", {
|
||||
count: ownerReviewInbox?.total ?? 0,
|
||||
})}
|
||||
</span>
|
||||
<span className="border border-[#d8d3c7] bg-[#faf9f3] px-2 py-0.5">
|
||||
{t("staleCandidates.ownerReviewInbox.returned", {
|
||||
count: ownerReviewInbox?.returned ?? 0,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{ownerReviewInbox === null ? (
|
||||
<p className="text-[11px] leading-5 text-[#8a5a08]">
|
||||
{t("staleCandidates.ownerReviewInbox.unavailable")}
|
||||
</p>
|
||||
) : ownerReviewItems.length === 0 ? (
|
||||
<p className="text-[11px] leading-5 text-[#5f5b52]">
|
||||
{t("staleCandidates.ownerReviewInbox.empty")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
{ownerReviewItems.slice(0, 6).map((item) => {
|
||||
const inboxCandidate = staleCandidateFromOwnerReviewItem(item);
|
||||
const completionAction = staleReviewCompletionActions[item.entry_id];
|
||||
const completionPreview = completionAction?.previewResult ?? null;
|
||||
const completionResult = completionAction?.result ?? null;
|
||||
const completionPreviewReady = Boolean(completionPreview?.dry_run_plan_fingerprint);
|
||||
const completionPreviewStatusKey = kmStaleReviewCompleteStatusKey(completionPreview?.status);
|
||||
const completionResultStatusKey = kmStaleReviewCompleteStatusKey(completionResult?.status);
|
||||
return (
|
||||
<article
|
||||
key={item.dispatch_id}
|
||||
className="border border-[#e0ddd4] bg-[#faf9f3] px-3 py-2 text-[11px] leading-5 text-[#5f5b52]"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold text-[#141413]">
|
||||
{item.title}
|
||||
</p>
|
||||
<p className="mt-1 truncate font-mono text-[#77736a]">
|
||||
{item.dispatch_id}
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 border border-[#d9b36f] bg-[#fff7e8] px-2 py-0.5 font-mono font-semibold text-[#8a5a08]">
|
||||
{item.priority_tier}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 grid gap-1">
|
||||
<p>
|
||||
{t("staleCandidates.ownerReviewInbox.meta", {
|
||||
days: item.stale_days,
|
||||
score: item.priority_score,
|
||||
views: item.view_count,
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
{t("staleCandidates.ownerReviewInbox.state", {
|
||||
status: t(`statuses.${governanceKmDispatchStatusKey(item.dispatch_status)}` as never),
|
||||
stage: t(`stages.${governanceKmStageKey(item.workflow_stage)}` as never),
|
||||
})}
|
||||
</p>
|
||||
<p className="truncate">
|
||||
{t("staleCandidates.ownerReviewInbox.batch", {
|
||||
batch: item.batch_dispatch_id ?? "--",
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
{t("staleCandidates.refs", {
|
||||
incident: item.related_incident_id ?? "--",
|
||||
playbook: item.related_playbook_id ?? "--",
|
||||
approval: item.related_approval_id ?? "--",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => previewStaleReviewCompletion(inboxCandidate)}
|
||||
disabled={completionAction?.previewLoading || completionAction?.confirmLoading}
|
||||
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"
|
||||
>
|
||||
<SearchCheck className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{completionAction?.previewLoading
|
||||
? t("staleCandidates.completeActions.previewing")
|
||||
: t("staleCandidates.completeActions.preview")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => confirmStaleReviewCompletion(inboxCandidate)}
|
||||
disabled={
|
||||
!completionPreviewReady ||
|
||||
completionAction?.previewLoading ||
|
||||
completionAction?.confirmLoading
|
||||
}
|
||||
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-[#d9b36f] hover:bg-[#fff7e8] hover:text-[#8a5a08] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<ShieldCheck className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{completionAction?.confirmLoading
|
||||
? t("staleCandidates.completeActions.confirming")
|
||||
: t("staleCandidates.completeActions.confirm")}
|
||||
</button>
|
||||
</div>
|
||||
{completionAction?.error ? (
|
||||
<p className="mt-2 border border-[#e2a29b] bg-[#fff0ef] px-2 py-1 text-[#9f2f25]">
|
||||
{completionAction.error}
|
||||
</p>
|
||||
) : null}
|
||||
{completionPreview ? (
|
||||
<p className="mt-2 border border-[#d9b36f] bg-[#fff7e8] px-2 py-1 text-[#8a5a08]">
|
||||
{t(
|
||||
`staleCandidates.completeActions.statuses.${completionPreviewStatusKey}` as never
|
||||
)}{" "}
|
||||
{t("staleCandidates.completeActions.planFingerprint", {
|
||||
fingerprint: completionPreview.dry_run_plan_fingerprint ?? "--",
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
{completionResult ? (
|
||||
<p className="mt-2 border border-[#9bc7a4] bg-[#f0faf2] px-2 py-1 text-[#17602a]">
|
||||
{t(
|
||||
`staleCandidates.completeActions.statuses.${completionResultStatusKey}` as never
|
||||
)}{" "}
|
||||
{t("staleCandidates.completeActions.result", {
|
||||
audit: completionResult.audit_dispatch_id ?? "--",
|
||||
recheck: completionResult.stale_ratio_recheck_dispatch_id ?? "--",
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{staleCandidates === null ? (
|
||||
<p className="text-xs text-[#8a5a08]">
|
||||
{t("staleCandidates.unavailable")}
|
||||
@@ -3577,6 +3798,7 @@ export default function AwoooPWorkItemsPage() {
|
||||
knowledgeReviewDrafts: null,
|
||||
knowledgeReviewDedupe: null,
|
||||
knowledgeStaleCandidates: null,
|
||||
knowledgeStaleOwnerReviews: null,
|
||||
channelEvents: null,
|
||||
eventRecurrence: null,
|
||||
slo: null,
|
||||
@@ -3597,6 +3819,7 @@ export default function AwoooPWorkItemsPage() {
|
||||
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 knowledgeStaleOwnerReviewsUrl = `${API_BASE}/api/v1/ai/governance/km-stale-owner-reviews?project_id=${encodedProjectId}&dispatch_status=pending&limit=30`;
|
||||
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`;
|
||||
@@ -3611,6 +3834,7 @@ export default function AwoooPWorkItemsPage() {
|
||||
knowledgeReviewDrafts,
|
||||
knowledgeReviewDedupe,
|
||||
knowledgeStaleCandidates,
|
||||
knowledgeStaleOwnerReviews,
|
||||
channelEvents,
|
||||
eventRecurrence,
|
||||
slo,
|
||||
@@ -3624,6 +3848,7 @@ export default function AwoooPWorkItemsPage() {
|
||||
fetchJson<KnowledgeListResponse>(knowledgeReviewDraftsUrl),
|
||||
fetchJson<KnowledgeReviewDraftDedupeResponse>(knowledgeReviewDedupeUrl),
|
||||
fetchJson<KnowledgeStaleCandidatesResponse>(knowledgeStaleCandidatesUrl),
|
||||
fetchJson<KnowledgeStaleOwnerReviewInboxResponse>(knowledgeStaleOwnerReviewsUrl),
|
||||
fetchJson<RecentEventsResponse>(channelEventsUrl),
|
||||
fetchJson<RecurrenceResponse>(recurrenceUrl),
|
||||
fetchJson<SloResponse>(sloUrl),
|
||||
@@ -3654,6 +3879,7 @@ export default function AwoooPWorkItemsPage() {
|
||||
knowledgeReviewDrafts,
|
||||
knowledgeReviewDedupe,
|
||||
knowledgeStaleCandidates,
|
||||
knowledgeStaleOwnerReviews,
|
||||
channelEvents,
|
||||
eventRecurrence,
|
||||
slo,
|
||||
@@ -3781,6 +4007,7 @@ export default function AwoooPWorkItemsPage() {
|
||||
reviewDrafts={telemetry.knowledgeReviewDrafts}
|
||||
dedupe={telemetry.knowledgeReviewDedupe}
|
||||
staleCandidates={telemetry.knowledgeStaleCandidates}
|
||||
ownerReviewInbox={telemetry.knowledgeStaleOwnerReviews}
|
||||
onArchived={fetchTelemetry}
|
||||
/>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user