feat(governance): preview stale km completion batches
All checks were successful
CD Pipeline / tests (push) Successful in 1m8s
Code Review / ai-code-review (push) Successful in 11s
Type Sync Check / check-type-sync (push) Successful in 26s
CD Pipeline / build-and-deploy (push) Successful in 4m9s
CD Pipeline / post-deploy-checks (push) Successful in 1m33s
All checks were successful
CD Pipeline / tests (push) Successful in 1m8s
Code Review / ai-code-review (push) Successful in 11s
Type Sync Check / check-type-sync (push) Successful in 26s
CD Pipeline / build-and-deploy (push) Successful in 4m9s
CD Pipeline / post-deploy-checks (push) Successful in 1m33s
This commit is contained in:
@@ -37,6 +37,8 @@ from src.models.governance import (
|
||||
KnowledgeStaleOwnerReviewBurnDownResponse,
|
||||
KnowledgeStaleOwnerReviewCompleteRequest,
|
||||
KnowledgeStaleOwnerReviewCompleteResponse,
|
||||
KnowledgeStaleOwnerReviewCompletionBatchPreviewRequest,
|
||||
KnowledgeStaleOwnerReviewCompletionBatchPreviewResponse,
|
||||
KnowledgeStaleOwnerReviewCompletionQueueResponse,
|
||||
KnowledgeStaleOwnerReviewInboxResponse,
|
||||
KnowledgeStaleOwnerReviewRequest,
|
||||
@@ -50,6 +52,7 @@ from src.services.governance_km_stale_review_service import (
|
||||
KmStaleOwnerReviewError,
|
||||
batch_queue_km_stale_owner_reviews,
|
||||
complete_km_stale_owner_review,
|
||||
preview_km_stale_owner_review_completion_batch,
|
||||
query_km_stale_owner_review_burndown,
|
||||
query_km_stale_owner_review_completion_queue,
|
||||
query_km_stale_owner_review_inbox,
|
||||
@@ -320,6 +323,13 @@ async def get_km_stale_owner_review_completion_queue(
|
||||
str,
|
||||
Query(pattern="^(all|ready|blocked|completed|failed|pending)$"),
|
||||
] = "all",
|
||||
priority_tier: Annotated[list[str] | None, Query(alias="priority_tier")] = None,
|
||||
recommended_completion_outcome: Annotated[
|
||||
str,
|
||||
Query(pattern="^(all|refresh_with_evidence|archive|supersede)$"),
|
||||
] = "all",
|
||||
batch_governance_event_id: Annotated[str | None, Query(max_length=120)] = None,
|
||||
can_preview: bool | None = None,
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||
) -> KnowledgeStaleOwnerReviewCompletionQueueResponse:
|
||||
"""
|
||||
@@ -332,13 +342,59 @@ async def get_km_stale_owner_review_completion_queue(
|
||||
"km_stale_owner_review_completion_queue_request",
|
||||
project_id=project_id,
|
||||
status_bucket=status_bucket,
|
||||
priority_tiers=priority_tier,
|
||||
recommended_completion_outcome=recommended_completion_outcome,
|
||||
batch_governance_event_id=batch_governance_event_id,
|
||||
can_preview=can_preview,
|
||||
limit=limit,
|
||||
)
|
||||
return await query_km_stale_owner_review_completion_queue(
|
||||
project_id=project_id,
|
||||
status_bucket=status_bucket,
|
||||
limit=limit,
|
||||
try:
|
||||
return await query_km_stale_owner_review_completion_queue(
|
||||
project_id=project_id,
|
||||
status_bucket=status_bucket,
|
||||
priority_tiers=priority_tier,
|
||||
recommended_completion_outcome=recommended_completion_outcome,
|
||||
batch_governance_event_id=batch_governance_event_id,
|
||||
can_preview=can_preview,
|
||||
limit=limit,
|
||||
)
|
||||
except KmStaleOwnerReviewError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# POST /api/v1/ai/governance/km-stale-owner-review-completion-queue/batch-preview
|
||||
# =============================================================================
|
||||
|
||||
@router.post(
|
||||
"/ai/governance/km-stale-owner-review-completion-queue/batch-preview",
|
||||
response_model=KnowledgeStaleOwnerReviewCompletionBatchPreviewResponse,
|
||||
)
|
||||
async def post_km_stale_owner_review_completion_batch_preview(
|
||||
request: KnowledgeStaleOwnerReviewCompletionBatchPreviewRequest,
|
||||
) -> KnowledgeStaleOwnerReviewCompletionBatchPreviewResponse:
|
||||
"""
|
||||
Preview a bounded set of owner-review completion candidates.
|
||||
|
||||
This endpoint is intentionally dry-run only: it does not write KM, does not
|
||||
enqueue a batch executor, and does not create governance audit rows. Each
|
||||
item must still be completed through the single-item dry-run + owner confirm
|
||||
endpoint.
|
||||
"""
|
||||
logger.info(
|
||||
"km_stale_owner_review_completion_batch_preview_request",
|
||||
project_id=request.project_id,
|
||||
status_bucket=request.status_bucket,
|
||||
priority_tiers=request.priority_tiers,
|
||||
recommended_completion_outcome=request.recommended_completion_outcome,
|
||||
batch_governance_event_id=request.batch_governance_event_id,
|
||||
limit=request.limit,
|
||||
owner=request.owner,
|
||||
)
|
||||
try:
|
||||
return await preview_km_stale_owner_review_completion_batch(request=request)
|
||||
except KmStaleOwnerReviewError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -465,6 +465,15 @@ class KnowledgeStaleOwnerReviewCompletionQueueResponse(BaseModel):
|
||||
schema_version: str = "km_stale_owner_review_completion_queue_v1"
|
||||
project_id: str
|
||||
status_bucket: Literal["all", "ready", "blocked", "completed", "failed", "pending"]
|
||||
priority_tiers: list[str] = Field(default_factory=list)
|
||||
recommended_completion_outcome: Literal[
|
||||
"all",
|
||||
"refresh_with_evidence",
|
||||
"archive",
|
||||
"supersede",
|
||||
] = "all"
|
||||
batch_governance_event_id: str | None = None
|
||||
can_preview: bool | None = None
|
||||
total: int
|
||||
returned: int
|
||||
pending_count: int
|
||||
@@ -479,6 +488,57 @@ class KnowledgeStaleOwnerReviewCompletionQueueResponse(BaseModel):
|
||||
generated_at: datetime
|
||||
|
||||
|
||||
class KnowledgeStaleOwnerReviewCompletionBatchPreviewRequest(BaseModel):
|
||||
project_id: str = Field(default="awoooi", min_length=1, max_length=64)
|
||||
status_bucket: Literal["all", "ready", "blocked", "completed", "failed", "pending"] = "ready"
|
||||
priority_tiers: list[Literal["P0", "P1", "P2"]] = Field(
|
||||
default_factory=lambda: ["P0", "P1", "P2"],
|
||||
min_length=1,
|
||||
max_length=3,
|
||||
)
|
||||
recommended_completion_outcome: Literal[
|
||||
"all",
|
||||
"refresh_with_evidence",
|
||||
"archive",
|
||||
"supersede",
|
||||
] = "all"
|
||||
batch_governance_event_id: str | None = Field(default=None, max_length=120)
|
||||
limit: int = Field(default=10, ge=1, le=30)
|
||||
owner: str = Field(default="operator_console", min_length=1, max_length=100)
|
||||
owner_note: str | None = Field(default=None, max_length=240)
|
||||
|
||||
|
||||
class KnowledgeStaleOwnerReviewCompletionBatchPreviewResponse(BaseModel):
|
||||
schema_version: str = "km_stale_owner_review_completion_batch_preview_v1"
|
||||
project_id: str
|
||||
status: Literal["dry_run"] = "dry_run"
|
||||
owner: str
|
||||
owner_note: str | None = None
|
||||
status_bucket: Literal["all", "ready", "blocked", "completed", "failed", "pending"]
|
||||
priority_tiers: list[str]
|
||||
recommended_completion_outcome: Literal[
|
||||
"all",
|
||||
"refresh_with_evidence",
|
||||
"archive",
|
||||
"supersede",
|
||||
]
|
||||
batch_governance_event_id: str | None = None
|
||||
requested_limit: int
|
||||
candidate_count: int
|
||||
previewable_count: int
|
||||
blocked_count: int
|
||||
completed_count: int
|
||||
failed_count: int
|
||||
writes_km: bool = False
|
||||
writes_governance_audit: bool = False
|
||||
batch_writes_allowed: bool = False
|
||||
manual_review_required: bool = True
|
||||
dry_run_plan_fingerprint: str
|
||||
next_action: str = "preview_each_ready_item_then_confirm_single_item"
|
||||
items: list[KnowledgeStaleOwnerReviewCompletionQueueItem] = Field(default_factory=list)
|
||||
generated_at: datetime
|
||||
|
||||
|
||||
class KnowledgeStaleOwnerReviewCompleteRequest(BaseModel):
|
||||
dispatch_id: str | None = Field(
|
||||
default=None,
|
||||
|
||||
@@ -37,6 +37,8 @@ from src.models.governance import (
|
||||
KnowledgeStaleOwnerReviewBurnDownResponse,
|
||||
KnowledgeStaleOwnerReviewCompleteRequest,
|
||||
KnowledgeStaleOwnerReviewCompleteResponse,
|
||||
KnowledgeStaleOwnerReviewCompletionBatchPreviewRequest,
|
||||
KnowledgeStaleOwnerReviewCompletionBatchPreviewResponse,
|
||||
KnowledgeStaleOwnerReviewCompletionQueueItem,
|
||||
KnowledgeStaleOwnerReviewCompletionQueueResponse,
|
||||
KnowledgeStaleOwnerReviewInboxItem,
|
||||
@@ -120,9 +122,15 @@ async def query_km_stale_owner_review_completion_queue(
|
||||
*,
|
||||
project_id: str = "awoooi",
|
||||
status_bucket: Literal["all", "ready", "blocked", "completed", "failed", "pending"] = "all",
|
||||
priority_tiers: list[str] | None = None,
|
||||
recommended_completion_outcome: str = "all",
|
||||
batch_governance_event_id: str | None = None,
|
||||
can_preview: bool | None = None,
|
||||
limit: int = 20,
|
||||
) -> KnowledgeStaleOwnerReviewCompletionQueueResponse:
|
||||
"""Read owner-review completion readiness without writing KM content."""
|
||||
normalized_priority_tiers = _normalize_priority_tiers(priority_tiers)
|
||||
normalized_outcome = _normalize_completion_outcome(recommended_completion_outcome)
|
||||
generated_at = now_taipei()
|
||||
inbox_items = await _load_owner_review_inbox_items(
|
||||
project_id=project_id,
|
||||
@@ -148,10 +156,21 @@ async def query_km_stale_owner_review_completion_queue(
|
||||
item for item in queue_items
|
||||
if _completion_queue_bucket_matches(item, status_bucket=status_bucket)
|
||||
]
|
||||
filtered_items = _filter_completion_queue_items(
|
||||
filtered_items,
|
||||
priority_tiers=normalized_priority_tiers,
|
||||
recommended_completion_outcome=normalized_outcome,
|
||||
batch_governance_event_id=batch_governance_event_id,
|
||||
can_preview=can_preview,
|
||||
)
|
||||
limited = filtered_items[:limit]
|
||||
return KnowledgeStaleOwnerReviewCompletionQueueResponse(
|
||||
project_id=project_id,
|
||||
status_bucket=status_bucket,
|
||||
priority_tiers=normalized_priority_tiers,
|
||||
recommended_completion_outcome=normalized_outcome,
|
||||
batch_governance_event_id=batch_governance_event_id,
|
||||
can_preview=can_preview,
|
||||
total=len(filtered_items),
|
||||
returned=len(limited),
|
||||
pending_count=sum(
|
||||
@@ -167,6 +186,47 @@ async def query_km_stale_owner_review_completion_queue(
|
||||
)
|
||||
|
||||
|
||||
async def preview_km_stale_owner_review_completion_batch(
|
||||
*,
|
||||
request: KnowledgeStaleOwnerReviewCompletionBatchPreviewRequest,
|
||||
) -> KnowledgeStaleOwnerReviewCompletionBatchPreviewResponse:
|
||||
"""Preview a bounded completion batch without writing KM or governance audit rows."""
|
||||
generated_at = now_taipei()
|
||||
queue = await query_km_stale_owner_review_completion_queue(
|
||||
project_id=request.project_id,
|
||||
status_bucket=request.status_bucket,
|
||||
priority_tiers=list(request.priority_tiers),
|
||||
recommended_completion_outcome=request.recommended_completion_outcome,
|
||||
batch_governance_event_id=request.batch_governance_event_id,
|
||||
can_preview=None,
|
||||
limit=request.limit,
|
||||
)
|
||||
items = queue.items
|
||||
previewable_items = [item for item in items if item.can_preview]
|
||||
fingerprint = _build_completion_batch_preview_fingerprint(
|
||||
request=request,
|
||||
items=items,
|
||||
)
|
||||
return KnowledgeStaleOwnerReviewCompletionBatchPreviewResponse(
|
||||
project_id=request.project_id,
|
||||
owner=request.owner,
|
||||
owner_note=request.owner_note,
|
||||
status_bucket=request.status_bucket,
|
||||
priority_tiers=list(dict.fromkeys(request.priority_tiers)),
|
||||
recommended_completion_outcome=request.recommended_completion_outcome,
|
||||
batch_governance_event_id=request.batch_governance_event_id,
|
||||
requested_limit=request.limit,
|
||||
candidate_count=len(items),
|
||||
previewable_count=len(previewable_items),
|
||||
blocked_count=sum(1 for item in items if item.readiness == "blocked"),
|
||||
completed_count=sum(1 for item in items if item.readiness == "completed"),
|
||||
failed_count=sum(1 for item in items if item.readiness == "failed"),
|
||||
dry_run_plan_fingerprint=fingerprint,
|
||||
items=items,
|
||||
generated_at=generated_at,
|
||||
)
|
||||
|
||||
|
||||
async def _load_owner_review_inbox_items(
|
||||
*,
|
||||
project_id: str,
|
||||
@@ -973,6 +1033,107 @@ def _completion_queue_bucket_matches(
|
||||
return item.readiness == status_bucket
|
||||
|
||||
|
||||
def _normalize_priority_tiers(priority_tiers: list[str] | None) -> list[str]:
|
||||
if not priority_tiers:
|
||||
return []
|
||||
allowed = {"P0", "P1", "P2"}
|
||||
normalized = list(dict.fromkeys(str(tier).upper() for tier in priority_tiers))
|
||||
unsupported = [tier for tier in normalized if tier not in allowed]
|
||||
if unsupported:
|
||||
raise KmStaleOwnerReviewError(
|
||||
422,
|
||||
"unsupported stale KM owner-review completion priority_tier",
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_completion_outcome(outcome: str) -> Literal[
|
||||
"all",
|
||||
"refresh_with_evidence",
|
||||
"archive",
|
||||
"supersede",
|
||||
]:
|
||||
if outcome == "all":
|
||||
return "all"
|
||||
if outcome == "refresh_with_evidence":
|
||||
return "refresh_with_evidence"
|
||||
if outcome == "archive":
|
||||
return "archive"
|
||||
if outcome == "supersede":
|
||||
return "supersede"
|
||||
raise KmStaleOwnerReviewError(
|
||||
422,
|
||||
"unsupported stale KM owner-review completion outcome",
|
||||
)
|
||||
|
||||
|
||||
def _filter_completion_queue_items(
|
||||
items: list[KnowledgeStaleOwnerReviewCompletionQueueItem],
|
||||
*,
|
||||
priority_tiers: list[str],
|
||||
recommended_completion_outcome: str,
|
||||
batch_governance_event_id: str | None,
|
||||
can_preview: bool | None,
|
||||
) -> list[KnowledgeStaleOwnerReviewCompletionQueueItem]:
|
||||
filtered = items
|
||||
if priority_tiers:
|
||||
wanted = set(priority_tiers)
|
||||
filtered = [item for item in filtered if item.priority_tier in wanted]
|
||||
if recommended_completion_outcome != "all":
|
||||
filtered = [
|
||||
item for item in filtered
|
||||
if item.recommended_completion_outcome == recommended_completion_outcome
|
||||
]
|
||||
if batch_governance_event_id:
|
||||
filtered = [
|
||||
item for item in filtered
|
||||
if item.batch_governance_event_id == batch_governance_event_id
|
||||
]
|
||||
if can_preview is not None:
|
||||
filtered = [item for item in filtered if item.can_preview is can_preview]
|
||||
return filtered
|
||||
|
||||
|
||||
def _build_completion_batch_preview_fingerprint(
|
||||
*,
|
||||
request: KnowledgeStaleOwnerReviewCompletionBatchPreviewRequest,
|
||||
items: list[KnowledgeStaleOwnerReviewCompletionQueueItem],
|
||||
) -> str:
|
||||
payload = {
|
||||
"schema_version": "km_stale_owner_review_completion_batch_preview_v1",
|
||||
"project_id": request.project_id,
|
||||
"status_bucket": request.status_bucket,
|
||||
"priority_tiers": list(dict.fromkeys(request.priority_tiers)),
|
||||
"recommended_completion_outcome": request.recommended_completion_outcome,
|
||||
"batch_governance_event_id": request.batch_governance_event_id,
|
||||
"limit": request.limit,
|
||||
"owner": request.owner,
|
||||
"owner_note_sha256": (
|
||||
hashlib.sha256(request.owner_note.encode("utf-8")).hexdigest()
|
||||
if request.owner_note
|
||||
else None
|
||||
),
|
||||
"items": [
|
||||
{
|
||||
"dispatch_id": item.dispatch_id,
|
||||
"entry_id": item.entry_id,
|
||||
"readiness": item.readiness,
|
||||
"recommended_completion_outcome": item.recommended_completion_outcome,
|
||||
"can_preview": item.can_preview,
|
||||
"workflow_stage": item.workflow_stage,
|
||||
}
|
||||
for item in items
|
||||
],
|
||||
}
|
||||
encoded = json.dumps(
|
||||
payload,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
return "sha256:" + hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _completion_outcome_for_owner_review_item(
|
||||
item: KnowledgeStaleOwnerReviewInboxItem,
|
||||
) -> Literal["refresh_with_evidence", "archive", "supersede"]:
|
||||
|
||||
@@ -45,6 +45,8 @@ from src.models.governance import (
|
||||
KnowledgeStaleOwnerReviewBurnDownResponse,
|
||||
KnowledgeStaleOwnerReviewCompleteRequest,
|
||||
KnowledgeStaleOwnerReviewCompleteResponse,
|
||||
KnowledgeStaleOwnerReviewCompletionBatchPreviewRequest,
|
||||
KnowledgeStaleOwnerReviewCompletionBatchPreviewResponse,
|
||||
KnowledgeStaleOwnerReviewCompletionQueueItem,
|
||||
KnowledgeStaleOwnerReviewCompletionQueueResponse,
|
||||
KnowledgeStaleOwnerReviewInboxItem,
|
||||
@@ -65,6 +67,7 @@ from src.services.governance_km_stale_review_service import (
|
||||
KmStaleOwnerReviewError,
|
||||
_build_batch_owner_review_decision_context,
|
||||
_build_batch_queue_plan_fingerprint,
|
||||
_build_completion_batch_preview_fingerprint,
|
||||
_build_completion_plan_fingerprint,
|
||||
_build_completion_queue_item,
|
||||
_build_owner_review_burndown_items,
|
||||
@@ -1224,11 +1227,21 @@ class TestKmReviewDraftDedupe:
|
||||
):
|
||||
r = client.get(
|
||||
"/api/v1/ai/governance/km-stale-owner-review-completion-queue"
|
||||
"?project_id=awoooi&status_bucket=all&limit=12"
|
||||
"?project_id=awoooi&status_bucket=all&priority_tier=P0"
|
||||
"&recommended_completion_outcome=refresh_with_evidence"
|
||||
"&can_preview=true&limit=12"
|
||||
)
|
||||
|
||||
assert r.status_code == 200
|
||||
assert captured == {"project_id": "awoooi", "status_bucket": "all", "limit": 12}
|
||||
assert captured == {
|
||||
"project_id": "awoooi",
|
||||
"status_bucket": "all",
|
||||
"priority_tiers": ["P0"],
|
||||
"recommended_completion_outcome": "refresh_with_evidence",
|
||||
"batch_governance_event_id": None,
|
||||
"can_preview": True,
|
||||
"limit": 12,
|
||||
}
|
||||
data = r.json()
|
||||
assert data["schema_version"] == "km_stale_owner_review_completion_queue_v1"
|
||||
assert data["writes_on_read"] is False
|
||||
@@ -1240,6 +1253,83 @@ class TestKmReviewDraftDedupe:
|
||||
assert data["items"][0]["can_preview"] is True
|
||||
assert data["items"][1]["writes_km_on_confirm"] is False
|
||||
|
||||
def test_completion_queue_batch_preview_endpoint_is_dry_run_only(self, client):
|
||||
fake = KnowledgeStaleOwnerReviewCompletionBatchPreviewResponse(
|
||||
project_id="awoooi",
|
||||
owner="operator_console",
|
||||
owner_note="review top ready items",
|
||||
status_bucket="ready",
|
||||
priority_tiers=["P0", "P1"],
|
||||
recommended_completion_outcome="all",
|
||||
requested_limit=10,
|
||||
candidate_count=1,
|
||||
previewable_count=1,
|
||||
blocked_count=0,
|
||||
completed_count=0,
|
||||
failed_count=0,
|
||||
dry_run_plan_fingerprint="sha256:" + "d" * 64,
|
||||
items=[
|
||||
KnowledgeStaleOwnerReviewCompletionQueueItem(
|
||||
dispatch_id="dispatch-ready-001",
|
||||
governance_event_id="event-001",
|
||||
entry_id="km-001",
|
||||
project_id="awoooi",
|
||||
title="Sentry checkout failure repair",
|
||||
dispatch_status="pending",
|
||||
workflow_stage="waiting_owner_review",
|
||||
readiness="ready",
|
||||
recommended_completion_outcome="refresh_with_evidence",
|
||||
next_action="preview_stale_km_review_completion",
|
||||
required_owner_fields=["owner_note_or_updated_content"],
|
||||
can_preview=True,
|
||||
can_confirm_after_preview=True,
|
||||
writes_km_on_confirm=True,
|
||||
priority_tier="P0",
|
||||
priority_score=265,
|
||||
recommended_action="refresh_with_evidence",
|
||||
stale_days=35,
|
||||
view_count=7,
|
||||
correlation_sources=["incident", "playbook", "sentry"],
|
||||
reasons=["linked_incident"],
|
||||
queued_at=NOW,
|
||||
)
|
||||
],
|
||||
generated_at=NOW,
|
||||
)
|
||||
captured: dict = {}
|
||||
|
||||
async def mock_completion_batch_preview(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return fake
|
||||
|
||||
with patch(
|
||||
"src.api.v1.ai_governance.preview_km_stale_owner_review_completion_batch",
|
||||
new=mock_completion_batch_preview,
|
||||
):
|
||||
r = client.post(
|
||||
"/api/v1/ai/governance/km-stale-owner-review-completion-queue/batch-preview",
|
||||
json={
|
||||
"project_id": "awoooi",
|
||||
"status_bucket": "ready",
|
||||
"priority_tiers": ["P0", "P1"],
|
||||
"limit": 10,
|
||||
"owner": "operator_console",
|
||||
"owner_note": "review top ready items",
|
||||
},
|
||||
)
|
||||
|
||||
assert r.status_code == 200
|
||||
assert isinstance(captured["request"], KnowledgeStaleOwnerReviewCompletionBatchPreviewRequest)
|
||||
data = r.json()
|
||||
assert data["schema_version"] == "km_stale_owner_review_completion_batch_preview_v1"
|
||||
assert data["status"] == "dry_run"
|
||||
assert data["writes_km"] is False
|
||||
assert data["writes_governance_audit"] is False
|
||||
assert data["batch_writes_allowed"] is False
|
||||
assert data["manual_review_required"] is True
|
||||
assert data["previewable_count"] == 1
|
||||
assert data["items"][0]["can_preview"] is True
|
||||
|
||||
def test_completion_queue_item_marks_active_review_ready(self):
|
||||
inbox_item = KnowledgeStaleOwnerReviewInboxItem(
|
||||
dispatch_id="dispatch-ready-001",
|
||||
@@ -1301,6 +1391,50 @@ class TestKmReviewDraftDedupe:
|
||||
assert item.can_confirm_after_preview is False
|
||||
assert item.writes_km_on_confirm is False
|
||||
|
||||
def test_completion_batch_preview_fingerprint_is_stable_and_read_only(self):
|
||||
request = KnowledgeStaleOwnerReviewCompletionBatchPreviewRequest(
|
||||
project_id="awoooi",
|
||||
status_bucket="ready",
|
||||
priority_tiers=["P0", "P1"],
|
||||
limit=10,
|
||||
owner="operator_console",
|
||||
owner_note="top ready items",
|
||||
)
|
||||
items = [
|
||||
KnowledgeStaleOwnerReviewCompletionQueueItem(
|
||||
dispatch_id="dispatch-ready-001",
|
||||
governance_event_id="event-001",
|
||||
entry_id="km-001",
|
||||
project_id="awoooi",
|
||||
title="Sentry checkout failure repair",
|
||||
dispatch_status="pending",
|
||||
workflow_stage="waiting_owner_review",
|
||||
readiness="ready",
|
||||
recommended_completion_outcome="refresh_with_evidence",
|
||||
next_action="preview_stale_km_review_completion",
|
||||
required_owner_fields=["owner_note_or_updated_content"],
|
||||
can_preview=True,
|
||||
can_confirm_after_preview=True,
|
||||
writes_km_on_confirm=True,
|
||||
priority_tier="P0",
|
||||
priority_score=265,
|
||||
recommended_action="refresh_with_evidence",
|
||||
stale_days=35,
|
||||
view_count=7,
|
||||
correlation_sources=["incident", "playbook"],
|
||||
reasons=["linked_incident"],
|
||||
queued_at=NOW,
|
||||
)
|
||||
]
|
||||
|
||||
fingerprint = _build_completion_batch_preview_fingerprint(
|
||||
request=request,
|
||||
items=items,
|
||||
)
|
||||
|
||||
assert fingerprint.startswith("sha256:")
|
||||
assert len(fingerprint) == 71
|
||||
|
||||
def test_stale_owner_review_batch_context_is_operator_visible(self):
|
||||
request = KnowledgeStaleOwnerReviewBatchQueueRequest(
|
||||
project_id="awoooi",
|
||||
|
||||
@@ -2149,6 +2149,23 @@
|
||||
"next": "Next: {action}; outcome: {outcome}",
|
||||
"required": "Required fields: {fields}",
|
||||
"blockers": "Blockers: {blockers}",
|
||||
"filters": {
|
||||
"ready": "Ready",
|
||||
"blocked": "Blocked",
|
||||
"completed": "Completed",
|
||||
"failed": "Failed",
|
||||
"pending": "Pending",
|
||||
"all": "All",
|
||||
"priorityAll": "All priorities"
|
||||
},
|
||||
"batchPreview": {
|
||||
"preview": "Batch preview",
|
||||
"previewing": "Previewing",
|
||||
"previewFailed": "Completion batch preview failed",
|
||||
"summary": "Candidates {candidates}; single-item dry-run ready {previewable}; blocked {blocked}; writes KM={writesKm}; writes audit={writesAudit}; batch writes={batchWrites}",
|
||||
"planFingerprint": "Preview fingerprint: {fingerprint}",
|
||||
"next": "Next: {action}"
|
||||
},
|
||||
"readiness": {
|
||||
"ready": "Ready to dry-run",
|
||||
"blocked": "Needs manual unblock",
|
||||
|
||||
@@ -2150,6 +2150,23 @@
|
||||
"next": "下一步:{action};結果:{outcome}",
|
||||
"required": "需要欄位:{fields}",
|
||||
"blockers": "卡點:{blockers}",
|
||||
"filters": {
|
||||
"ready": "可處理",
|
||||
"blocked": "卡住",
|
||||
"completed": "已完成",
|
||||
"failed": "失敗",
|
||||
"pending": "待處理",
|
||||
"all": "全部",
|
||||
"priorityAll": "全部優先級"
|
||||
},
|
||||
"batchPreview": {
|
||||
"preview": "批次預覽",
|
||||
"previewing": "預覽中",
|
||||
"previewFailed": "批次 completion 預覽失敗",
|
||||
"summary": "候選 {candidates};可逐筆乾跑 {previewable};卡住 {blocked};寫 KM={writesKm};寫 audit={writesAudit};批次寫入={batchWrites}",
|
||||
"planFingerprint": "預覽指紋:{fingerprint}",
|
||||
"next": "下一步:{action}"
|
||||
},
|
||||
"readiness": {
|
||||
"ready": "可乾跑",
|
||||
"blocked": "需人工排除",
|
||||
|
||||
@@ -616,6 +616,10 @@ type KnowledgeStaleOwnerReviewCompletionQueueResponse = {
|
||||
schema_version?: string;
|
||||
project_id: string;
|
||||
status_bucket: "all" | "ready" | "blocked" | "completed" | "failed" | "pending";
|
||||
priority_tiers?: string[];
|
||||
recommended_completion_outcome?: "all" | "refresh_with_evidence" | "archive" | "supersede";
|
||||
batch_governance_event_id?: string | null;
|
||||
can_preview?: boolean | null;
|
||||
total: number;
|
||||
returned: number;
|
||||
pending_count: number;
|
||||
@@ -630,6 +634,38 @@ type KnowledgeStaleOwnerReviewCompletionQueueResponse = {
|
||||
generated_at?: string | null;
|
||||
};
|
||||
|
||||
type KnowledgeStaleOwnerReviewCompletionBatchPreviewResponse = {
|
||||
schema_version?: string;
|
||||
project_id: string;
|
||||
status: "dry_run";
|
||||
owner: string;
|
||||
owner_note?: string | null;
|
||||
status_bucket: "all" | "ready" | "blocked" | "completed" | "failed" | "pending";
|
||||
priority_tiers: string[];
|
||||
recommended_completion_outcome: "all" | "refresh_with_evidence" | "archive" | "supersede";
|
||||
batch_governance_event_id?: string | null;
|
||||
requested_limit: number;
|
||||
candidate_count: number;
|
||||
previewable_count: number;
|
||||
blocked_count: number;
|
||||
completed_count: number;
|
||||
failed_count: number;
|
||||
writes_km: boolean;
|
||||
writes_governance_audit: boolean;
|
||||
batch_writes_allowed: boolean;
|
||||
manual_review_required: boolean;
|
||||
dry_run_plan_fingerprint: string;
|
||||
next_action: string;
|
||||
items: KnowledgeStaleOwnerReviewCompletionQueueItem[];
|
||||
generated_at?: string | null;
|
||||
};
|
||||
|
||||
type KnowledgeStaleOwnerReviewCompletionBatchPreviewAction = {
|
||||
loading: boolean;
|
||||
result: KnowledgeStaleOwnerReviewCompletionBatchPreviewResponse | null;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
type KnowledgeStaleOwnerReviewCompleteResponse = {
|
||||
schema_version?: string;
|
||||
entry_id: string;
|
||||
@@ -2452,6 +2488,16 @@ function KnowledgeGovernancePanel({
|
||||
});
|
||||
const [staleReviewCompletionActions, setStaleReviewCompletionActions] =
|
||||
useState<Record<string, KnowledgeStaleOwnerReviewCompleteAction>>({});
|
||||
const [completionReadinessFilter, setCompletionReadinessFilter] =
|
||||
useState<"all" | "ready" | "blocked" | "completed" | "failed" | "pending">("ready");
|
||||
const [completionPriorityFilter, setCompletionPriorityFilter] =
|
||||
useState<"all" | "P0" | "P1" | "P2">("all");
|
||||
const [completionBatchPreviewAction, setCompletionBatchPreviewAction] =
|
||||
useState<KnowledgeStaleOwnerReviewCompletionBatchPreviewAction>({
|
||||
loading: false,
|
||||
result: null,
|
||||
error: null,
|
||||
});
|
||||
const items = queue?.items ?? [];
|
||||
const draftGroups = groupKnowledgeReviewDrafts(reviewDrafts, items);
|
||||
const dedupeGroups = dedupe?.groups ?? [];
|
||||
@@ -2463,7 +2509,20 @@ function KnowledgeGovernancePanel({
|
||||
const ownerReviewItems = ownerReviewInbox?.items ?? [];
|
||||
const burnDownItems = burnDown?.items ?? [];
|
||||
const burnDownSnapshot = burnDown?.current_snapshot ?? null;
|
||||
const completionQueueItems = completionQueue?.items ?? [];
|
||||
const completionQueueItems = useMemo(
|
||||
() => (completionQueue?.items ?? []).filter((item) => {
|
||||
const readinessMatches =
|
||||
completionReadinessFilter === "all" ||
|
||||
(completionReadinessFilter === "pending"
|
||||
? ["pending", "dispatched", "executing"].includes(item.dispatch_status)
|
||||
: item.readiness === completionReadinessFilter);
|
||||
const priorityMatches =
|
||||
completionPriorityFilter === "all" ||
|
||||
item.priority_tier === completionPriorityFilter;
|
||||
return readinessMatches && priorityMatches;
|
||||
}),
|
||||
[completionPriorityFilter, completionQueue?.items, completionReadinessFilter]
|
||||
);
|
||||
const draftTotal = dedupe?.total_review_drafts ?? reviewDrafts?.total ?? 0;
|
||||
const activeCount = items.filter((item) =>
|
||||
["pending", "dispatched", "executing"].includes(item.dispatch_status)
|
||||
@@ -2776,11 +2835,46 @@ function KnowledgeGovernancePanel({
|
||||
}
|
||||
}, [onArchived, staleReviewActions, staleReviewCompletionActions, t]);
|
||||
|
||||
const previewCompletionBatch = useCallback(async () => {
|
||||
setCompletionBatchPreviewAction({
|
||||
loading: true,
|
||||
result: null,
|
||||
error: null,
|
||||
});
|
||||
const result = await postJson<KnowledgeStaleOwnerReviewCompletionBatchPreviewResponse>(
|
||||
`${API_BASE}/api/v1/ai/governance/km-stale-owner-review-completion-queue/batch-preview`,
|
||||
{
|
||||
project_id: completionQueue?.project_id ?? staleCandidates?.project_id ?? "awoooi",
|
||||
status_bucket: completionReadinessFilter,
|
||||
priority_tiers: completionPriorityFilter === "all"
|
||||
? ["P0", "P1", "P2"]
|
||||
: [completionPriorityFilter],
|
||||
recommended_completion_outcome: "all",
|
||||
limit: 10,
|
||||
owner: "operator_console",
|
||||
owner_note: "completion_queue_batch_preview",
|
||||
},
|
||||
15000
|
||||
);
|
||||
setCompletionBatchPreviewAction({
|
||||
loading: false,
|
||||
result,
|
||||
error: result ? null : t("staleCandidates.completionQueue.batchPreview.previewFailed"),
|
||||
});
|
||||
}, [
|
||||
completionPriorityFilter,
|
||||
completionQueue?.project_id,
|
||||
completionReadinessFilter,
|
||||
staleCandidates?.project_id,
|
||||
t,
|
||||
]);
|
||||
|
||||
const staleBatchPreview = staleBatchAction.previewResult;
|
||||
const staleBatchResult = staleBatchAction.result;
|
||||
const staleBatchPreviewReady = Boolean(staleBatchPreview?.dry_run_plan_fingerprint);
|
||||
const staleBatchPreviewStatusKey = kmStaleBatchStatusKey(staleBatchPreview?.status);
|
||||
const staleBatchResultStatusKey = kmStaleBatchStatusKey(staleBatchResult?.status);
|
||||
const completionBatchPreview = completionBatchPreviewAction.result;
|
||||
|
||||
return (
|
||||
<section className="border border-[#e0ddd4] bg-white">
|
||||
@@ -3216,6 +3310,84 @@ function KnowledgeGovernancePanel({
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2 border border-[#e0ddd4] bg-[#faf9f3] px-2 py-2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(["ready", "blocked", "completed", "failed", "pending", "all"] as const).map((filter) => (
|
||||
<button
|
||||
key={filter}
|
||||
type="button"
|
||||
onClick={() => setCompletionReadinessFilter(filter)}
|
||||
className={cn(
|
||||
"border px-2 py-1 text-[11px] font-semibold leading-4",
|
||||
completionReadinessFilter === filter
|
||||
? "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]"
|
||||
: "border-[#d8d3c7] bg-white text-[#5f5b52] hover:border-[#d9b36f]"
|
||||
)}
|
||||
>
|
||||
{t(`staleCandidates.completionQueue.filters.${filter}` as never)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(["all", "P0", "P1", "P2"] as const).map((filter) => (
|
||||
<button
|
||||
key={filter}
|
||||
type="button"
|
||||
onClick={() => setCompletionPriorityFilter(filter)}
|
||||
className={cn(
|
||||
"border px-2 py-1 font-mono text-[11px] font-semibold leading-4",
|
||||
completionPriorityFilter === filter
|
||||
? "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]"
|
||||
: "border-[#d8d3c7] bg-white text-[#5f5b52] hover:border-[#9bc7a4]"
|
||||
)}
|
||||
>
|
||||
{filter === "all"
|
||||
? t("staleCandidates.completionQueue.filters.priorityAll")
|
||||
: filter}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={previewCompletionBatch}
|
||||
disabled={completionBatchPreviewAction.loading}
|
||||
className="inline-flex items-center gap-1.5 border border-[#d8d3c7] bg-white px-2 py-1 text-xs font-semibold text-[#2e2b26] hover:border-[#1f6feb] hover:bg-[#edf4ff] hover:text-[#0f4fa8] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<SearchCheck className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{completionBatchPreviewAction.loading
|
||||
? t("staleCandidates.completionQueue.batchPreview.previewing")
|
||||
: t("staleCandidates.completionQueue.batchPreview.preview")}
|
||||
</button>
|
||||
</div>
|
||||
{completionBatchPreviewAction.error ? (
|
||||
<p className="mb-2 border border-[#e2a29b] bg-[#fff0ef] px-2 py-1 text-[11px] leading-5 text-[#9f2f25]">
|
||||
{completionBatchPreviewAction.error}
|
||||
</p>
|
||||
) : null}
|
||||
{completionBatchPreview ? (
|
||||
<div className="mb-2 border border-[#1f6feb] bg-[#edf4ff] px-2 py-1.5 text-[11px] leading-5 text-[#0f4fa8]">
|
||||
<p className="font-semibold">
|
||||
{t("staleCandidates.completionQueue.batchPreview.summary", {
|
||||
candidates: completionBatchPreview.candidate_count,
|
||||
previewable: completionBatchPreview.previewable_count,
|
||||
blocked: completionBatchPreview.blocked_count,
|
||||
writesKm: String(completionBatchPreview.writes_km),
|
||||
writesAudit: String(completionBatchPreview.writes_governance_audit),
|
||||
batchWrites: String(completionBatchPreview.batch_writes_allowed),
|
||||
})}
|
||||
</p>
|
||||
<p className="mt-1 break-all font-mono text-[11px] text-[#5f5b52]">
|
||||
{t("staleCandidates.completionQueue.batchPreview.planFingerprint", {
|
||||
fingerprint: completionBatchPreview.dry_run_plan_fingerprint,
|
||||
})}
|
||||
</p>
|
||||
<p className="mt-1 text-[#5f5b52]">
|
||||
{t("staleCandidates.completionQueue.batchPreview.next", {
|
||||
action: completionBatchPreview.next_action,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{completionQueue === null ? (
|
||||
<p className="text-[11px] leading-5 text-[#8a5a08]">
|
||||
{t("staleCandidates.completionQueue.unavailable")}
|
||||
|
||||
Reference in New Issue
Block a user