feat(governance): surface stale km burndown
All checks were successful
CD Pipeline / tests (push) Successful in 5m28s
Code Review / ai-code-review (push) Successful in 12s
Type Sync Check / check-type-sync (push) Successful in 25s
CD Pipeline / build-and-deploy (push) Successful in 4m6s
CD Pipeline / post-deploy-checks (push) Successful in 1m32s
All checks were successful
CD Pipeline / tests (push) Successful in 5m28s
Code Review / ai-code-review (push) Successful in 12s
Type Sync Check / check-type-sync (push) Successful in 25s
CD Pipeline / build-and-deploy (push) Successful in 4m6s
CD Pipeline / post-deploy-checks (push) Successful in 1m32s
This commit is contained in:
@@ -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
|
||||
# =============================================================================
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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。",
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mb-3 border border-[#e0ddd4] bg-white px-3 py-2 text-xs text-[#5f5b52]">
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Gauge className="h-4 w-4 text-[#5f5b52]" aria-hidden="true" />
|
||||
<div>
|
||||
<p className="font-semibold text-[#141413]">
|
||||
{t("staleCandidates.burnDown.title")}
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] leading-5 text-[#77736a]">
|
||||
{t("staleCandidates.burnDown.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 font-mono text-[11px] text-[#5f5b52]">
|
||||
<span className="border border-[#d8d3c7] bg-[#faf9f3] px-2 py-0.5">
|
||||
{t("staleCandidates.burnDown.statuses", {
|
||||
status: burnDown
|
||||
? t(`staleCandidates.burnDown.status.${burnDown.burn_down_status}` as never)
|
||||
: "--",
|
||||
})}
|
||||
</span>
|
||||
<span className="border border-[#d8d3c7] bg-[#faf9f3] px-2 py-0.5">
|
||||
{t("staleCandidates.burnDown.remaining", {
|
||||
count: burnDown?.entries_to_threshold ?? 0,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{burnDown === null ? (
|
||||
<p className="text-[11px] leading-5 text-[#8a5a08]">
|
||||
{t("staleCandidates.burnDown.unavailable")}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-px border border-[#e0ddd4] bg-[#e0ddd4] md:grid-cols-4">
|
||||
<div className="bg-[#faf9f3] px-3 py-2">
|
||||
<p className="text-[11px] text-[#77736a]">
|
||||
{t("staleCandidates.burnDown.currentRatio")}
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-sm font-semibold text-[#141413]">
|
||||
{burnDownSnapshot
|
||||
? formatStaleRatio(burnDownSnapshot.stale_ratio)
|
||||
: "--"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-[#faf9f3] px-3 py-2">
|
||||
<p className="text-[11px] text-[#77736a]">
|
||||
{t("staleCandidates.burnDown.currentCount")}
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-sm font-semibold text-[#141413]">
|
||||
{burnDownSnapshot
|
||||
? `${burnDownSnapshot.stale_count}/${burnDownSnapshot.total_count}`
|
||||
: "--"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-[#faf9f3] px-3 py-2">
|
||||
<p className="text-[11px] text-[#77736a]">
|
||||
{t("staleCandidates.burnDown.ownerReviews")}
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-sm font-semibold text-[#141413]">
|
||||
{t("staleCandidates.burnDown.ownerReviewCounts", {
|
||||
pending: burnDown.pending_owner_reviews,
|
||||
completed: burnDown.completed_owner_reviews,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-[#faf9f3] px-3 py-2">
|
||||
<p className="text-[11px] text-[#77736a]">
|
||||
{t("staleCandidates.burnDown.latestDelta")}
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-sm font-semibold text-[#141413]">
|
||||
{t("staleCandidates.burnDown.delta", {
|
||||
stale: formatSignedNumber(burnDown.latest_stale_count_delta),
|
||||
ratio: formatSignedRatio(burnDown.latest_stale_ratio_delta),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2 font-mono text-[11px] text-[#5f5b52]">
|
||||
<span className="border border-[#d8d3c7] bg-[#faf9f3] px-2 py-0.5">
|
||||
{t("staleCandidates.burnDown.auditTotal", {
|
||||
count: burnDown.completion_audit_total,
|
||||
})}
|
||||
</span>
|
||||
<span className="border border-[#d8d3c7] bg-[#faf9f3] px-2 py-0.5">
|
||||
{t("staleCandidates.burnDown.recheckTotal", {
|
||||
count: burnDown.stale_ratio_recheck_total,
|
||||
})}
|
||||
</span>
|
||||
<span className="border border-[#d8d3c7] bg-[#faf9f3] px-2 py-0.5">
|
||||
{t("staleCandidates.burnDown.guardrail", {
|
||||
writes: String(burnDown.writes_on_read),
|
||||
review: String(burnDown.manual_review_required),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{burnDownItems.length === 0 ? (
|
||||
<p className="mt-2 text-[11px] leading-5 text-[#5f5b52]">
|
||||
{t("staleCandidates.burnDown.empty")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-2 grid gap-2 md:grid-cols-2">
|
||||
{burnDownItems.slice(0, 4).map((item) => (
|
||||
<article
|
||||
key={item.completion_dispatch_id}
|
||||
className="border border-[#e0ddd4] bg-[#faf9f3] px-3 py-2 text-[11px] leading-5 text-[#5f5b52]"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-mono font-semibold text-[#141413]">
|
||||
{item.entry_id ?? "--"}
|
||||
</p>
|
||||
<p className="mt-1 truncate font-mono text-[#77736a]">
|
||||
{item.completion_dispatch_id}
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 border border-[#9bc7a4] bg-[#f0faf2] px-2 py-0.5 font-mono font-semibold text-[#17602a]">
|
||||
{formatSignedNumber(item.stale_count_delta)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2">
|
||||
{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)
|
||||
: "--",
|
||||
})}
|
||||
</p>
|
||||
<p className="truncate">
|
||||
{t("staleCandidates.burnDown.itemRefs", {
|
||||
source: item.source_dispatch_id ?? "--",
|
||||
recheck: item.recheck_dispatch_id ?? "--",
|
||||
})}
|
||||
</p>
|
||||
{item.stale_ratio_snapshot ? (
|
||||
<p>
|
||||
{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),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-3 border border-[#e0ddd4] bg-white px-3 py-2 text-xs text-[#5f5b52]">
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -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<KnowledgeReviewDraftDedupeResponse>(knowledgeReviewDedupeUrl),
|
||||
fetchJson<KnowledgeStaleCandidatesResponse>(knowledgeStaleCandidatesUrl),
|
||||
fetchJson<KnowledgeStaleOwnerReviewInboxResponse>(knowledgeStaleOwnerReviewsUrl),
|
||||
fetchJson<KnowledgeStaleOwnerReviewBurnDownResponse>(knowledgeStaleOwnerReviewBurnDownUrl),
|
||||
fetchJson<RecentEventsResponse>(channelEventsUrl),
|
||||
fetchJson<RecurrenceResponse>(recurrenceUrl),
|
||||
fetchJson<SloResponse>(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}
|
||||
/>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user