diff --git a/apps/api/src/api/v1/ai_governance.py b/apps/api/src/api/v1/ai_governance.py
index 4aa86674..d2e6a48c 100644
--- a/apps/api/src/api/v1/ai_governance.py
+++ b/apps/api/src/api/v1/ai_governance.py
@@ -34,6 +34,7 @@ from src.models.governance import (
KnowledgeStaleCandidatesResponse,
KnowledgeStaleOwnerReviewBatchQueueRequest,
KnowledgeStaleOwnerReviewBatchQueueResponse,
+ KnowledgeStaleOwnerReviewBurnDownResponse,
KnowledgeStaleOwnerReviewCompleteRequest,
KnowledgeStaleOwnerReviewCompleteResponse,
KnowledgeStaleOwnerReviewInboxResponse,
@@ -48,6 +49,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_burndown,
query_km_stale_owner_review_inbox,
queue_km_stale_owner_review,
)
@@ -273,6 +275,35 @@ async def get_km_stale_owner_reviews(
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
+# =============================================================================
+# GET /api/v1/ai/governance/km-stale-owner-review-burndown
+# =============================================================================
+
+@router.get(
+ "/ai/governance/km-stale-owner-review-burndown",
+ response_model=KnowledgeStaleOwnerReviewBurnDownResponse,
+)
+async def get_km_stale_owner_review_burndown(
+ project_id: Annotated[str, Query(min_length=1, max_length=64)] = "awoooi",
+ limit: Annotated[int, Query(ge=1, le=100)] = 20,
+) -> KnowledgeStaleOwnerReviewBurnDownResponse:
+ """
+ 查詢 stale KM owner-review 完成與 stale ratio burn-down 狀態。
+
+ 這是 read-only dashboard:把 pending review、completion audit、recheck
+ snapshot 與距離治理門檻的剩餘筆數放在同一個前端面板。
+ """
+ logger.debug(
+ "km_stale_owner_review_burndown_request",
+ project_id=project_id,
+ limit=limit,
+ )
+ return await query_km_stale_owner_review_burndown(
+ project_id=project_id,
+ 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 4c251549..42176dd4 100644
--- a/apps/api/src/models/governance.py
+++ b/apps/api/src/models/governance.py
@@ -376,6 +376,47 @@ class KnowledgeStaleOwnerReviewInboxResponse(BaseModel):
generated_at: datetime
+class KnowledgeStaleOwnerReviewBurnDownItem(BaseModel):
+ completion_dispatch_id: str
+ governance_event_id: str
+ source_dispatch_id: str | None = None
+ recheck_dispatch_id: str | None = None
+ entry_id: str | None = None
+ project_id: str
+ dispatch_status: str
+ workflow_stage: str
+ review_outcome: Literal[
+ "refresh_with_evidence",
+ "archive",
+ "supersede",
+ ] | None = None
+ owner: str | None = None
+ completed_at: datetime | None = None
+ stale_ratio_snapshot: KnowledgeReviewDraftStaleRatioSnapshot | None = None
+ stale_count_delta: int | None = None
+ stale_ratio_delta: float | None = None
+ above_threshold: bool | None = None
+
+
+class KnowledgeStaleOwnerReviewBurnDownResponse(BaseModel):
+ schema_version: str = "km_stale_owner_review_burndown_v1"
+ project_id: str
+ burn_down_status: Literal["above_threshold", "at_or_below_threshold", "no_data"]
+ current_snapshot: KnowledgeReviewDraftStaleRatioSnapshot | None = None
+ entries_to_threshold: int
+ pending_owner_reviews: int
+ completed_owner_reviews: int
+ completion_audit_total: int
+ stale_ratio_recheck_total: int
+ latest_stale_count_delta: int | None = None
+ latest_stale_ratio_delta: float | None = None
+ writes_on_read: bool = False
+ manual_review_required: bool = True
+ returned: int
+ items: list[KnowledgeStaleOwnerReviewBurnDownItem] = 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 70708d7d..4ab6f15c 100644
--- a/apps/api/src/services/governance_km_stale_review_service.py
+++ b/apps/api/src/services/governance_km_stale_review_service.py
@@ -33,6 +33,8 @@ from src.models.governance import (
KnowledgeStaleOwnerReviewBatchItem,
KnowledgeStaleOwnerReviewBatchQueueRequest,
KnowledgeStaleOwnerReviewBatchQueueResponse,
+ KnowledgeStaleOwnerReviewBurnDownItem,
+ KnowledgeStaleOwnerReviewBurnDownResponse,
KnowledgeStaleOwnerReviewCompleteRequest,
KnowledgeStaleOwnerReviewCompleteResponse,
KnowledgeStaleOwnerReviewInboxItem,
@@ -184,6 +186,41 @@ async def query_km_stale_owner_review_inbox(
)
+async def query_km_stale_owner_review_burndown(
+ *,
+ project_id: str = "awoooi",
+ limit: int = 20,
+) -> KnowledgeStaleOwnerReviewBurnDownResponse:
+ """Read stale KM owner-review completion progress and current burn-down status."""
+ generated_at = now_taipei()
+ async with get_db_context() as db:
+ current_snapshot = await _compute_km_stale_ratio_snapshot(db)
+ counts = await _load_owner_review_burndown_counts(db, project_id=project_id)
+ completion_rows = await _load_owner_review_completion_rows(
+ db,
+ project_id=project_id,
+ limit=limit,
+ )
+
+ items = _build_owner_review_burndown_items(completion_rows, project_id=project_id)
+ latest = items[0] if items else None
+ return KnowledgeStaleOwnerReviewBurnDownResponse(
+ project_id=project_id,
+ burn_down_status=_stale_burndown_status(current_snapshot),
+ current_snapshot=current_snapshot,
+ entries_to_threshold=_entries_to_stale_threshold(current_snapshot),
+ pending_owner_reviews=counts["pending_owner_reviews"],
+ completed_owner_reviews=counts["completed_owner_reviews"],
+ completion_audit_total=counts["completion_audit_total"],
+ stale_ratio_recheck_total=counts["stale_ratio_recheck_total"],
+ latest_stale_count_delta=latest.stale_count_delta if latest else None,
+ latest_stale_ratio_delta=latest.stale_ratio_delta if latest else None,
+ returned=len(items),
+ items=items,
+ generated_at=generated_at,
+ )
+
+
async def queue_km_stale_owner_review(
*,
entry_id: str,
@@ -813,6 +850,188 @@ def _build_owner_review_inbox_item(
)
+async def _load_owner_review_burndown_counts(
+ db: AsyncSession,
+ *,
+ project_id: str,
+) -> dict[str, int]:
+ sql = text("""
+ SELECT
+ COUNT(*) FILTER (
+ WHERE d.executor_type = :owner_executor
+ AND d.dispatch_status::text IN ('pending', 'dispatched', 'executing')
+ ) AS pending_owner_reviews,
+ COUNT(*) FILTER (
+ WHERE d.executor_type = :owner_executor
+ AND d.dispatch_status::text = 'succeeded'
+ ) AS completed_owner_reviews,
+ COUNT(*) FILTER (
+ WHERE d.executor_type = :complete_executor
+ AND d.dispatch_status::text = 'succeeded'
+ ) AS completion_audit_total,
+ COUNT(*) FILTER (
+ WHERE d.executor_type = :complete_executor
+ AND d.dispatch_status::text = 'succeeded'
+ AND d.decision_context -> 'stale_ratio_recheck' ->> 'dispatch_id' IS NOT NULL
+ ) AS stale_ratio_recheck_total
+ FROM governance_remediation_dispatch d
+ WHERE d.event_type = 'knowledge_degradation'
+ AND d.executor_type IN (:owner_executor, :complete_executor)
+ AND COALESCE(
+ d.decision_context -> 'workflow' ->> 'project_id',
+ d.decision_context ->> 'project_id',
+ d.decision_context -> 'candidate' ->> 'project_id'
+ ) = :project_id
+ """)
+ result = await db.execute(
+ sql,
+ {
+ "owner_executor": _EXECUTOR_TYPE,
+ "complete_executor": _COMPLETE_EXECUTOR_TYPE,
+ "project_id": project_id,
+ },
+ )
+ row = result.first()
+ return {
+ "pending_owner_reviews": int(row.pending_owner_reviews or 0) if row else 0,
+ "completed_owner_reviews": int(row.completed_owner_reviews or 0) if row else 0,
+ "completion_audit_total": int(row.completion_audit_total or 0) if row else 0,
+ "stale_ratio_recheck_total": int(row.stale_ratio_recheck_total or 0) if row else 0,
+ }
+
+
+async def _load_owner_review_completion_rows(
+ db: AsyncSession,
+ *,
+ project_id: str,
+ limit: int,
+) -> list[Any]:
+ sql = text("""
+ SELECT
+ d.id,
+ d.governance_event_id,
+ d.dispatch_status,
+ d.decision_context,
+ d.dispatched_at,
+ d.started_at,
+ d.completed_at
+ FROM governance_remediation_dispatch d
+ WHERE d.executor_type = :complete_executor
+ AND d.dispatch_status::text = 'succeeded'
+ AND COALESCE(
+ d.decision_context -> 'workflow' ->> 'project_id',
+ d.decision_context ->> 'project_id'
+ ) = :project_id
+ ORDER BY d.completed_at DESC NULLS LAST, d.dispatched_at DESC
+ LIMIT :limit
+ """)
+ result = await db.execute(
+ sql,
+ {
+ "complete_executor": _COMPLETE_EXECUTOR_TYPE,
+ "project_id": project_id,
+ "limit": limit,
+ },
+ )
+ return list(result.fetchall())
+
+
+def _build_owner_review_burndown_items(
+ rows: list[Any],
+ *,
+ project_id: str,
+) -> list[KnowledgeStaleOwnerReviewBurnDownItem]:
+ chronological: list[KnowledgeStaleOwnerReviewBurnDownItem] = []
+ previous_snapshot: KnowledgeReviewDraftStaleRatioSnapshot | None = None
+ for row in reversed(rows):
+ context = row.decision_context if isinstance(row.decision_context, dict) else {}
+ snapshot = _snapshot_from_context(context)
+ stale_count_delta: int | None = None
+ stale_ratio_delta: float | None = None
+ if snapshot is not None and previous_snapshot is not None:
+ stale_count_delta = snapshot.stale_count - previous_snapshot.stale_count
+ stale_ratio_delta = round(snapshot.stale_ratio - previous_snapshot.stale_ratio, 3)
+ if snapshot is not None:
+ previous_snapshot = snapshot
+
+ chronological.append(
+ _build_owner_review_burndown_item(
+ row=row,
+ context=context,
+ project_id=project_id,
+ stale_count_delta=stale_count_delta,
+ stale_ratio_delta=stale_ratio_delta,
+ )
+ )
+ return list(reversed(chronological))
+
+
+def _build_owner_review_burndown_item(
+ *,
+ row: Any,
+ context: dict[str, Any],
+ project_id: str,
+ stale_count_delta: int | None,
+ stale_ratio_delta: float | None,
+) -> KnowledgeStaleOwnerReviewBurnDownItem:
+ workflow = context.get("workflow") if isinstance(context.get("workflow"), dict) else {}
+ worker_result = context.get("worker_result") if isinstance(context.get("worker_result"), dict) else {}
+ snapshot = _snapshot_from_context(context)
+ dispatch_status = str(row.dispatch_status)
+ return KnowledgeStaleOwnerReviewBurnDownItem(
+ completion_dispatch_id=str(row.id),
+ governance_event_id=str(row.governance_event_id),
+ source_dispatch_id=_first_non_empty_string(
+ workflow.get("source_dispatch_id"),
+ context.get("source_dispatch_id"),
+ ),
+ recheck_dispatch_id=_extract_recheck_dispatch_id(context),
+ entry_id=_first_non_empty_string(
+ workflow.get("entry_id"),
+ context.get("entry_id"),
+ worker_result.get("entry_id"),
+ ),
+ project_id=_first_non_empty_string(
+ workflow.get("project_id"),
+ context.get("project_id"),
+ ) or project_id,
+ dispatch_status=dispatch_status,
+ workflow_stage=_extract_complete_workflow_stage(context, dispatch_status),
+ review_outcome=_normalize_optional_review_outcome(
+ workflow.get("review_outcome") or context.get("review_outcome") or worker_result.get("review_outcome")
+ ),
+ owner=_first_non_empty_string(context.get("owner")),
+ completed_at=row.completed_at,
+ stale_ratio_snapshot=snapshot,
+ stale_count_delta=stale_count_delta,
+ stale_ratio_delta=stale_ratio_delta,
+ above_threshold=(
+ snapshot.stale_ratio > snapshot.threshold
+ if snapshot is not None
+ else None
+ ),
+ )
+
+
+def _stale_burndown_status(
+ snapshot: KnowledgeReviewDraftStaleRatioSnapshot | None,
+) -> Literal["above_threshold", "at_or_below_threshold", "no_data"]:
+ if snapshot is None or snapshot.total_count <= 0:
+ return "no_data"
+ if snapshot.stale_ratio > snapshot.threshold:
+ return "above_threshold"
+ return "at_or_below_threshold"
+
+
+def _entries_to_stale_threshold(
+ snapshot: KnowledgeReviewDraftStaleRatioSnapshot | None,
+) -> int:
+ if snapshot is None or snapshot.total_count <= 0:
+ return 0
+ allowed_stale = int(snapshot.total_count * snapshot.threshold)
+ return max(0, snapshot.stale_count - allowed_stale)
+
+
def _priority_rank(priority_tier: str) -> int:
return {"P0": 3, "P1": 2, "P2": 1}.get(priority_tier, 0)
@@ -1246,6 +1465,7 @@ async def _complete_owner_review_and_write_audit(
request=request,
stale_ratio_snapshot=stale_ratio_snapshot,
plan_fingerprint=plan_fingerprint,
+ project_id=str(record.project_id),
),
executor_type=_RECHECK_EXECUTOR_TYPE,
attempt_count=0,
@@ -1473,10 +1693,13 @@ def _build_stale_ratio_recheck_context(
request: KnowledgeStaleOwnerReviewCompleteRequest,
stale_ratio_snapshot: KnowledgeReviewDraftStaleRatioSnapshot,
plan_fingerprint: str,
+ project_id: str | None = None,
) -> dict[str, Any]:
+ resolved_project_id = project_id or "awoooi"
return {
"schema_version": "km_stale_owner_review_recheck_v1",
"version": "v1",
+ "project_id": resolved_project_id,
"trigger_source": "km_stale_owner_review_complete",
"triggered_metric": "knowledge_degradation",
"metric_value": stale_ratio_snapshot.stale_ratio,
@@ -1493,6 +1716,7 @@ def _build_stale_ratio_recheck_context(
"work_kind": "km_stale_ratio_recheck",
"current_stage": "stale_ratio_recheck",
"entry_id": entry_id,
+ "project_id": resolved_project_id,
"review_outcome": request.review_outcome,
"steps": [
"detected",
@@ -1579,6 +1803,16 @@ def _normalize_review_outcome(value: Any) -> Literal[
return "refresh_with_evidence"
+def _normalize_optional_review_outcome(value: Any) -> Literal[
+ "refresh_with_evidence",
+ "archive",
+ "supersede",
+] | None:
+ if value in ("archive", "supersede", "refresh_with_evidence"):
+ return value
+ return None
+
+
def _extract_recheck_dispatch_id(context: dict[str, Any]) -> str | None:
recheck = context.get("stale_ratio_recheck")
if isinstance(recheck, dict):
diff --git a/apps/api/tests/test_ai_governance_endpoints.py b/apps/api/tests/test_ai_governance_endpoints.py
index efc76c61..688489b2 100644
--- a/apps/api/tests/test_ai_governance_endpoints.py
+++ b/apps/api/tests/test_ai_governance_endpoints.py
@@ -15,6 +15,7 @@ Unit Tests — AI Governance Endpoints (PR 1)
from __future__ import annotations
from datetime import datetime, timedelta, timezone
+from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
@@ -40,6 +41,8 @@ from src.models.governance import (
KnowledgeStaleOwnerReviewBatchItem,
KnowledgeStaleOwnerReviewBatchQueueRequest,
KnowledgeStaleOwnerReviewBatchQueueResponse,
+ KnowledgeStaleOwnerReviewBurnDownItem,
+ KnowledgeStaleOwnerReviewBurnDownResponse,
KnowledgeStaleOwnerReviewCompleteRequest,
KnowledgeStaleOwnerReviewCompleteResponse,
KnowledgeStaleOwnerReviewInboxItem,
@@ -61,10 +64,12 @@ 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_owner_review_burndown_items,
_build_owner_review_completion_audit_context,
_build_owner_review_inbox_item,
_build_stale_owner_review_decision_context,
_completion_stage_for_outcome,
+ _entries_to_stale_threshold,
)
from src.services.governance_km_stale_review_service import (
_build_stale_ratio_recheck_context as _build_stale_owner_review_recheck_context,
@@ -999,6 +1004,146 @@ class TestKmReviewDraftDedupe:
assert item.recommended_action == "refresh_with_evidence"
assert item.correlation_sources == ["incident", "playbook", "sentry", "signoz"]
+ def test_owner_review_burndown_endpoint_returns_completion_progress(self, client):
+ """Burn-down endpoint 應把 pending、completion audit 與 recheck snapshot 集中呈現。"""
+ fake = KnowledgeStaleOwnerReviewBurnDownResponse(
+ project_id="awoooi",
+ burn_down_status="above_threshold",
+ current_snapshot=KnowledgeReviewDraftStaleRatioSnapshot(
+ stale_count=118,
+ total_count=200,
+ stale_ratio=0.59,
+ threshold=0.2,
+ stale_days=7,
+ ),
+ entries_to_threshold=78,
+ pending_owner_reviews=9,
+ completed_owner_reviews=1,
+ completion_audit_total=1,
+ stale_ratio_recheck_total=1,
+ latest_stale_count_delta=-1,
+ latest_stale_ratio_delta=-0.005,
+ returned=1,
+ items=[
+ KnowledgeStaleOwnerReviewBurnDownItem(
+ completion_dispatch_id="dispatch-audit-001",
+ governance_event_id="event-001",
+ source_dispatch_id="dispatch-001",
+ recheck_dispatch_id="dispatch-recheck-001",
+ entry_id="km-001",
+ project_id="awoooi",
+ dispatch_status="succeeded",
+ workflow_stage="km_writeback_after_approval",
+ review_outcome="refresh_with_evidence",
+ owner="operator_console",
+ completed_at=NOW,
+ stale_ratio_snapshot=KnowledgeReviewDraftStaleRatioSnapshot(
+ stale_count=118,
+ total_count=200,
+ stale_ratio=0.59,
+ threshold=0.2,
+ stale_days=7,
+ ),
+ stale_count_delta=-1,
+ stale_ratio_delta=-0.005,
+ above_threshold=True,
+ )
+ ],
+ generated_at=NOW,
+ )
+ captured: dict = {}
+
+ async def mock_burndown(**kwargs):
+ captured.update(kwargs)
+ return fake
+
+ with patch(
+ "src.api.v1.ai_governance.query_km_stale_owner_review_burndown",
+ new=mock_burndown,
+ ):
+ r = client.get(
+ "/api/v1/ai/governance/km-stale-owner-review-burndown"
+ "?project_id=awoooi&limit=12"
+ )
+
+ assert r.status_code == 200
+ assert captured == {"project_id": "awoooi", "limit": 12}
+ data = r.json()
+ assert data["schema_version"] == "km_stale_owner_review_burndown_v1"
+ assert data["burn_down_status"] == "above_threshold"
+ assert data["writes_on_read"] is False
+ assert data["manual_review_required"] is True
+ assert data["pending_owner_reviews"] == 9
+ assert data["completion_audit_total"] == 1
+ assert data["stale_ratio_recheck_total"] == 1
+ assert data["latest_stale_count_delta"] == -1
+ assert data["items"][0]["recheck_dispatch_id"] == "dispatch-recheck-001"
+
+ def test_owner_review_burndown_items_compute_chronological_delta(self):
+ older_snapshot = {
+ "stale_count": 119,
+ "total_count": 200,
+ "stale_ratio": 0.595,
+ "threshold": 0.2,
+ "stale_days": 7,
+ }
+ newer_snapshot = {
+ "stale_count": 118,
+ "total_count": 200,
+ "stale_ratio": 0.59,
+ "threshold": 0.2,
+ "stale_days": 7,
+ }
+ rows = [
+ SimpleNamespace(
+ id="dispatch-audit-new",
+ governance_event_id="event-001",
+ dispatch_status="succeeded",
+ completed_at=NOW,
+ decision_context={
+ "project_id": "awoooi",
+ "owner": "operator_console",
+ "review_outcome": "refresh_with_evidence",
+ "workflow": {
+ "project_id": "awoooi",
+ "entry_id": "km-002",
+ "source_dispatch_id": "dispatch-002",
+ "current_stage": "km_writeback_after_approval",
+ "stale_ratio_snapshot": newer_snapshot,
+ },
+ "stale_ratio_recheck": {"dispatch_id": "dispatch-recheck-new"},
+ },
+ ),
+ SimpleNamespace(
+ id="dispatch-audit-old",
+ governance_event_id="event-001",
+ dispatch_status="succeeded",
+ completed_at=NOW - timedelta(minutes=10),
+ decision_context={
+ "project_id": "awoooi",
+ "owner": "operator_console",
+ "review_outcome": "refresh_with_evidence",
+ "workflow": {
+ "project_id": "awoooi",
+ "entry_id": "km-001",
+ "source_dispatch_id": "dispatch-001",
+ "current_stage": "km_writeback_after_approval",
+ "stale_ratio_snapshot": older_snapshot,
+ },
+ "stale_ratio_recheck": {"dispatch_id": "dispatch-recheck-old"},
+ },
+ ),
+ ]
+
+ items = _build_owner_review_burndown_items(rows, project_id="awoooi")
+
+ assert items[0].completion_dispatch_id == "dispatch-audit-new"
+ assert items[0].stale_count_delta == -1
+ assert items[0].stale_ratio_delta == pytest.approx(-0.005)
+ assert items[0].above_threshold is True
+ assert items[1].stale_count_delta is None
+ assert _entries_to_stale_threshold(items[0].stale_ratio_snapshot) == 78
+
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 e7888b03..9876ff02 100644
--- a/apps/web/messages/en.json
+++ b/apps/web/messages/en.json
@@ -2110,6 +2110,30 @@
"state": "Status: {status}; stage: {stage}",
"batch": "Batch: {batch}"
},
+ "burnDown": {
+ "title": "Stale ratio burn-down",
+ "subtitle": "Aligns owner review, completion audit, and recheck snapshots so the stale ratio movement is visible.",
+ "statuses": "Status: {status}",
+ "status": {
+ "above_threshold": "Above threshold",
+ "at_or_below_threshold": "At threshold",
+ "no_data": "No data"
+ },
+ "remaining": "{count} entries to threshold",
+ "unavailable": "The burn-down API has not responded; only per-item completion results are visible.",
+ "empty": "No owner-approved completion audit yet.",
+ "currentRatio": "Current stale ratio",
+ "currentCount": "Stale / total",
+ "ownerReviews": "Owner review",
+ "ownerReviewCounts": "pending {pending} / completed {completed}",
+ "latestDelta": "Latest delta",
+ "delta": "stale {stale} / ratio {ratio}",
+ "auditTotal": "Completion audit {count}",
+ "recheckTotal": "Recheck {count}",
+ "guardrail": "writes on read={writes}; manual review={review}",
+ "itemState": "stage: {stage}; outcome: {outcome}",
+ "itemRefs": "Source: {source}; Recheck: {recheck}"
+ },
"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 66217002..8302f29a 100644
--- a/apps/web/messages/zh-TW.json
+++ b/apps/web/messages/zh-TW.json
@@ -2111,6 +2111,30 @@
"state": "狀態:{status};階段:{stage}",
"batch": "Batch:{batch}"
},
+ "burnDown": {
+ "title": "Stale ratio burn-down",
+ "subtitle": "把 owner review、completion audit 與 recheck snapshot 對齊,確認陳舊比例是否真的下降。",
+ "statuses": "狀態:{status}",
+ "status": {
+ "above_threshold": "仍高於門檻",
+ "at_or_below_threshold": "已達門檻",
+ "no_data": "尚無資料"
+ },
+ "remaining": "距離門檻 {count} 筆",
+ "unavailable": "burn-down API 尚未回應;目前只能看單筆 completion 結果。",
+ "empty": "尚無 owner-approved completion audit。",
+ "currentRatio": "目前陳舊比例",
+ "currentCount": "陳舊 / 總數",
+ "ownerReviews": "Owner review",
+ "ownerReviewCounts": "待審 {pending} / 完成 {completed}",
+ "latestDelta": "最新變化",
+ "delta": "陳舊 {stale} / 比例 {ratio}",
+ "auditTotal": "Completion audit {count}",
+ "recheckTotal": "Recheck {count}",
+ "guardrail": "讀取不寫入={writes};人工覆核={review}",
+ "itemState": "階段:{stage};結果:{outcome}",
+ "itemRefs": "Source:{source};Recheck:{recheck}"
+ },
"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 8170ffb0..cd96a082 100644
--- a/apps/web/src/app/[locale]/awooop/work-items/page.tsx
+++ b/apps/web/src/app/[locale]/awooop/work-items/page.tsx
@@ -530,6 +530,51 @@ type KnowledgeStaleOwnerReviewInboxResponse = {
generated_at?: string | null;
};
+type KnowledgeStaleRatioSnapshot = {
+ stale_count: number;
+ total_count: number;
+ stale_ratio: number;
+ threshold: number;
+ stale_days: number;
+};
+
+type KnowledgeStaleOwnerReviewBurnDownItem = {
+ completion_dispatch_id: string;
+ governance_event_id: string;
+ source_dispatch_id?: string | null;
+ recheck_dispatch_id?: string | null;
+ entry_id?: string | null;
+ project_id: string;
+ dispatch_status: string;
+ workflow_stage: string;
+ review_outcome?: "refresh_with_evidence" | "archive" | "supersede" | null;
+ owner?: string | null;
+ completed_at?: string | null;
+ stale_ratio_snapshot?: KnowledgeStaleRatioSnapshot | null;
+ stale_count_delta?: number | null;
+ stale_ratio_delta?: number | null;
+ above_threshold?: boolean | null;
+};
+
+type KnowledgeStaleOwnerReviewBurnDownResponse = {
+ schema_version?: string;
+ project_id: string;
+ burn_down_status: "above_threshold" | "at_or_below_threshold" | "no_data";
+ current_snapshot?: KnowledgeStaleRatioSnapshot | null;
+ entries_to_threshold: number;
+ pending_owner_reviews: number;
+ completed_owner_reviews: number;
+ completion_audit_total: number;
+ stale_ratio_recheck_total: number;
+ latest_stale_count_delta?: number | null;
+ latest_stale_ratio_delta?: number | null;
+ writes_on_read: boolean;
+ manual_review_required: boolean;
+ returned: number;
+ items: KnowledgeStaleOwnerReviewBurnDownItem[];
+ generated_at?: string | null;
+};
+
type KnowledgeStaleOwnerReviewCompleteResponse = {
schema_version?: string;
entry_id: string;
@@ -546,13 +591,7 @@ type KnowledgeStaleOwnerReviewCompleteResponse = {
dry_run: boolean;
writes_km: boolean;
writes_governance_audit: boolean;
- stale_ratio_snapshot?: {
- stale_count: number;
- total_count: number;
- stale_ratio: number;
- threshold: number;
- stale_days: number;
- } | null;
+ stale_ratio_snapshot?: KnowledgeStaleRatioSnapshot | null;
dry_run_plan_fingerprint?: string | null;
next_action: string;
generated_at?: string | null;
@@ -650,6 +689,7 @@ type Telemetry = {
knowledgeReviewDedupe: KnowledgeReviewDraftDedupeResponse | null;
knowledgeStaleCandidates: KnowledgeStaleCandidatesResponse | null;
knowledgeStaleOwnerReviews: KnowledgeStaleOwnerReviewInboxResponse | null;
+ knowledgeStaleOwnerReviewBurnDown: KnowledgeStaleOwnerReviewBurnDownResponse | null;
channelEvents: RecentEventsResponse | null;
eventRecurrence: RecurrenceResponse | null;
slo: SloResponse | null;
@@ -1206,6 +1246,19 @@ function formatStaleRatio(value: number) {
return `${(value * 100).toFixed(1)}%`;
}
+function formatSignedNumber(value: number | null | undefined) {
+ if (value === null || value === undefined) return "--";
+ return value > 0 ? `+${value}` : String(value);
+}
+
+function formatSignedRatio(value: number | null | undefined) {
+ if (value === null || value === undefined) return "--";
+ const formatted = formatStaleRatio(Math.abs(value));
+ if (value > 0) return `+${formatted}`;
+ if (value < 0) return `-${formatted}`;
+ return formatted;
+}
+
function kmStaleReasonKey(value: string | null | undefined) {
switch (value) {
case "linked_incident":
@@ -2306,6 +2359,7 @@ function KnowledgeGovernancePanel({
dedupe,
staleCandidates,
ownerReviewInbox,
+ burnDown,
onArchived,
}: {
queue: GovernanceQueueResponse | null;
@@ -2313,6 +2367,7 @@ function KnowledgeGovernancePanel({
dedupe: KnowledgeReviewDraftDedupeResponse | null;
staleCandidates: KnowledgeStaleCandidatesResponse | null;
ownerReviewInbox: KnowledgeStaleOwnerReviewInboxResponse | null;
+ burnDown: KnowledgeStaleOwnerReviewBurnDownResponse | null;
onArchived: () => void;
}) {
const t = useTranslations("awooop.workItems.knowledgeGovernance");
@@ -2336,6 +2391,8 @@ function KnowledgeGovernancePanel({
);
const staleCandidateItems = staleCandidates?.items ?? [];
const ownerReviewItems = ownerReviewInbox?.items ?? [];
+ const burnDownItems = burnDown?.items ?? [];
+ const burnDownSnapshot = burnDown?.current_snapshot ?? null;
const draftTotal = dedupe?.total_review_drafts ?? reviewDrafts?.total ?? 0;
const activeCount = items.filter((item) =>
["pending", "dispatched", "executing"].includes(item.dispatch_status)
@@ -2901,6 +2958,157 @@ function KnowledgeGovernancePanel({
) : null}
+
+
+
+
+
+
+ {t("staleCandidates.burnDown.title")}
+
+
+ {t("staleCandidates.burnDown.subtitle")}
+
+
+
+
+
+ {t("staleCandidates.burnDown.statuses", {
+ status: burnDown
+ ? t(`staleCandidates.burnDown.status.${burnDown.burn_down_status}` as never)
+ : "--",
+ })}
+
+
+ {t("staleCandidates.burnDown.remaining", {
+ count: burnDown?.entries_to_threshold ?? 0,
+ })}
+
+
+
+ {burnDown === null ? (
+
+ {t("staleCandidates.burnDown.unavailable")}
+
+ ) : (
+ <>
+
+
+
+ {t("staleCandidates.burnDown.currentRatio")}
+
+
+ {burnDownSnapshot
+ ? formatStaleRatio(burnDownSnapshot.stale_ratio)
+ : "--"}
+
+
+
+
+ {t("staleCandidates.burnDown.currentCount")}
+
+
+ {burnDownSnapshot
+ ? `${burnDownSnapshot.stale_count}/${burnDownSnapshot.total_count}`
+ : "--"}
+
+
+
+
+ {t("staleCandidates.burnDown.ownerReviews")}
+
+
+ {t("staleCandidates.burnDown.ownerReviewCounts", {
+ pending: burnDown.pending_owner_reviews,
+ completed: burnDown.completed_owner_reviews,
+ })}
+
+
+
+
+ {t("staleCandidates.burnDown.latestDelta")}
+
+
+ {t("staleCandidates.burnDown.delta", {
+ stale: formatSignedNumber(burnDown.latest_stale_count_delta),
+ ratio: formatSignedRatio(burnDown.latest_stale_ratio_delta),
+ })}
+
+
+
+
+
+ {t("staleCandidates.burnDown.auditTotal", {
+ count: burnDown.completion_audit_total,
+ })}
+
+
+ {t("staleCandidates.burnDown.recheckTotal", {
+ count: burnDown.stale_ratio_recheck_total,
+ })}
+
+
+ {t("staleCandidates.burnDown.guardrail", {
+ writes: String(burnDown.writes_on_read),
+ review: String(burnDown.manual_review_required),
+ })}
+
+
+ {burnDownItems.length === 0 ? (
+
+ {t("staleCandidates.burnDown.empty")}
+
+ ) : (
+
+ {burnDownItems.slice(0, 4).map((item) => (
+
+
+
+
+ {item.entry_id ?? "--"}
+
+
+ {item.completion_dispatch_id}
+
+
+
+ {formatSignedNumber(item.stale_count_delta)}
+
+
+
+ {t("staleCandidates.burnDown.itemState", {
+ stage: t(`stages.${governanceKmStageKey(item.workflow_stage)}` as never),
+ outcome: item.review_outcome
+ ? t(`staleCandidates.completeActions.outcomes.${item.review_outcome}` as never)
+ : "--",
+ })}
+
+
+ {t("staleCandidates.burnDown.itemRefs", {
+ source: item.source_dispatch_id ?? "--",
+ recheck: item.recheck_dispatch_id ?? "--",
+ })}
+
+ {item.stale_ratio_snapshot ? (
+
+ {t("staleCandidates.completeActions.snapshot", {
+ stale: item.stale_ratio_snapshot.stale_count,
+ total: item.stale_ratio_snapshot.total_count,
+ ratio: formatStaleRatio(item.stale_ratio_snapshot.stale_ratio),
+ threshold: formatStaleRatio(item.stale_ratio_snapshot.threshold),
+ })}
+
+ ) : null}
+
+ ))}
+
+ )}
+ >
+ )}
+
@@ -3799,6 +4007,7 @@ export default function AwoooPWorkItemsPage() {
knowledgeReviewDedupe: null,
knowledgeStaleCandidates: null,
knowledgeStaleOwnerReviews: null,
+ knowledgeStaleOwnerReviewBurnDown: null,
channelEvents: null,
eventRecurrence: null,
slo: null,
@@ -3820,6 +4029,7 @@ export default function AwoooPWorkItemsPage() {
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 knowledgeStaleOwnerReviewBurnDownUrl = `${API_BASE}/api/v1/ai/governance/km-stale-owner-review-burndown?project_id=${encodedProjectId}&limit=20`;
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`;
@@ -3835,6 +4045,7 @@ export default function AwoooPWorkItemsPage() {
knowledgeReviewDedupe,
knowledgeStaleCandidates,
knowledgeStaleOwnerReviews,
+ knowledgeStaleOwnerReviewBurnDown,
channelEvents,
eventRecurrence,
slo,
@@ -3849,6 +4060,7 @@ export default function AwoooPWorkItemsPage() {
fetchJson(knowledgeReviewDedupeUrl),
fetchJson(knowledgeStaleCandidatesUrl),
fetchJson(knowledgeStaleOwnerReviewsUrl),
+ fetchJson(knowledgeStaleOwnerReviewBurnDownUrl),
fetchJson(channelEventsUrl),
fetchJson(recurrenceUrl),
fetchJson(sloUrl),
@@ -3880,6 +4092,7 @@ export default function AwoooPWorkItemsPage() {
knowledgeReviewDedupe,
knowledgeStaleCandidates,
knowledgeStaleOwnerReviews,
+ knowledgeStaleOwnerReviewBurnDown,
channelEvents,
eventRecurrence,
slo,
@@ -4008,6 +4221,7 @@ export default function AwoooPWorkItemsPage() {
dedupe={telemetry.knowledgeReviewDedupe}
staleCandidates={telemetry.knowledgeStaleCandidates}
ownerReviewInbox={telemetry.knowledgeStaleOwnerReviews}
+ burnDown={telemetry.knowledgeStaleOwnerReviewBurnDown}
onArchived={fetchTelemetry}
/>