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.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} />