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"]:
|
||||
|
||||
Reference in New Issue
Block a user