diff --git a/apps/api/src/api/v1/ai_governance.py b/apps/api/src/api/v1/ai_governance.py
index d2e6a48c2..afd501607 100644
--- a/apps/api/src/api/v1/ai_governance.py
+++ b/apps/api/src/api/v1/ai_governance.py
@@ -37,6 +37,7 @@ from src.models.governance import (
KnowledgeStaleOwnerReviewBurnDownResponse,
KnowledgeStaleOwnerReviewCompleteRequest,
KnowledgeStaleOwnerReviewCompleteResponse,
+ KnowledgeStaleOwnerReviewCompletionQueueResponse,
KnowledgeStaleOwnerReviewInboxResponse,
KnowledgeStaleOwnerReviewRequest,
KnowledgeStaleOwnerReviewResponse,
@@ -50,6 +51,7 @@ from src.services.governance_km_stale_review_service import (
batch_queue_km_stale_owner_reviews,
complete_km_stale_owner_review,
query_km_stale_owner_review_burndown,
+ query_km_stale_owner_review_completion_queue,
query_km_stale_owner_review_inbox,
queue_km_stale_owner_review,
)
@@ -304,6 +306,41 @@ async def get_km_stale_owner_review_burndown(
)
+# =============================================================================
+# GET /api/v1/ai/governance/km-stale-owner-review-completion-queue
+# =============================================================================
+
+@router.get(
+ "/ai/governance/km-stale-owner-review-completion-queue",
+ response_model=KnowledgeStaleOwnerReviewCompletionQueueResponse,
+)
+async def get_km_stale_owner_review_completion_queue(
+ project_id: Annotated[str, Query(min_length=1, max_length=64)] = "awoooi",
+ status_bucket: Annotated[
+ str,
+ Query(pattern="^(all|ready|blocked|completed|failed|pending)$"),
+ ] = "all",
+ limit: Annotated[int, Query(ge=1, le=100)] = 20,
+) -> KnowledgeStaleOwnerReviewCompletionQueueResponse:
+ """
+ 查詢 stale KM owner-review completion 分流。
+
+ 這是 read-only queue:把 active / completed / failed dispatch 拆成
+ ready、blocked、completed、failed,讓前端呈現下一步卡點;打開頁面不寫 KM。
+ """
+ logger.debug(
+ "km_stale_owner_review_completion_queue_request",
+ project_id=project_id,
+ status_bucket=status_bucket,
+ limit=limit,
+ )
+ return await query_km_stale_owner_review_completion_queue(
+ project_id=project_id,
+ status_bucket=status_bucket,
+ limit=limit,
+ )
+
+
# =============================================================================
# POST /api/v1/ai/governance/km-stale-candidates/batch-queue-review
# =============================================================================
diff --git a/apps/api/src/models/governance.py b/apps/api/src/models/governance.py
index 42176dd4b..41687df86 100644
--- a/apps/api/src/models/governance.py
+++ b/apps/api/src/models/governance.py
@@ -417,6 +417,68 @@ class KnowledgeStaleOwnerReviewBurnDownResponse(BaseModel):
generated_at: datetime
+class KnowledgeStaleOwnerReviewCompletionQueueItem(BaseModel):
+ dispatch_id: str
+ governance_event_id: str
+ entry_id: str
+ project_id: str
+ title: str
+ dispatch_status: str
+ workflow_stage: str
+ readiness: Literal["ready", "blocked", "completed", "failed"]
+ recommended_completion_outcome: Literal[
+ "refresh_with_evidence",
+ "archive",
+ "supersede",
+ ]
+ next_action: str
+ blockers: list[str] = Field(default_factory=list)
+ required_owner_fields: list[str] = Field(default_factory=list)
+ can_preview: bool
+ can_confirm_after_preview: bool
+ writes_km_on_confirm: bool
+ 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 KnowledgeStaleOwnerReviewCompletionQueueResponse(BaseModel):
+ schema_version: str = "km_stale_owner_review_completion_queue_v1"
+ project_id: str
+ status_bucket: Literal["all", "ready", "blocked", "completed", "failed", "pending"]
+ total: int
+ returned: int
+ pending_count: int
+ ready_count: int
+ blocked_count: int
+ completed_count: int
+ failed_count: int
+ writes_on_read: bool = False
+ manual_review_required: bool = True
+ batch_writes_allowed: bool = False
+ items: list[KnowledgeStaleOwnerReviewCompletionQueueItem] = Field(default_factory=list)
+ generated_at: datetime
+
+
class KnowledgeStaleOwnerReviewCompleteRequest(BaseModel):
dispatch_id: str | None = Field(
default=None,
diff --git a/apps/api/src/services/governance_km_stale_review_service.py b/apps/api/src/services/governance_km_stale_review_service.py
index 4ab6f15c5..7d0288b81 100644
--- a/apps/api/src/services/governance_km_stale_review_service.py
+++ b/apps/api/src/services/governance_km_stale_review_service.py
@@ -37,6 +37,8 @@ from src.models.governance import (
KnowledgeStaleOwnerReviewBurnDownResponse,
KnowledgeStaleOwnerReviewCompleteRequest,
KnowledgeStaleOwnerReviewCompleteResponse,
+ KnowledgeStaleOwnerReviewCompletionQueueItem,
+ KnowledgeStaleOwnerReviewCompletionQueueResponse,
KnowledgeStaleOwnerReviewInboxItem,
KnowledgeStaleOwnerReviewInboxResponse,
KnowledgeStaleOwnerReviewRequest,
@@ -57,6 +59,7 @@ _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"})
+_TERMINAL_FAILED_DISPATCH_STATUSES = frozenset({"failed", "cancelled"})
_OWNER_REVIEW_DISPATCH_STATUSES = frozenset({
"pending",
"dispatched",
@@ -88,6 +91,88 @@ async def query_km_stale_owner_review_inbox(
raise KmStaleOwnerReviewError(422, "unsupported stale KM owner-review dispatch_status")
generated_at = now_taipei()
+ items = await _load_owner_review_inbox_items(
+ project_id=project_id,
+ dispatch_status=dispatch_status,
+ generated_at=generated_at,
+ )
+ 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 query_km_stale_owner_review_completion_queue(
+ *,
+ project_id: str = "awoooi",
+ status_bucket: Literal["all", "ready", "blocked", "completed", "failed", "pending"] = "all",
+ limit: int = 20,
+) -> KnowledgeStaleOwnerReviewCompletionQueueResponse:
+ """Read owner-review completion readiness without writing KM content."""
+ generated_at = now_taipei()
+ inbox_items = await _load_owner_review_inbox_items(
+ project_id=project_id,
+ dispatch_status="all",
+ generated_at=generated_at,
+ )
+ queue_items = [
+ _build_completion_queue_item(item)
+ for item in inbox_items
+ ]
+ queue_items.sort(
+ key=lambda item: (
+ item.readiness == "ready",
+ _priority_rank(item.priority_tier),
+ item.priority_score,
+ item.stale_days,
+ item.queued_at.isoformat() if item.queued_at else "",
+ ),
+ reverse=True,
+ )
+
+ filtered_items = [
+ item for item in queue_items
+ if _completion_queue_bucket_matches(item, status_bucket=status_bucket)
+ ]
+ limited = filtered_items[:limit]
+ return KnowledgeStaleOwnerReviewCompletionQueueResponse(
+ project_id=project_id,
+ status_bucket=status_bucket,
+ total=len(filtered_items),
+ returned=len(limited),
+ pending_count=sum(
+ 1 for item in queue_items
+ if item.dispatch_status in _ACTIVE_DISPATCH_STATUSES
+ ),
+ ready_count=sum(1 for item in queue_items if item.readiness == "ready"),
+ blocked_count=sum(1 for item in queue_items if item.readiness == "blocked"),
+ completed_count=sum(1 for item in queue_items if item.readiness == "completed"),
+ failed_count=sum(1 for item in queue_items if item.readiness == "failed"),
+ items=limited,
+ generated_at=generated_at,
+ )
+
+
+async def _load_owner_review_inbox_items(
+ *,
+ project_id: str,
+ dispatch_status: str,
+ generated_at: Any,
+) -> list[KnowledgeStaleOwnerReviewInboxItem]:
status_filter = (
"TRUE"
if dispatch_status == "all"
@@ -165,25 +250,7 @@ async def query_km_stale_owner_review_inbox(
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,
- )
+ return items
async def query_km_stale_owner_review_burndown(
@@ -850,6 +917,128 @@ def _build_owner_review_inbox_item(
)
+def _build_completion_queue_item(
+ item: KnowledgeStaleOwnerReviewInboxItem,
+) -> KnowledgeStaleOwnerReviewCompletionQueueItem:
+ outcome = _completion_outcome_for_owner_review_item(item)
+ blockers = _completion_queue_blockers(item, outcome=outcome)
+ readiness = _completion_queue_readiness(item, blockers=blockers)
+ can_preview = readiness == "ready"
+ return KnowledgeStaleOwnerReviewCompletionQueueItem(
+ dispatch_id=item.dispatch_id,
+ governance_event_id=item.governance_event_id,
+ entry_id=item.entry_id,
+ project_id=item.project_id,
+ title=item.title,
+ dispatch_status=item.dispatch_status,
+ workflow_stage=item.workflow_stage,
+ readiness=readiness,
+ recommended_completion_outcome=outcome,
+ next_action=_completion_queue_next_action(readiness),
+ blockers=blockers,
+ required_owner_fields=_completion_required_owner_fields(outcome),
+ can_preview=can_preview,
+ can_confirm_after_preview=can_preview,
+ writes_km_on_confirm=can_preview,
+ owner=item.owner,
+ owner_note=item.owner_note,
+ batch_governance_event_id=item.batch_governance_event_id,
+ batch_dispatch_id=item.batch_dispatch_id,
+ priority_tier=item.priority_tier,
+ priority_score=item.priority_score,
+ recommended_action=item.recommended_action,
+ stale_days=item.stale_days,
+ view_count=item.view_count,
+ correlation_sources=item.correlation_sources,
+ reasons=item.reasons,
+ related_incident_id=item.related_incident_id,
+ related_playbook_id=item.related_playbook_id,
+ related_approval_id=item.related_approval_id,
+ dry_run_plan_fingerprint=item.dry_run_plan_fingerprint,
+ queued_at=item.queued_at,
+ started_at=item.started_at,
+ completed_at=item.completed_at,
+ )
+
+
+def _completion_queue_bucket_matches(
+ item: KnowledgeStaleOwnerReviewCompletionQueueItem,
+ *,
+ status_bucket: str,
+) -> bool:
+ if status_bucket == "all":
+ return True
+ if status_bucket == "pending":
+ return item.dispatch_status in _ACTIVE_DISPATCH_STATUSES
+ return item.readiness == status_bucket
+
+
+def _completion_outcome_for_owner_review_item(
+ item: KnowledgeStaleOwnerReviewInboxItem,
+) -> Literal["refresh_with_evidence", "archive", "supersede"]:
+ if item.recommended_action == "archive_or_supersede":
+ return "archive"
+ return "refresh_with_evidence"
+
+
+def _completion_required_owner_fields(
+ outcome: Literal["refresh_with_evidence", "archive", "supersede"],
+) -> list[str]:
+ if outcome == "refresh_with_evidence":
+ return ["owner_note_or_updated_content"]
+ if outcome == "supersede":
+ return ["superseded_by_entry_id", "owner_note"]
+ return ["owner_note"]
+
+
+def _completion_queue_blockers(
+ item: KnowledgeStaleOwnerReviewInboxItem,
+ *,
+ outcome: Literal["refresh_with_evidence", "archive", "supersede"],
+) -> list[str]:
+ blockers: list[str] = []
+ if not item.dispatch_id:
+ blockers.append("missing_dispatch_id")
+ if not item.entry_id:
+ blockers.append("missing_entry_id")
+ if item.dispatch_status == "skipped":
+ blockers.append("owner_review_skipped")
+ if item.dispatch_status not in (
+ _ACTIVE_DISPATCH_STATUSES
+ | _TERMINAL_FAILED_DISPATCH_STATUSES
+ | frozenset({"succeeded", "skipped"})
+ ):
+ blockers.append("unsupported_dispatch_status")
+ if outcome == "supersede":
+ blockers.append("superseded_by_entry_id_required")
+ return blockers
+
+
+def _completion_queue_readiness(
+ item: KnowledgeStaleOwnerReviewInboxItem,
+ *,
+ blockers: list[str],
+) -> Literal["ready", "blocked", "completed", "failed"]:
+ if item.dispatch_status == "succeeded":
+ return "completed"
+ if item.dispatch_status in _TERMINAL_FAILED_DISPATCH_STATUSES:
+ return "failed"
+ if item.dispatch_status in _ACTIVE_DISPATCH_STATUSES and not blockers:
+ return "ready"
+ return "blocked"
+
+
+def _completion_queue_next_action(
+ readiness: Literal["ready", "blocked", "completed", "failed"],
+) -> str:
+ return {
+ "ready": "preview_stale_km_review_completion",
+ "blocked": "inspect_owner_review_blocker",
+ "completed": "view_stale_km_completion_audit",
+ "failed": "manual_km_owner_review_triage",
+ }[readiness]
+
+
async def _load_owner_review_burndown_counts(
db: AsyncSession,
*,
diff --git a/apps/api/tests/test_ai_governance_endpoints.py b/apps/api/tests/test_ai_governance_endpoints.py
index 688489b2f..3255266bd 100644
--- a/apps/api/tests/test_ai_governance_endpoints.py
+++ b/apps/api/tests/test_ai_governance_endpoints.py
@@ -45,6 +45,8 @@ from src.models.governance import (
KnowledgeStaleOwnerReviewBurnDownResponse,
KnowledgeStaleOwnerReviewCompleteRequest,
KnowledgeStaleOwnerReviewCompleteResponse,
+ KnowledgeStaleOwnerReviewCompletionQueueItem,
+ KnowledgeStaleOwnerReviewCompletionQueueResponse,
KnowledgeStaleOwnerReviewInboxItem,
KnowledgeStaleOwnerReviewInboxResponse,
KnowledgeStaleOwnerReviewRequest,
@@ -64,6 +66,7 @@ from src.services.governance_km_stale_review_service import (
_build_batch_owner_review_decision_context,
_build_batch_queue_plan_fingerprint,
_build_completion_plan_fingerprint,
+ _build_completion_queue_item,
_build_owner_review_burndown_items,
_build_owner_review_completion_audit_context,
_build_owner_review_inbox_item,
@@ -1144,6 +1147,160 @@ class TestKmReviewDraftDedupe:
assert items[1].stale_count_delta is None
assert _entries_to_stale_threshold(items[0].stale_ratio_snapshot) == 78
+ def test_owner_review_completion_queue_endpoint_returns_readiness_split(self, client):
+ """Completion queue 應把 ready / blocked / completed / failed 分流給前端。"""
+ fake = KnowledgeStaleOwnerReviewCompletionQueueResponse(
+ project_id="awoooi",
+ status_bucket="all",
+ total=2,
+ returned=2,
+ pending_count=1,
+ ready_count=1,
+ blocked_count=0,
+ completed_count=1,
+ failed_count=0,
+ items=[
+ KnowledgeStaleOwnerReviewCompletionQueueItem(
+ dispatch_id="dispatch-ready-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",
+ readiness="ready",
+ recommended_completion_outcome="refresh_with_evidence",
+ next_action="preview_stale_km_review_completion",
+ required_owner_fields=["owner_note_or_updated_content"],
+ can_preview=True,
+ can_confirm_after_preview=True,
+ writes_km_on_confirm=True,
+ 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",
+ queued_at=NOW,
+ ),
+ KnowledgeStaleOwnerReviewCompletionQueueItem(
+ dispatch_id="dispatch-done-001",
+ governance_event_id="event-001",
+ entry_id="km-002",
+ project_id="awoooi",
+ title="Auto repair canary runbook",
+ dispatch_status="succeeded",
+ workflow_stage="km_candidate_reviewed",
+ readiness="completed",
+ recommended_completion_outcome="archive",
+ next_action="view_stale_km_completion_audit",
+ required_owner_fields=["owner_note"],
+ can_preview=False,
+ can_confirm_after_preview=False,
+ writes_km_on_confirm=False,
+ priority_tier="P1",
+ priority_score=120,
+ recommended_action="archive_or_supersede",
+ stale_days=18,
+ view_count=2,
+ correlation_sources=["playbook"],
+ reasons=["high_stale_days"],
+ completed_at=NOW,
+ ),
+ ],
+ generated_at=NOW,
+ )
+ captured: dict = {}
+
+ async def mock_completion_queue(**kwargs):
+ captured.update(kwargs)
+ return fake
+
+ with patch(
+ "src.api.v1.ai_governance.query_km_stale_owner_review_completion_queue",
+ new=mock_completion_queue,
+ ):
+ r = client.get(
+ "/api/v1/ai/governance/km-stale-owner-review-completion-queue"
+ "?project_id=awoooi&status_bucket=all&limit=12"
+ )
+
+ assert r.status_code == 200
+ assert captured == {"project_id": "awoooi", "status_bucket": "all", "limit": 12}
+ data = r.json()
+ assert data["schema_version"] == "km_stale_owner_review_completion_queue_v1"
+ assert data["writes_on_read"] is False
+ assert data["manual_review_required"] is True
+ assert data["batch_writes_allowed"] is False
+ assert data["pending_count"] == 1
+ assert data["completed_count"] == 1
+ assert data["items"][0]["readiness"] == "ready"
+ assert data["items"][0]["can_preview"] is True
+ assert data["items"][1]["writes_km_on_confirm"] is False
+
+ def test_completion_queue_item_marks_active_review_ready(self):
+ inbox_item = KnowledgeStaleOwnerReviewInboxItem(
+ dispatch_id="dispatch-ready-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",
+ 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"],
+ related_incident_id="INC-20260513-79ED5E",
+ queued_at=NOW,
+ )
+
+ item = _build_completion_queue_item(inbox_item)
+
+ assert item.readiness == "ready"
+ assert item.next_action == "preview_stale_km_review_completion"
+ assert item.recommended_completion_outcome == "refresh_with_evidence"
+ assert item.required_owner_fields == ["owner_note_or_updated_content"]
+ assert item.can_preview is True
+ assert item.can_confirm_after_preview is True
+ assert item.writes_km_on_confirm is True
+
+ def test_completion_queue_item_marks_terminal_states_non_writable(self):
+ inbox_item = KnowledgeStaleOwnerReviewInboxItem(
+ dispatch_id="dispatch-completed-001",
+ governance_event_id="event-001",
+ entry_id="km-001",
+ project_id="awoooi",
+ title="Old auto repair note",
+ dispatch_status="succeeded",
+ workflow_stage="km_candidate_reviewed",
+ priority_tier="P1",
+ priority_score=120,
+ recommended_action="archive_or_supersede",
+ stale_days=20,
+ view_count=2,
+ correlation_sources=["playbook"],
+ reasons=["high_stale_days"],
+ completed_at=NOW,
+ )
+
+ item = _build_completion_queue_item(inbox_item)
+
+ assert item.readiness == "completed"
+ assert item.next_action == "view_stale_km_completion_audit"
+ assert item.recommended_completion_outcome == "archive"
+ assert item.can_preview is False
+ assert item.can_confirm_after_preview is False
+ assert item.writes_km_on_confirm is False
+
def test_stale_owner_review_batch_context_is_operator_visible(self):
request = KnowledgeStaleOwnerReviewBatchQueueRequest(
project_id="awoooi",
diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json
index 9876ff02f..550caee6a 100644
--- a/apps/web/messages/en.json
+++ b/apps/web/messages/en.json
@@ -2134,6 +2134,28 @@
"itemState": "stage: {stage}; outcome: {outcome}",
"itemRefs": "Source: {source}; Recheck: {recheck}"
},
+ "completionQueue": {
+ "title": "Completion queue",
+ "subtitle": "Splits owner review into ready, blocked, completed, and failed work so the next step is visible.",
+ "ready": "Ready {count}",
+ "blocked": "Blocked {count}",
+ "completed": "Completed {count}",
+ "failed": "Failed {count}",
+ "pending": "Pending dispatch {count}",
+ "guardrail": "writes on read={writes}; manual review={review}; batch writes={batch}",
+ "unavailable": "The completion queue API has not responded; use the owner-review inbox for single-item confirmation.",
+ "empty": "No owner-review completion work items.",
+ "state": "Split: {readiness}; stage: {stage}",
+ "next": "Next: {action}; outcome: {outcome}",
+ "required": "Required fields: {fields}",
+ "blockers": "Blockers: {blockers}",
+ "readiness": {
+ "ready": "Ready to dry-run",
+ "blocked": "Needs manual unblock",
+ "completed": "Completed",
+ "failed": "Failed"
+ }
+ },
"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.",
diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json
index 8302f29ac..dbfca39ea 100644
--- a/apps/web/messages/zh-TW.json
+++ b/apps/web/messages/zh-TW.json
@@ -2135,6 +2135,28 @@
"itemState": "階段:{stage};結果:{outcome}",
"itemRefs": "Source:{source};Recheck:{recheck}"
},
+ "completionQueue": {
+ "title": "Completion 分流佇列",
+ "subtitle": "把 owner review 拆成可乾跑、卡住、已完成、失敗,避免只看到告警卻不知道下一步。",
+ "ready": "可處理 {count}",
+ "blocked": "卡住 {count}",
+ "completed": "完成 {count}",
+ "failed": "失敗 {count}",
+ "pending": "待處理 dispatch {count}",
+ "guardrail": "讀取不寫入={writes};人工覆核={review};批次寫入={batch}",
+ "unavailable": "completion queue API 尚未回應;目前只能從 owner review 工作台逐筆確認。",
+ "empty": "目前沒有 owner-review completion 工作項。",
+ "state": "分流:{readiness};階段:{stage}",
+ "next": "下一步:{action};結果:{outcome}",
+ "required": "需要欄位:{fields}",
+ "blockers": "卡點:{blockers}",
+ "readiness": {
+ "ready": "可乾跑",
+ "blocked": "需人工排除",
+ "completed": "已完成",
+ "failed": "失敗待處理"
+ }
+ },
"batchActions": {
"title": "批次處理 P0 / P1 陳舊 KM",
"subtitle": "先乾跑鎖定最新 P0 / P1 候選,再批次建立 owner-review dispatch;不會直接寫入 KM。",
diff --git a/apps/web/src/app/[locale]/awooop/work-items/page.tsx b/apps/web/src/app/[locale]/awooop/work-items/page.tsx
index cd96a0824..67ff89df1 100644
--- a/apps/web/src/app/[locale]/awooop/work-items/page.tsx
+++ b/apps/web/src/app/[locale]/awooop/work-items/page.tsx
@@ -12,6 +12,7 @@ import {
Activity,
Archive,
ArrowRight,
+ CheckCircle2,
ClipboardList,
Database,
Fingerprint,
@@ -575,6 +576,60 @@ type KnowledgeStaleOwnerReviewBurnDownResponse = {
generated_at?: string | null;
};
+type KnowledgeStaleOwnerReviewCompletionQueueItem = {
+ dispatch_id: string;
+ governance_event_id: string;
+ entry_id: string;
+ project_id: string;
+ title: string;
+ dispatch_status: string;
+ workflow_stage: string;
+ readiness: "ready" | "blocked" | "completed" | "failed";
+ recommended_completion_outcome: "refresh_with_evidence" | "archive" | "supersede";
+ next_action: string;
+ blockers: string[];
+ required_owner_fields: string[];
+ can_preview: boolean;
+ can_confirm_after_preview: boolean;
+ writes_km_on_confirm: boolean;
+ 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 KnowledgeStaleOwnerReviewCompletionQueueResponse = {
+ schema_version?: string;
+ project_id: string;
+ status_bucket: "all" | "ready" | "blocked" | "completed" | "failed" | "pending";
+ total: number;
+ returned: number;
+ pending_count: number;
+ ready_count: number;
+ blocked_count: number;
+ completed_count: number;
+ failed_count: number;
+ writes_on_read: boolean;
+ manual_review_required: boolean;
+ batch_writes_allowed: boolean;
+ items: KnowledgeStaleOwnerReviewCompletionQueueItem[];
+ generated_at?: string | null;
+};
+
type KnowledgeStaleOwnerReviewCompleteResponse = {
schema_version?: string;
entry_id: string;
@@ -690,6 +745,7 @@ type Telemetry = {
knowledgeStaleCandidates: KnowledgeStaleCandidatesResponse | null;
knowledgeStaleOwnerReviews: KnowledgeStaleOwnerReviewInboxResponse | null;
knowledgeStaleOwnerReviewBurnDown: KnowledgeStaleOwnerReviewBurnDownResponse | null;
+ knowledgeStaleOwnerReviewCompletionQueue: KnowledgeStaleOwnerReviewCompletionQueueResponse | null;
channelEvents: RecentEventsResponse | null;
eventRecurrence: RecurrenceResponse | null;
slo: SloResponse | null;
@@ -1324,6 +1380,18 @@ function kmStaleBatchItemStatusKey(value: string | null | undefined) {
}
}
+function kmStaleCompletionReadinessKey(value: string | null | undefined) {
+ switch (value) {
+ case "ready":
+ case "blocked":
+ case "completed":
+ case "failed":
+ return value;
+ default:
+ return "blocked";
+ }
+}
+
function kmStaleReviewCompleteStatusKey(value: string | null | undefined) {
switch (value) {
case "dry_run":
@@ -1343,7 +1411,7 @@ function kmStaleReviewOutcomeForCandidate(
}
function staleCandidateFromOwnerReviewItem(
- item: KnowledgeStaleOwnerReviewInboxItem
+ item: KnowledgeStaleOwnerReviewInboxItem | KnowledgeStaleOwnerReviewCompletionQueueItem
): KnowledgeStaleCandidate {
return {
entry_id: item.entry_id,
@@ -2360,6 +2428,7 @@ function KnowledgeGovernancePanel({
staleCandidates,
ownerReviewInbox,
burnDown,
+ completionQueue,
onArchived,
}: {
queue: GovernanceQueueResponse | null;
@@ -2368,6 +2437,7 @@ function KnowledgeGovernancePanel({
staleCandidates: KnowledgeStaleCandidatesResponse | null;
ownerReviewInbox: KnowledgeStaleOwnerReviewInboxResponse | null;
burnDown: KnowledgeStaleOwnerReviewBurnDownResponse | null;
+ completionQueue: KnowledgeStaleOwnerReviewCompletionQueueResponse | null;
onArchived: () => void;
}) {
const t = useTranslations("awooop.workItems.knowledgeGovernance");
@@ -2393,6 +2463,7 @@ function KnowledgeGovernancePanel({
const ownerReviewItems = ownerReviewInbox?.items ?? [];
const burnDownItems = burnDown?.items ?? [];
const burnDownSnapshot = burnDown?.current_snapshot ?? null;
+ const completionQueueItems = completionQueue?.items ?? [];
const draftTotal = dedupe?.total_review_drafts ?? reviewDrafts?.total ?? 0;
const activeCount = items.filter((item) =>
["pending", "dispatched", "executing"].includes(item.dispatch_status)
@@ -3109,6 +3180,185 @@ function KnowledgeGovernancePanel({
>
)}
+
+
+
+
+
+
+ {t("staleCandidates.completionQueue.title")}
+
+
+ {t("staleCandidates.completionQueue.subtitle")}
+
+
+
+
+
+ {t("staleCandidates.completionQueue.ready", {
+ count: completionQueue?.ready_count ?? 0,
+ })}
+
+
+ {t("staleCandidates.completionQueue.blocked", {
+ count: completionQueue?.blocked_count ?? 0,
+ })}
+
+
+ {t("staleCandidates.completionQueue.completed", {
+ count: completionQueue?.completed_count ?? 0,
+ })}
+
+
+ {t("staleCandidates.completionQueue.failed", {
+ count: completionQueue?.failed_count ?? 0,
+ })}
+
+
+
+ {completionQueue === null ? (
+
+ {t("staleCandidates.completionQueue.unavailable")}
+
+ ) : completionQueueItems.length === 0 ? (
+
+ {t("staleCandidates.completionQueue.empty")}
+
+ ) : (
+ <>
+
+
+ {t("staleCandidates.completionQueue.pending", {
+ count: completionQueue.pending_count,
+ })}
+
+
+ {t("staleCandidates.completionQueue.guardrail", {
+ writes: String(completionQueue.writes_on_read),
+ review: String(completionQueue.manual_review_required),
+ batch: String(completionQueue.batch_writes_allowed),
+ })}
+
+
+
+ {completionQueueItems.slice(0, 6).map((item) => {
+ const completionCandidate = staleCandidateFromOwnerReviewItem(item);
+ const completionAction = staleReviewCompletionActions[item.entry_id];
+ const completionPreview = completionAction?.previewResult ?? null;
+ const completionResult = completionAction?.result ?? null;
+ const readinessKey = kmStaleCompletionReadinessKey(item.readiness);
+ return (
+
+
+
+
+ {item.title}
+
+
+ {item.entry_id}
+
+
+
+ {item.priority_tier}
+
+
+
+ {t("staleCandidates.completionQueue.state", {
+ readiness: t(`staleCandidates.completionQueue.readiness.${readinessKey}` as never),
+ stage: t(`stages.${governanceKmStageKey(item.workflow_stage)}` as never),
+ })}
+
+
+ {t("staleCandidates.completionQueue.next", {
+ action: item.next_action,
+ outcome: t(`staleCandidates.completeActions.outcomes.${item.recommended_completion_outcome}` as never),
+ })}
+
+
+ {t("staleCandidates.ownerReviewInbox.meta", {
+ days: item.stale_days,
+ score: item.priority_score,
+ views: item.view_count,
+ })}
+
+
+ {t("staleCandidates.ownerReviewInbox.batch", {
+ batch: item.batch_dispatch_id ?? "--",
+ })}
+
+
+ {t("staleCandidates.completionQueue.required", {
+ fields: item.required_owner_fields.length > 0
+ ? item.required_owner_fields.join(", ")
+ : "--",
+ })}
+
+ {item.blockers.length > 0 ? (
+
+ {t("staleCandidates.completionQueue.blockers", {
+ blockers: item.blockers.join(", "),
+ })}
+
+ ) : null}
+ {item.can_preview ? (
+
+
+
+
+ ) : null}
+ {completionAction?.error ? (
+
+ {completionAction.error}
+
+ ) : null}
+ {completionPreview ? (
+
+ {t("staleCandidates.completeActions.planFingerprint", {
+ fingerprint: completionPreview.dry_run_plan_fingerprint ?? "--",
+ })}
+
+ ) : null}
+ {completionResult ? (
+
+ {t("staleCandidates.completeActions.result", {
+ audit: completionResult.audit_dispatch_id ?? "--",
+ recheck: completionResult.stale_ratio_recheck_dispatch_id ?? "--",
+ })}
+
+ ) : null}
+
+ );
+ })}
+
+ >
+ )}
+
@@ -4008,6 +4258,7 @@ export default function AwoooPWorkItemsPage() {
knowledgeStaleCandidates: null,
knowledgeStaleOwnerReviews: null,
knowledgeStaleOwnerReviewBurnDown: null,
+ knowledgeStaleOwnerReviewCompletionQueue: null,
channelEvents: null,
eventRecurrence: null,
slo: null,
@@ -4030,6 +4281,7 @@ export default function AwoooPWorkItemsPage() {
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 knowledgeStaleOwnerReviewBurnDownUrl = `${API_BASE}/api/v1/ai/governance/km-stale-owner-review-burndown?project_id=${encodedProjectId}&limit=20`;
+ const knowledgeStaleOwnerReviewCompletionQueueUrl = `${API_BASE}/api/v1/ai/governance/km-stale-owner-review-completion-queue?project_id=${encodedProjectId}&status_bucket=all&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`;
@@ -4046,6 +4298,7 @@ export default function AwoooPWorkItemsPage() {
knowledgeStaleCandidates,
knowledgeStaleOwnerReviews,
knowledgeStaleOwnerReviewBurnDown,
+ knowledgeStaleOwnerReviewCompletionQueue,
channelEvents,
eventRecurrence,
slo,
@@ -4061,6 +4314,7 @@ export default function AwoooPWorkItemsPage() {
fetchJson(knowledgeStaleCandidatesUrl),
fetchJson(knowledgeStaleOwnerReviewsUrl),
fetchJson(knowledgeStaleOwnerReviewBurnDownUrl),
+ fetchJson(knowledgeStaleOwnerReviewCompletionQueueUrl),
fetchJson(channelEventsUrl),
fetchJson(recurrenceUrl),
fetchJson(sloUrl),
@@ -4093,6 +4347,7 @@ export default function AwoooPWorkItemsPage() {
knowledgeStaleCandidates,
knowledgeStaleOwnerReviews,
knowledgeStaleOwnerReviewBurnDown,
+ knowledgeStaleOwnerReviewCompletionQueue,
channelEvents,
eventRecurrence,
slo,
@@ -4222,6 +4477,7 @@ export default function AwoooPWorkItemsPage() {
staleCandidates={telemetry.knowledgeStaleCandidates}
ownerReviewInbox={telemetry.knowledgeStaleOwnerReviews}
burnDown={telemetry.knowledgeStaleOwnerReviewBurnDown}
+ completionQueue={telemetry.knowledgeStaleOwnerReviewCompletionQueue}
onArchived={fetchTelemetry}
/>