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

This commit is contained in:
Your Name
2026-05-24 23:15:03 +08:00
parent 1a4ac330b1
commit 4cfc6a4c79
7 changed files with 624 additions and 7 deletions

View File

@@ -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
# =============================================================================

View File

@@ -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,

View File

@@ -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"]: