feat(governance): batch queue stale km reviews
All checks were successful
CD Pipeline / tests (push) Successful in 5m47s
Code Review / ai-code-review (push) Successful in 11s
Type Sync Check / check-type-sync (push) Successful in 27s
CD Pipeline / build-and-deploy (push) Successful in 4m13s
CD Pipeline / post-deploy-checks (push) Successful in 2m11s
All checks were successful
CD Pipeline / tests (push) Successful in 5m47s
Code Review / ai-code-review (push) Successful in 11s
Type Sync Check / check-type-sync (push) Successful in 27s
CD Pipeline / build-and-deploy (push) Successful in 4m13s
CD Pipeline / post-deploy-checks (push) Successful in 2m11s
This commit is contained in:
@@ -32,6 +32,8 @@ from src.models.governance import (
|
||||
KnowledgeReviewDraftArchiveResponse,
|
||||
KnowledgeReviewDraftDedupeResponse,
|
||||
KnowledgeStaleCandidatesResponse,
|
||||
KnowledgeStaleOwnerReviewBatchQueueRequest,
|
||||
KnowledgeStaleOwnerReviewBatchQueueResponse,
|
||||
KnowledgeStaleOwnerReviewCompleteRequest,
|
||||
KnowledgeStaleOwnerReviewCompleteResponse,
|
||||
KnowledgeStaleOwnerReviewRequest,
|
||||
@@ -43,6 +45,7 @@ from src.services.governance_km_review_service import (
|
||||
)
|
||||
from src.services.governance_km_stale_review_service import (
|
||||
KmStaleOwnerReviewError,
|
||||
batch_queue_km_stale_owner_reviews,
|
||||
complete_km_stale_owner_review,
|
||||
queue_km_stale_owner_review,
|
||||
)
|
||||
@@ -230,6 +233,37 @@ async def get_km_stale_candidates(
|
||||
return await query_km_stale_candidates(project_id=project_id, limit=limit)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# POST /api/v1/ai/governance/km-stale-candidates/batch-queue-review
|
||||
# =============================================================================
|
||||
|
||||
@router.post(
|
||||
"/ai/governance/km-stale-candidates/batch-queue-review",
|
||||
response_model=KnowledgeStaleOwnerReviewBatchQueueResponse,
|
||||
)
|
||||
async def post_km_stale_candidate_batch_queue_review(
|
||||
request: KnowledgeStaleOwnerReviewBatchQueueRequest,
|
||||
) -> KnowledgeStaleOwnerReviewBatchQueueResponse:
|
||||
"""
|
||||
將 P0/P1 stale KM 批次排入 owner review。
|
||||
|
||||
這個 endpoint 只建立 batch audit 與逐筆 owner-review dispatch,不改寫 KM。
|
||||
真正 refresh / archive / supersede 仍需單筆 dry-run fingerprint + owner approval。
|
||||
"""
|
||||
logger.info(
|
||||
"km_stale_candidate_batch_queue_review_request",
|
||||
project_id=request.project_id,
|
||||
priority_tiers=request.priority_tiers,
|
||||
limit=request.limit,
|
||||
owner=request.owner,
|
||||
dry_run=request.dry_run,
|
||||
)
|
||||
try:
|
||||
return await batch_queue_km_stale_owner_reviews(request=request)
|
||||
except KmStaleOwnerReviewError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# POST /api/v1/ai/governance/km-stale-candidates/{entry_id}/queue-review
|
||||
# =============================================================================
|
||||
|
||||
@@ -272,6 +272,65 @@ class KnowledgeStaleOwnerReviewResponse(BaseModel):
|
||||
generated_at: datetime
|
||||
|
||||
|
||||
class KnowledgeStaleOwnerReviewBatchQueueRequest(BaseModel):
|
||||
project_id: str = Field(default="awoooi", min_length=1, max_length=64)
|
||||
priority_tiers: list[Literal["P0", "P1", "P2"]] = Field(
|
||||
default_factory=lambda: ["P0", "P1"],
|
||||
min_length=1,
|
||||
max_length=3,
|
||||
)
|
||||
limit: int = Field(default=10, ge=1, le=50)
|
||||
owner: str = Field(default="operator_console", min_length=1, max_length=100)
|
||||
owner_note: str | None = Field(default=None, max_length=240)
|
||||
dry_run: bool = False
|
||||
dry_run_plan_fingerprint: str | None = Field(
|
||||
default=None,
|
||||
max_length=80,
|
||||
description="Dry-run response fingerprint that must be echoed before queueing a batch.",
|
||||
)
|
||||
|
||||
|
||||
class KnowledgeStaleOwnerReviewBatchItem(BaseModel):
|
||||
entry_id: str
|
||||
title: str
|
||||
priority_tier: Literal["P0", "P1", "P2"]
|
||||
recommended_action: Literal[
|
||||
"refresh_with_evidence",
|
||||
"owner_review",
|
||||
"archive_or_supersede",
|
||||
]
|
||||
status: Literal["would_queue", "queued", "already_queued", "skipped"]
|
||||
reason: str | None = None
|
||||
governance_event_id: str | None = None
|
||||
dispatch_id: str | None = None
|
||||
workflow_stage: str
|
||||
|
||||
|
||||
class KnowledgeStaleOwnerReviewBatchQueueResponse(BaseModel):
|
||||
schema_version: str = "km_stale_owner_review_batch_v1"
|
||||
project_id: str
|
||||
status: Literal["dry_run", "queued", "noop_already_queued"]
|
||||
owner: str
|
||||
owner_note: str | None = None
|
||||
dry_run: bool
|
||||
priority_tiers: list[str]
|
||||
requested_limit: int
|
||||
candidate_count: int
|
||||
queued_count: int
|
||||
already_queued_count: int
|
||||
skipped_count: int
|
||||
batch_governance_event_id: str | None = None
|
||||
batch_dispatch_id: str | None = None
|
||||
workflow_stage: str
|
||||
writes_km: bool = False
|
||||
writes_governance_audit: bool
|
||||
stale_ratio_snapshot: KnowledgeReviewDraftStaleRatioSnapshot | None = None
|
||||
dry_run_plan_fingerprint: str | None = None
|
||||
items: list[KnowledgeStaleOwnerReviewBatchItem] = Field(default_factory=list)
|
||||
next_action: str = "owner_review_stale_km_batch"
|
||||
generated_at: datetime
|
||||
|
||||
|
||||
class KnowledgeStaleOwnerReviewCompleteRequest(BaseModel):
|
||||
dispatch_id: str | None = Field(
|
||||
default=None,
|
||||
|
||||
@@ -29,6 +29,10 @@ from src.db.models import (
|
||||
)
|
||||
from src.models.governance import (
|
||||
KnowledgeReviewDraftStaleRatioSnapshot,
|
||||
KnowledgeStaleCandidate,
|
||||
KnowledgeStaleOwnerReviewBatchItem,
|
||||
KnowledgeStaleOwnerReviewBatchQueueRequest,
|
||||
KnowledgeStaleOwnerReviewBatchQueueResponse,
|
||||
KnowledgeStaleOwnerReviewCompleteRequest,
|
||||
KnowledgeStaleOwnerReviewCompleteResponse,
|
||||
KnowledgeStaleOwnerReviewRequest,
|
||||
@@ -36,14 +40,19 @@ from src.models.governance import (
|
||||
)
|
||||
from src.models.knowledge import EntryStatus
|
||||
from src.services.governance_agent import KM_STALE_DAYS, KM_STALE_RATIO
|
||||
from src.services.governance_query_service import _build_km_stale_candidate
|
||||
from src.services.governance_query_service import (
|
||||
_build_km_stale_candidate,
|
||||
query_km_stale_candidates,
|
||||
)
|
||||
from src.utils.timezone import now_taipei
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
_EXECUTOR_TYPE = "hermes_km_stale_owner_review"
|
||||
_BATCH_EXECUTOR_TYPE = "hermes_km_stale_owner_review_batch"
|
||||
_COMPLETE_EXECUTOR_TYPE = "hermes_km_stale_owner_review_complete"
|
||||
_RECHECK_EXECUTOR_TYPE = "hermes_km_stale_ratio_recheck"
|
||||
_ACTIVE_DISPATCH_STATUSES = frozenset({"pending", "dispatched", "executing"})
|
||||
|
||||
|
||||
class KmStaleOwnerReviewError(Exception):
|
||||
@@ -158,6 +167,487 @@ async def queue_km_stale_owner_review(
|
||||
)
|
||||
|
||||
|
||||
async def batch_queue_km_stale_owner_reviews(
|
||||
*,
|
||||
request: KnowledgeStaleOwnerReviewBatchQueueRequest,
|
||||
) -> KnowledgeStaleOwnerReviewBatchQueueResponse:
|
||||
"""Queue a bounded P0/P1 stale-KM batch for owner review without KM writes."""
|
||||
selected = await _select_batch_stale_candidates(request)
|
||||
items = _build_batch_plan_items(selected, dry_run=request.dry_run)
|
||||
snapshot = await _load_current_km_stale_ratio_snapshot()
|
||||
fingerprint = _build_batch_queue_plan_fingerprint(
|
||||
request=request,
|
||||
items=items,
|
||||
snapshot=snapshot,
|
||||
)
|
||||
queueable = [item for item in items if item.status == "would_queue"]
|
||||
|
||||
if request.dry_run:
|
||||
return _build_batch_queue_response(
|
||||
request=request,
|
||||
status="dry_run",
|
||||
items=items,
|
||||
stale_ratio_snapshot=snapshot,
|
||||
dry_run_plan_fingerprint=fingerprint,
|
||||
writes_governance_audit=False,
|
||||
)
|
||||
|
||||
if not request.dry_run_plan_fingerprint:
|
||||
raise KmStaleOwnerReviewError(
|
||||
403,
|
||||
"dry_run_plan_fingerprint from a dry-run preview is required before queueing a stale KM batch",
|
||||
)
|
||||
if request.dry_run_plan_fingerprint != fingerprint:
|
||||
raise KmStaleOwnerReviewError(
|
||||
409,
|
||||
"dry_run_plan_fingerprint does not match the latest stale KM batch queue plan",
|
||||
)
|
||||
if not queueable:
|
||||
return _build_batch_queue_response(
|
||||
request=request,
|
||||
status="noop_already_queued",
|
||||
items=items,
|
||||
stale_ratio_snapshot=snapshot,
|
||||
dry_run_plan_fingerprint=fingerprint,
|
||||
writes_governance_audit=False,
|
||||
)
|
||||
|
||||
write_result = await _write_batch_owner_review_dispatches(
|
||||
request=request,
|
||||
candidates=selected,
|
||||
plan_items=items,
|
||||
stale_ratio_snapshot=snapshot,
|
||||
plan_fingerprint=fingerprint,
|
||||
)
|
||||
return _build_batch_queue_response(
|
||||
request=request,
|
||||
status="queued",
|
||||
items=write_result["items"],
|
||||
stale_ratio_snapshot=snapshot,
|
||||
dry_run_plan_fingerprint=fingerprint,
|
||||
writes_governance_audit=True,
|
||||
batch_governance_event_id=write_result["batch_governance_event_id"],
|
||||
batch_dispatch_id=write_result["batch_dispatch_id"],
|
||||
)
|
||||
|
||||
|
||||
async def _select_batch_stale_candidates(
|
||||
request: KnowledgeStaleOwnerReviewBatchQueueRequest,
|
||||
) -> list[KnowledgeStaleCandidate]:
|
||||
"""Load a bounded priority batch from the read model instead of duplicating scoring logic."""
|
||||
fetch_limit = min(100, max(request.limit * 4, request.limit))
|
||||
response = await query_km_stale_candidates(
|
||||
project_id=request.project_id,
|
||||
limit=fetch_limit,
|
||||
)
|
||||
wanted_tiers = set(request.priority_tiers)
|
||||
selected: list[KnowledgeStaleCandidate] = []
|
||||
for candidate in response.items:
|
||||
if candidate.priority_tier not in wanted_tiers:
|
||||
continue
|
||||
selected.append(candidate)
|
||||
if len(selected) >= request.limit:
|
||||
break
|
||||
return selected
|
||||
|
||||
|
||||
def _build_batch_plan_items(
|
||||
candidates: list[KnowledgeStaleCandidate],
|
||||
*,
|
||||
dry_run: bool,
|
||||
) -> list[KnowledgeStaleOwnerReviewBatchItem]:
|
||||
items: list[KnowledgeStaleOwnerReviewBatchItem] = []
|
||||
for candidate in candidates:
|
||||
owner_status = str(candidate.owner_review_status or "")
|
||||
if owner_status in _ACTIVE_DISPATCH_STATUSES:
|
||||
status: Literal["would_queue", "queued", "already_queued", "skipped"] = "already_queued"
|
||||
reason = "active_owner_review_exists"
|
||||
workflow_stage = candidate.owner_review_stage or "waiting_owner_review"
|
||||
elif owner_status == "succeeded":
|
||||
status = "skipped"
|
||||
reason = "already_reviewed_or_completed"
|
||||
workflow_stage = candidate.owner_review_stage or "km_candidate_reviewed"
|
||||
else:
|
||||
status = "would_queue" if dry_run else "would_queue"
|
||||
reason = None
|
||||
workflow_stage = "waiting_owner_review"
|
||||
items.append(
|
||||
KnowledgeStaleOwnerReviewBatchItem(
|
||||
entry_id=candidate.entry_id,
|
||||
title=candidate.title,
|
||||
priority_tier=candidate.priority_tier,
|
||||
recommended_action=candidate.recommended_action,
|
||||
status=status,
|
||||
reason=reason,
|
||||
dispatch_id=candidate.owner_review_dispatch_id,
|
||||
workflow_stage=workflow_stage,
|
||||
)
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
def _build_batch_queue_plan_fingerprint(
|
||||
*,
|
||||
request: KnowledgeStaleOwnerReviewBatchQueueRequest,
|
||||
items: list[KnowledgeStaleOwnerReviewBatchItem],
|
||||
snapshot: KnowledgeReviewDraftStaleRatioSnapshot,
|
||||
) -> str:
|
||||
payload = {
|
||||
"schema_version": "km_stale_owner_review_batch_plan_v1",
|
||||
"project_id": request.project_id,
|
||||
"priority_tiers": list(dict.fromkeys(request.priority_tiers)),
|
||||
"limit": request.limit,
|
||||
"owner": request.owner,
|
||||
"owner_note_sha256": (
|
||||
hashlib.sha256(request.owner_note.encode("utf-8")).hexdigest()
|
||||
if request.owner_note
|
||||
else None
|
||||
),
|
||||
"stale_ratio_snapshot": snapshot.model_dump(),
|
||||
"items": [
|
||||
{
|
||||
"entry_id": item.entry_id,
|
||||
"priority_tier": item.priority_tier,
|
||||
"recommended_action": item.recommended_action,
|
||||
"status": item.status,
|
||||
"dispatch_id": item.dispatch_id,
|
||||
}
|
||||
for item in items
|
||||
],
|
||||
}
|
||||
encoded = json.dumps(
|
||||
payload,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
return "sha256:" + hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
async def _write_batch_owner_review_dispatches(
|
||||
*,
|
||||
request: KnowledgeStaleOwnerReviewBatchQueueRequest,
|
||||
candidates: list[KnowledgeStaleCandidate],
|
||||
plan_items: list[KnowledgeStaleOwnerReviewBatchItem],
|
||||
stale_ratio_snapshot: KnowledgeReviewDraftStaleRatioSnapshot,
|
||||
plan_fingerprint: str,
|
||||
) -> dict[str, Any]:
|
||||
now = taipei_now()
|
||||
batch_event_id = generate_uuid()
|
||||
batch_dispatch_id = generate_uuid()
|
||||
candidate_by_id = {candidate.entry_id: candidate for candidate in candidates}
|
||||
queued_ids = {item.entry_id for item in plan_items if item.status == "would_queue"}
|
||||
queued_item_ids: dict[str, tuple[str, str]] = {}
|
||||
|
||||
async with get_db_context() as db:
|
||||
batch_event = AiGovernanceEvent(
|
||||
id=batch_event_id,
|
||||
event_type="knowledge_degradation",
|
||||
triggered_at=now,
|
||||
details=_build_batch_owner_review_event_details(
|
||||
request=request,
|
||||
items=plan_items,
|
||||
stale_ratio_snapshot=stale_ratio_snapshot,
|
||||
),
|
||||
resolved=True,
|
||||
resolved_at=now,
|
||||
)
|
||||
batch_dispatch = GovernanceRemediationDispatch(
|
||||
id=batch_dispatch_id,
|
||||
governance_event_id=batch_event_id,
|
||||
event_type="knowledge_degradation",
|
||||
dispatch_status="succeeded",
|
||||
decision_context=_build_batch_owner_review_decision_context(
|
||||
batch_governance_event_id=batch_event_id,
|
||||
batch_dispatch_id=batch_dispatch_id,
|
||||
request=request,
|
||||
items=plan_items,
|
||||
stale_ratio_snapshot=stale_ratio_snapshot,
|
||||
plan_fingerprint=plan_fingerprint,
|
||||
),
|
||||
executor_type=_BATCH_EXECUTOR_TYPE,
|
||||
attempt_count=0,
|
||||
max_attempts=1,
|
||||
dispatched_at=now,
|
||||
started_at=now,
|
||||
completed_at=now,
|
||||
created_by=request.owner[:100],
|
||||
)
|
||||
db.add(batch_event)
|
||||
db.add(batch_dispatch)
|
||||
|
||||
for item in plan_items:
|
||||
if item.entry_id not in queued_ids:
|
||||
continue
|
||||
candidate = candidate_by_id[item.entry_id]
|
||||
event_id = generate_uuid()
|
||||
dispatch_id = generate_uuid()
|
||||
candidate_payload = candidate.model_dump(mode="json")
|
||||
event_details = _build_stale_owner_review_event_details(
|
||||
entry_id=item.entry_id,
|
||||
candidate=candidate_payload,
|
||||
owner=request.owner,
|
||||
owner_note=request.owner_note,
|
||||
)
|
||||
event_details["batch"] = {
|
||||
"batch_governance_event_id": batch_event_id,
|
||||
"batch_dispatch_id": batch_dispatch_id,
|
||||
"dry_run_plan_fingerprint": plan_fingerprint,
|
||||
}
|
||||
decision_context = _build_stale_owner_review_decision_context(
|
||||
governance_event_id=event_id,
|
||||
entry_id=item.entry_id,
|
||||
candidate=candidate_payload,
|
||||
owner=request.owner,
|
||||
owner_note=request.owner_note,
|
||||
)
|
||||
decision_context = _attach_batch_to_owner_review_context(
|
||||
decision_context,
|
||||
batch_governance_event_id=batch_event_id,
|
||||
batch_dispatch_id=batch_dispatch_id,
|
||||
plan_fingerprint=plan_fingerprint,
|
||||
)
|
||||
db.add(
|
||||
AiGovernanceEvent(
|
||||
id=event_id,
|
||||
event_type="knowledge_degradation",
|
||||
triggered_at=now,
|
||||
details=event_details,
|
||||
resolved=False,
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
GovernanceRemediationDispatch(
|
||||
id=dispatch_id,
|
||||
governance_event_id=event_id,
|
||||
event_type="knowledge_degradation",
|
||||
dispatch_status="pending",
|
||||
playbook_id=None,
|
||||
incident_id=None,
|
||||
approval_id=None,
|
||||
decision_context=decision_context,
|
||||
executor_type=_EXECUTOR_TYPE,
|
||||
attempt_count=0,
|
||||
max_attempts=1,
|
||||
dispatched_at=now,
|
||||
created_by=request.owner[:100],
|
||||
)
|
||||
)
|
||||
queued_item_ids[item.entry_id] = (event_id, dispatch_id)
|
||||
await db.flush()
|
||||
|
||||
updated_items: list[KnowledgeStaleOwnerReviewBatchItem] = []
|
||||
for item in plan_items:
|
||||
ids = queued_item_ids.get(item.entry_id)
|
||||
if ids is None:
|
||||
updated_items.append(item)
|
||||
continue
|
||||
event_id, dispatch_id = ids
|
||||
updated_items.append(
|
||||
item.model_copy(update={
|
||||
"status": "queued",
|
||||
"governance_event_id": event_id,
|
||||
"dispatch_id": dispatch_id,
|
||||
"workflow_stage": "waiting_owner_review",
|
||||
})
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"km_stale_owner_review_batch_queued",
|
||||
project_id=request.project_id,
|
||||
batch_governance_event_id=batch_event_id,
|
||||
batch_dispatch_id=batch_dispatch_id,
|
||||
queued_count=len(queued_item_ids),
|
||||
candidate_count=len(plan_items),
|
||||
)
|
||||
return {
|
||||
"batch_governance_event_id": batch_event_id,
|
||||
"batch_dispatch_id": batch_dispatch_id,
|
||||
"items": updated_items,
|
||||
}
|
||||
|
||||
|
||||
def _attach_batch_to_owner_review_context(
|
||||
context: dict[str, Any],
|
||||
*,
|
||||
batch_governance_event_id: str,
|
||||
batch_dispatch_id: str,
|
||||
plan_fingerprint: str,
|
||||
) -> dict[str, Any]:
|
||||
merged = dict(context)
|
||||
workflow = dict(merged.get("workflow") if isinstance(merged.get("workflow"), dict) else {})
|
||||
workflow.update({
|
||||
"batch_governance_event_id": batch_governance_event_id,
|
||||
"batch_dispatch_id": batch_dispatch_id,
|
||||
"batch_plan_fingerprint": plan_fingerprint,
|
||||
})
|
||||
merged["workflow"] = workflow
|
||||
merged["batch"] = {
|
||||
"batch_governance_event_id": batch_governance_event_id,
|
||||
"batch_dispatch_id": batch_dispatch_id,
|
||||
"dry_run_plan_fingerprint": plan_fingerprint,
|
||||
}
|
||||
return merged
|
||||
|
||||
|
||||
def _build_batch_owner_review_event_details(
|
||||
*,
|
||||
request: KnowledgeStaleOwnerReviewBatchQueueRequest,
|
||||
items: list[KnowledgeStaleOwnerReviewBatchItem],
|
||||
stale_ratio_snapshot: KnowledgeReviewDraftStaleRatioSnapshot,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": "km_stale_owner_review_batch_event_v1",
|
||||
"trigger_source": "stale_km_priority_batch_queue",
|
||||
"next_action": "owner_review_stale_km_batch",
|
||||
"impact": {
|
||||
"status": "batch_owner_review_queued",
|
||||
"project_id": request.project_id,
|
||||
"priority_tiers": list(dict.fromkeys(request.priority_tiers)),
|
||||
"requested_limit": request.limit,
|
||||
"candidate_count": len(items),
|
||||
"queued_count": _count_batch_items(items, "would_queue"),
|
||||
"already_queued_count": _count_batch_items(items, "already_queued"),
|
||||
"skipped_count": _count_batch_items(items, "skipped"),
|
||||
"stale_ratio": stale_ratio_snapshot.stale_ratio,
|
||||
"threshold": stale_ratio_snapshot.threshold,
|
||||
},
|
||||
"remediation": {
|
||||
"next_action": "owner_review_stale_km_batch",
|
||||
"items": [
|
||||
"review_p0_p1_stale_km_candidates",
|
||||
"complete_each_candidate_after_owner_approval",
|
||||
"run_stale_ratio_recheck_after_writeback",
|
||||
],
|
||||
},
|
||||
"ownership": _stale_owner_review_ownership(),
|
||||
"owner": request.owner,
|
||||
"owner_note": request.owner_note,
|
||||
"stale_ratio_snapshot": stale_ratio_snapshot.model_dump(),
|
||||
"items": [item.model_dump() for item in items],
|
||||
}
|
||||
|
||||
|
||||
def _build_batch_owner_review_decision_context(
|
||||
*,
|
||||
batch_governance_event_id: str,
|
||||
batch_dispatch_id: str,
|
||||
request: KnowledgeStaleOwnerReviewBatchQueueRequest,
|
||||
items: list[KnowledgeStaleOwnerReviewBatchItem],
|
||||
stale_ratio_snapshot: KnowledgeReviewDraftStaleRatioSnapshot,
|
||||
plan_fingerprint: str,
|
||||
) -> dict[str, Any]:
|
||||
queued_count = _count_batch_items(items, "would_queue")
|
||||
return {
|
||||
"schema_version": "km_stale_owner_review_batch_dispatch_v1",
|
||||
"version": "v1",
|
||||
"trigger_source": "stale_km_priority_batch_queue",
|
||||
"triggered_metric": "knowledge_degradation",
|
||||
"metric_value": stale_ratio_snapshot.stale_ratio,
|
||||
"threshold": stale_ratio_snapshot.threshold,
|
||||
"suggested_action": "owner_review_stale_km_batch",
|
||||
"next_action": "owner_review_stale_km_batch",
|
||||
"decision_path": "batch_owner_review_queued",
|
||||
"ownership": _stale_owner_review_ownership(),
|
||||
"workflow": {
|
||||
"work_item_id": (
|
||||
"governance:knowledge_degradation:"
|
||||
f"{batch_governance_event_id}:km_stale_owner_review_batch"
|
||||
),
|
||||
"work_kind": "km_stale_owner_review_batch",
|
||||
"current_stage": "batch_owner_review_queued",
|
||||
"project_id": request.project_id,
|
||||
"priority_tiers": list(dict.fromkeys(request.priority_tiers)),
|
||||
"requested_limit": request.limit,
|
||||
"batch_governance_event_id": batch_governance_event_id,
|
||||
"batch_dispatch_id": batch_dispatch_id,
|
||||
"steps": [
|
||||
"detected",
|
||||
"prioritized_stale_candidate",
|
||||
"batch_owner_review_queued",
|
||||
"waiting_owner_review",
|
||||
"owner_updates_or_archives_km",
|
||||
"stale_ratio_recheck",
|
||||
],
|
||||
"stage_by_dispatch_status": {
|
||||
"pending": "batch_owner_review_queued",
|
||||
"dispatched": "batch_owner_review_queued",
|
||||
"executing": "batch_owner_review_queued",
|
||||
"succeeded": "batch_owner_review_queued",
|
||||
"failed": "needs_manual_km_triage",
|
||||
"skipped": "needs_manual_km_triage",
|
||||
"cancelled": "cancelled",
|
||||
},
|
||||
"next_action": "owner_review_stale_km_batch",
|
||||
"needs_human_review": True,
|
||||
"writes_km_without_approval": False,
|
||||
"writes_km": False,
|
||||
"writes_governance_audit": True,
|
||||
"dry_run_plan_fingerprint": plan_fingerprint,
|
||||
"stale_ratio_snapshot": stale_ratio_snapshot.model_dump(),
|
||||
},
|
||||
"worker_result": {
|
||||
"status": "batch_owner_review_queued",
|
||||
"candidate_count": len(items),
|
||||
"queued_count": queued_count,
|
||||
"already_queued_count": _count_batch_items(items, "already_queued"),
|
||||
"skipped_count": _count_batch_items(items, "skipped"),
|
||||
"writes_km": False,
|
||||
},
|
||||
"owner": request.owner,
|
||||
"owner_note": request.owner_note,
|
||||
"items": [item.model_dump() for item in items],
|
||||
}
|
||||
|
||||
|
||||
def _build_batch_queue_response(
|
||||
*,
|
||||
request: KnowledgeStaleOwnerReviewBatchQueueRequest,
|
||||
status: Literal["dry_run", "queued", "noop_already_queued"],
|
||||
items: list[KnowledgeStaleOwnerReviewBatchItem],
|
||||
stale_ratio_snapshot: KnowledgeReviewDraftStaleRatioSnapshot,
|
||||
dry_run_plan_fingerprint: str,
|
||||
writes_governance_audit: bool,
|
||||
batch_governance_event_id: str | None = None,
|
||||
batch_dispatch_id: str | None = None,
|
||||
) -> KnowledgeStaleOwnerReviewBatchQueueResponse:
|
||||
queued_status = "would_queue" if status == "dry_run" else "queued"
|
||||
workflow_stage = {
|
||||
"dry_run": "batch_owner_review_previewed",
|
||||
"queued": "batch_owner_review_queued",
|
||||
"noop_already_queued": "batch_noop_already_queued",
|
||||
}[status]
|
||||
return KnowledgeStaleOwnerReviewBatchQueueResponse(
|
||||
project_id=request.project_id,
|
||||
status=status,
|
||||
owner=request.owner,
|
||||
owner_note=request.owner_note,
|
||||
dry_run=request.dry_run,
|
||||
priority_tiers=list(dict.fromkeys(request.priority_tiers)),
|
||||
requested_limit=request.limit,
|
||||
candidate_count=len(items),
|
||||
queued_count=_count_batch_items(items, queued_status),
|
||||
already_queued_count=_count_batch_items(items, "already_queued"),
|
||||
skipped_count=_count_batch_items(items, "skipped"),
|
||||
batch_governance_event_id=batch_governance_event_id,
|
||||
batch_dispatch_id=batch_dispatch_id,
|
||||
workflow_stage=workflow_stage,
|
||||
writes_km=False,
|
||||
writes_governance_audit=writes_governance_audit,
|
||||
stale_ratio_snapshot=stale_ratio_snapshot,
|
||||
dry_run_plan_fingerprint=dry_run_plan_fingerprint,
|
||||
items=items,
|
||||
generated_at=now_taipei(),
|
||||
)
|
||||
|
||||
|
||||
def _count_batch_items(
|
||||
items: list[KnowledgeStaleOwnerReviewBatchItem],
|
||||
status: str,
|
||||
) -> int:
|
||||
return sum(1 for item in items if item.status == status)
|
||||
|
||||
|
||||
async def complete_km_stale_owner_review(
|
||||
*,
|
||||
entry_id: str,
|
||||
|
||||
@@ -37,6 +37,9 @@ from src.models.governance import (
|
||||
KnowledgeReviewDraftStaleRatioSnapshot,
|
||||
KnowledgeStaleCandidate,
|
||||
KnowledgeStaleCandidatesResponse,
|
||||
KnowledgeStaleOwnerReviewBatchItem,
|
||||
KnowledgeStaleOwnerReviewBatchQueueRequest,
|
||||
KnowledgeStaleOwnerReviewBatchQueueResponse,
|
||||
KnowledgeStaleOwnerReviewCompleteRequest,
|
||||
KnowledgeStaleOwnerReviewCompleteResponse,
|
||||
KnowledgeStaleOwnerReviewRequest,
|
||||
@@ -53,6 +56,8 @@ from src.services.governance_km_review_service import (
|
||||
)
|
||||
from src.services.governance_km_stale_review_service import (
|
||||
KmStaleOwnerReviewError,
|
||||
_build_batch_owner_review_decision_context,
|
||||
_build_batch_queue_plan_fingerprint,
|
||||
_build_completion_plan_fingerprint,
|
||||
_build_owner_review_completion_audit_context,
|
||||
_build_stale_owner_review_decision_context,
|
||||
@@ -765,6 +770,163 @@ class TestKmReviewDraftDedupe:
|
||||
assert r.status_code == 409
|
||||
assert r.json()["detail"] == "KM entry is no longer past the stale threshold"
|
||||
|
||||
def test_batch_queue_stale_candidate_endpoint_returns_batch_dispatch(self, client):
|
||||
"""P0/P1 stale KM batch queue 應只寫治理 dispatch,不改寫 KM。"""
|
||||
fake = KnowledgeStaleOwnerReviewBatchQueueResponse(
|
||||
project_id="awoooi",
|
||||
status="queued",
|
||||
owner="operator_console",
|
||||
dry_run=False,
|
||||
priority_tiers=["P0", "P1"],
|
||||
requested_limit=10,
|
||||
candidate_count=2,
|
||||
queued_count=1,
|
||||
already_queued_count=1,
|
||||
skipped_count=0,
|
||||
batch_governance_event_id="event-batch-001",
|
||||
batch_dispatch_id="dispatch-batch-001",
|
||||
workflow_stage="batch_owner_review_queued",
|
||||
writes_km=False,
|
||||
writes_governance_audit=True,
|
||||
stale_ratio_snapshot=KnowledgeReviewDraftStaleRatioSnapshot(
|
||||
stale_count=119,
|
||||
total_count=200,
|
||||
stale_ratio=0.595,
|
||||
threshold=0.2,
|
||||
stale_days=7,
|
||||
),
|
||||
dry_run_plan_fingerprint="sha256:" + "b" * 64,
|
||||
items=[
|
||||
KnowledgeStaleOwnerReviewBatchItem(
|
||||
entry_id="km-001",
|
||||
title="Sentry checkout failure repair",
|
||||
priority_tier="P0",
|
||||
recommended_action="refresh_with_evidence",
|
||||
status="queued",
|
||||
governance_event_id="event-001",
|
||||
dispatch_id="dispatch-001",
|
||||
workflow_stage="waiting_owner_review",
|
||||
),
|
||||
KnowledgeStaleOwnerReviewBatchItem(
|
||||
entry_id="km-002",
|
||||
title="Already queued",
|
||||
priority_tier="P1",
|
||||
recommended_action="owner_review",
|
||||
status="already_queued",
|
||||
reason="active_owner_review_exists",
|
||||
dispatch_id="dispatch-002",
|
||||
workflow_stage="waiting_owner_review",
|
||||
),
|
||||
],
|
||||
generated_at=NOW,
|
||||
)
|
||||
captured: dict = {}
|
||||
|
||||
async def mock_batch_queue(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return fake
|
||||
|
||||
with patch(
|
||||
"src.api.v1.ai_governance.batch_queue_km_stale_owner_reviews",
|
||||
new=mock_batch_queue,
|
||||
):
|
||||
r = client.post(
|
||||
"/api/v1/ai/governance/km-stale-candidates/batch-queue-review",
|
||||
json={
|
||||
"project_id": "awoooi",
|
||||
"priority_tiers": ["P0", "P1"],
|
||||
"limit": 10,
|
||||
"owner": "operator_console",
|
||||
"dry_run": False,
|
||||
"dry_run_plan_fingerprint": "sha256:" + "b" * 64,
|
||||
},
|
||||
)
|
||||
|
||||
assert r.status_code == 200
|
||||
assert isinstance(captured["request"], KnowledgeStaleOwnerReviewBatchQueueRequest)
|
||||
data = r.json()
|
||||
assert data["schema_version"] == "km_stale_owner_review_batch_v1"
|
||||
assert data["status"] == "queued"
|
||||
assert data["workflow_stage"] == "batch_owner_review_queued"
|
||||
assert data["writes_km"] is False
|
||||
assert data["writes_governance_audit"] is True
|
||||
assert data["batch_dispatch_id"] == "dispatch-batch-001"
|
||||
assert data["items"][0]["status"] == "queued"
|
||||
assert data["items"][1]["status"] == "already_queued"
|
||||
|
||||
def test_batch_queue_stale_candidate_endpoint_maps_validation_error(self, client):
|
||||
async def mock_batch_queue(**kwargs):
|
||||
raise KmStaleOwnerReviewError(
|
||||
409,
|
||||
"dry_run_plan_fingerprint does not match the latest stale KM batch queue plan",
|
||||
)
|
||||
|
||||
with patch(
|
||||
"src.api.v1.ai_governance.batch_queue_km_stale_owner_reviews",
|
||||
new=mock_batch_queue,
|
||||
):
|
||||
r = client.post(
|
||||
"/api/v1/ai/governance/km-stale-candidates/batch-queue-review",
|
||||
json={
|
||||
"project_id": "awoooi",
|
||||
"limit": 10,
|
||||
"dry_run": False,
|
||||
"dry_run_plan_fingerprint": "sha256:" + "c" * 64,
|
||||
},
|
||||
)
|
||||
|
||||
assert r.status_code == 409
|
||||
assert "batch queue plan" in r.json()["detail"]
|
||||
|
||||
def test_stale_owner_review_batch_context_is_operator_visible(self):
|
||||
request = KnowledgeStaleOwnerReviewBatchQueueRequest(
|
||||
project_id="awoooi",
|
||||
priority_tiers=["P0", "P1"],
|
||||
limit=10,
|
||||
owner="operator_console",
|
||||
dry_run=False,
|
||||
)
|
||||
snapshot = KnowledgeReviewDraftStaleRatioSnapshot(
|
||||
stale_count=119,
|
||||
total_count=200,
|
||||
stale_ratio=0.595,
|
||||
threshold=0.2,
|
||||
stale_days=7,
|
||||
)
|
||||
items = [
|
||||
KnowledgeStaleOwnerReviewBatchItem(
|
||||
entry_id="km-001",
|
||||
title="Sentry checkout failure repair",
|
||||
priority_tier="P0",
|
||||
recommended_action="refresh_with_evidence",
|
||||
status="would_queue",
|
||||
workflow_stage="waiting_owner_review",
|
||||
)
|
||||
]
|
||||
fingerprint = _build_batch_queue_plan_fingerprint(
|
||||
request=request,
|
||||
items=items,
|
||||
snapshot=snapshot,
|
||||
)
|
||||
ctx = _build_batch_owner_review_decision_context(
|
||||
batch_governance_event_id="event-batch-001",
|
||||
batch_dispatch_id="dispatch-batch-001",
|
||||
request=request,
|
||||
items=items,
|
||||
stale_ratio_snapshot=snapshot,
|
||||
plan_fingerprint=fingerprint,
|
||||
)
|
||||
|
||||
assert fingerprint.startswith("sha256:")
|
||||
assert ctx["decision_path"] == "batch_owner_review_queued"
|
||||
assert ctx["workflow"]["work_kind"] == "km_stale_owner_review_batch"
|
||||
assert ctx["workflow"]["current_stage"] == "batch_owner_review_queued"
|
||||
assert ctx["workflow"]["writes_km"] is False
|
||||
assert ctx["workflow"]["writes_km_without_approval"] is False
|
||||
assert ctx["worker_result"]["status"] == "batch_owner_review_queued"
|
||||
assert ctx["worker_result"]["queued_count"] == 1
|
||||
assert ctx["ownership"]["lead_agent"] == "Hermes"
|
||||
|
||||
def test_stale_owner_review_context_is_operator_visible(self):
|
||||
candidate = {
|
||||
"entry_id": "km-001",
|
||||
|
||||
@@ -2099,6 +2099,33 @@
|
||||
"queued": "Queued for owner review",
|
||||
"already_queued": "Already in owner review"
|
||||
},
|
||||
"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.",
|
||||
"preview": "Dry-run batch",
|
||||
"previewing": "Dry-running",
|
||||
"confirm": "Queue batch",
|
||||
"confirming": "Queueing",
|
||||
"previewFailed": "Batch dry-run failed; refresh and verify that the stale candidates API is available.",
|
||||
"confirmFailed": "Batch queue failed; the backend may have detected changed candidates or dispatch state.",
|
||||
"missingPreviewFingerprint": "Missing batch dry-run plan fingerprint; run the dry-run again first.",
|
||||
"summary": "Candidates {candidates}; will queue {queued}; already in review {already}; skipped {skipped}; writes KM: {writesKm}; writes audit: {writesAudit}",
|
||||
"planFingerprint": "Batch plan fingerprint: {fingerprint}",
|
||||
"result": "Batch dispatch: {batch}; Event: {event}; queued {queued}; already in review {already}; skipped {skipped}",
|
||||
"statuses": {
|
||||
"dry_run": "Batch dry-run complete",
|
||||
"queued": "Batch queued for owner review",
|
||||
"noop_already_queued": "All candidates already queued or handled",
|
||||
"unknown": "Batch status pending"
|
||||
},
|
||||
"itemStatuses": {
|
||||
"would_queue": "Will queue",
|
||||
"queued": "Queued",
|
||||
"already_queued": "Already in review",
|
||||
"skipped": "Skipped",
|
||||
"unknown": "Pending"
|
||||
}
|
||||
},
|
||||
"completeActions": {
|
||||
"preview": "Dry-run complete",
|
||||
"previewing": "Previewing",
|
||||
@@ -2218,6 +2245,9 @@
|
||||
"ai_analyzed": "AI analyzed",
|
||||
"queued_kb_healthcheck": "Queued for KM healthcheck",
|
||||
"draft_km_updates": "Drafting KM updates",
|
||||
"batch_owner_review_previewed": "Batch owner review previewed",
|
||||
"batch_owner_review_queued": "Batch queued for owner review",
|
||||
"batch_noop_already_queued": "Batch does not need requeue",
|
||||
"waiting_owner_review": "Waiting owner review",
|
||||
"owner_updates_or_archives_km": "Owner updates or archives KM",
|
||||
"km_writeback_after_approval": "KM writeback after approval",
|
||||
|
||||
@@ -2100,6 +2100,33 @@
|
||||
"queued": "已排入 owner review",
|
||||
"already_queued": "已在 owner review"
|
||||
},
|
||||
"batchActions": {
|
||||
"title": "批次處理 P0 / P1 陳舊 KM",
|
||||
"subtitle": "先乾跑鎖定最新 P0 / P1 候選,再批次建立 owner-review dispatch;不會直接寫入 KM。",
|
||||
"preview": "乾跑批次",
|
||||
"previewing": "乾跑中",
|
||||
"confirm": "批次排入",
|
||||
"confirming": "排入中",
|
||||
"previewFailed": "批次乾跑失敗;請重新整理後確認 stale candidates API 是否可用。",
|
||||
"confirmFailed": "批次排入失敗;後端可能偵測到候選清單或 dispatch 狀態已變更。",
|
||||
"missingPreviewFingerprint": "缺少批次乾跑 plan fingerprint;請先重新執行乾跑。",
|
||||
"summary": "候選 {candidates};將排入 {queued};已在審核 {already};略過 {skipped};寫 KM:{writesKm};寫稽核:{writesAudit}",
|
||||
"planFingerprint": "Batch plan fingerprint:{fingerprint}",
|
||||
"result": "Batch dispatch:{batch};Event:{event};已排入 {queued};已在審核 {already};略過 {skipped}",
|
||||
"statuses": {
|
||||
"dry_run": "批次乾跑完成",
|
||||
"queued": "批次已排入 owner review",
|
||||
"noop_already_queued": "全部已在審核或已處理",
|
||||
"unknown": "批次狀態待確認"
|
||||
},
|
||||
"itemStatuses": {
|
||||
"would_queue": "將排入",
|
||||
"queued": "已排入",
|
||||
"already_queued": "已在審核",
|
||||
"skipped": "略過",
|
||||
"unknown": "待確認"
|
||||
}
|
||||
},
|
||||
"completeActions": {
|
||||
"preview": "乾跑完成",
|
||||
"previewing": "預覽中",
|
||||
@@ -2219,6 +2246,9 @@
|
||||
"ai_analyzed": "AI 已分析",
|
||||
"queued_kb_healthcheck": "已排入 KM healthcheck",
|
||||
"draft_km_updates": "產生 KM 更新草稿",
|
||||
"batch_owner_review_previewed": "批次 owner review 已乾跑",
|
||||
"batch_owner_review_queued": "批次已排入 owner review",
|
||||
"batch_noop_already_queued": "批次無需重複排入",
|
||||
"waiting_owner_review": "等待 owner 審核",
|
||||
"owner_updates_or_archives_km": "Owner 更新或封存 KM",
|
||||
"km_writeback_after_approval": "審核後寫回 KM",
|
||||
|
||||
@@ -438,6 +438,57 @@ type KnowledgeStaleOwnerReviewAction = {
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
type KnowledgeStaleOwnerReviewBatchItem = {
|
||||
entry_id: string;
|
||||
title: string;
|
||||
priority_tier: "P0" | "P1" | "P2";
|
||||
recommended_action: "refresh_with_evidence" | "owner_review" | "archive_or_supersede";
|
||||
status: "would_queue" | "queued" | "already_queued" | "skipped";
|
||||
reason?: string | null;
|
||||
governance_event_id?: string | null;
|
||||
dispatch_id?: string | null;
|
||||
workflow_stage: string;
|
||||
};
|
||||
|
||||
type KnowledgeStaleOwnerReviewBatchResponse = {
|
||||
schema_version?: string;
|
||||
project_id: string;
|
||||
status: "dry_run" | "queued" | "noop_already_queued";
|
||||
owner: string;
|
||||
owner_note?: string | null;
|
||||
dry_run: boolean;
|
||||
priority_tiers: string[];
|
||||
requested_limit: number;
|
||||
candidate_count: number;
|
||||
queued_count: number;
|
||||
already_queued_count: number;
|
||||
skipped_count: number;
|
||||
batch_governance_event_id?: string | null;
|
||||
batch_dispatch_id?: string | null;
|
||||
workflow_stage: string;
|
||||
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;
|
||||
dry_run_plan_fingerprint?: string | null;
|
||||
items: KnowledgeStaleOwnerReviewBatchItem[];
|
||||
next_action: string;
|
||||
generated_at?: string | null;
|
||||
};
|
||||
|
||||
type KnowledgeStaleOwnerReviewBatchAction = {
|
||||
previewLoading: boolean;
|
||||
confirmLoading: boolean;
|
||||
previewResult: KnowledgeStaleOwnerReviewBatchResponse | null;
|
||||
result: KnowledgeStaleOwnerReviewBatchResponse | null;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
type KnowledgeStaleOwnerReviewCompleteResponse = {
|
||||
schema_version?: string;
|
||||
entry_id: string;
|
||||
@@ -872,6 +923,9 @@ function governanceKmStageKey(stage?: string | null) {
|
||||
stage === "detected" ||
|
||||
stage === "ai_analyzed" ||
|
||||
stage === "draft_km_updates" ||
|
||||
stage === "batch_owner_review_previewed" ||
|
||||
stage === "batch_owner_review_queued" ||
|
||||
stage === "batch_noop_already_queued" ||
|
||||
stage === "waiting_owner_review" ||
|
||||
stage === "owner_updates_or_archives_km" ||
|
||||
stage === "km_writeback_after_approval" ||
|
||||
@@ -1152,6 +1206,29 @@ function kmStaleReviewStatusKey(value: string | null | undefined) {
|
||||
}
|
||||
}
|
||||
|
||||
function kmStaleBatchStatusKey(value: string | null | undefined) {
|
||||
switch (value) {
|
||||
case "dry_run":
|
||||
case "queued":
|
||||
case "noop_already_queued":
|
||||
return value;
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
function kmStaleBatchItemStatusKey(value: string | null | undefined) {
|
||||
switch (value) {
|
||||
case "would_queue":
|
||||
case "queued":
|
||||
case "already_queued":
|
||||
case "skipped":
|
||||
return value;
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
function kmStaleReviewCompleteStatusKey(value: string | null | undefined) {
|
||||
switch (value) {
|
||||
case "dry_run":
|
||||
@@ -2170,6 +2247,13 @@ function KnowledgeGovernancePanel({
|
||||
const t = useTranslations("awooop.workItems.knowledgeGovernance");
|
||||
const [archiveActions, setArchiveActions] = useState<Record<string, KnowledgeReviewDraftArchiveAction>>({});
|
||||
const [staleReviewActions, setStaleReviewActions] = useState<Record<string, KnowledgeStaleOwnerReviewAction>>({});
|
||||
const [staleBatchAction, setStaleBatchAction] = useState<KnowledgeStaleOwnerReviewBatchAction>({
|
||||
previewLoading: false,
|
||||
confirmLoading: false,
|
||||
previewResult: null,
|
||||
result: null,
|
||||
error: null,
|
||||
});
|
||||
const [staleReviewCompletionActions, setStaleReviewCompletionActions] =
|
||||
useState<Record<string, KnowledgeStaleOwnerReviewCompleteAction>>({});
|
||||
const items = queue?.items ?? [];
|
||||
@@ -2275,6 +2359,79 @@ function KnowledgeGovernancePanel({
|
||||
}
|
||||
}, [archiveActions, onArchived, t]);
|
||||
|
||||
const previewStaleBatchQueue = useCallback(async () => {
|
||||
setStaleBatchAction((current) => ({
|
||||
previewLoading: true,
|
||||
confirmLoading: false,
|
||||
previewResult: current.previewResult,
|
||||
result: null,
|
||||
error: null,
|
||||
}));
|
||||
const result = await postJson<KnowledgeStaleOwnerReviewBatchResponse>(
|
||||
`${API_BASE}/api/v1/ai/governance/km-stale-candidates/batch-queue-review`,
|
||||
{
|
||||
project_id: staleCandidates?.project_id ?? "awoooi",
|
||||
priority_tiers: ["P0", "P1"],
|
||||
limit: 10,
|
||||
owner: "operator_console",
|
||||
owner_note: "p0_p1_stale_km_batch",
|
||||
dry_run: true,
|
||||
},
|
||||
15000
|
||||
);
|
||||
setStaleBatchAction({
|
||||
previewLoading: false,
|
||||
confirmLoading: false,
|
||||
previewResult: result,
|
||||
result: null,
|
||||
error: result ? null : t("staleCandidates.batchActions.previewFailed"),
|
||||
});
|
||||
}, [staleCandidates?.project_id, t]);
|
||||
|
||||
const confirmStaleBatchQueue = useCallback(async () => {
|
||||
const fingerprint = staleBatchAction.previewResult?.dry_run_plan_fingerprint;
|
||||
if (!fingerprint) {
|
||||
setStaleBatchAction((current) => ({
|
||||
previewLoading: false,
|
||||
confirmLoading: false,
|
||||
previewResult: current.previewResult,
|
||||
result: current.result,
|
||||
error: t("staleCandidates.batchActions.missingPreviewFingerprint"),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
setStaleBatchAction((current) => ({
|
||||
previewLoading: false,
|
||||
confirmLoading: true,
|
||||
previewResult: current.previewResult,
|
||||
result: null,
|
||||
error: null,
|
||||
}));
|
||||
const result = await postJson<KnowledgeStaleOwnerReviewBatchResponse>(
|
||||
`${API_BASE}/api/v1/ai/governance/km-stale-candidates/batch-queue-review`,
|
||||
{
|
||||
project_id: staleCandidates?.project_id ?? "awoooi",
|
||||
priority_tiers: ["P0", "P1"],
|
||||
limit: 10,
|
||||
owner: "operator_console",
|
||||
owner_note: "p0_p1_stale_km_batch",
|
||||
dry_run: false,
|
||||
dry_run_plan_fingerprint: fingerprint,
|
||||
},
|
||||
15000
|
||||
);
|
||||
setStaleBatchAction((current) => ({
|
||||
previewLoading: false,
|
||||
confirmLoading: false,
|
||||
previewResult: current.previewResult,
|
||||
result,
|
||||
error: result ? null : t("staleCandidates.batchActions.confirmFailed"),
|
||||
}));
|
||||
if (result?.status === "queued" || result?.status === "noop_already_queued") {
|
||||
onArchived();
|
||||
}
|
||||
}, [onArchived, staleBatchAction.previewResult, staleCandidates?.project_id, t]);
|
||||
|
||||
const queueStaleOwnerReview = useCallback(async (candidate: KnowledgeStaleCandidate) => {
|
||||
setStaleReviewActions((current) => ({
|
||||
...current,
|
||||
@@ -2419,6 +2576,12 @@ function KnowledgeGovernancePanel({
|
||||
}
|
||||
}, [onArchived, staleReviewActions, staleReviewCompletionActions, 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);
|
||||
|
||||
return (
|
||||
<section className="border border-[#e0ddd4] bg-white">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[#e0ddd4] bg-[#faf9f3] px-4 py-3">
|
||||
@@ -2557,6 +2720,115 @@ function KnowledgeGovernancePanel({
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-3 border border-[#e0ddd4] bg-white px-3 py-2 text-xs text-[#5f5b52]">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="font-semibold text-[#141413]">
|
||||
{t("staleCandidates.batchActions.title")}
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] leading-5 text-[#77736a]">
|
||||
{t("staleCandidates.batchActions.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={previewStaleBatchQueue}
|
||||
disabled={staleBatchAction.previewLoading || staleBatchAction.confirmLoading}
|
||||
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-[#9bc7a4] hover:bg-[#f0faf2] hover:text-[#17602a] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<SearchCheck className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{staleBatchAction.previewLoading
|
||||
? t("staleCandidates.batchActions.previewing")
|
||||
: t("staleCandidates.batchActions.preview")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={confirmStaleBatchQueue}
|
||||
disabled={
|
||||
!staleBatchPreviewReady ||
|
||||
staleBatchAction.previewLoading ||
|
||||
staleBatchAction.confirmLoading
|
||||
}
|
||||
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-[#d9b36f] hover:bg-[#fff7e8] hover:text-[#8a5a08] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<ClipboardList className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{staleBatchAction.confirmLoading
|
||||
? t("staleCandidates.batchActions.confirming")
|
||||
: t("staleCandidates.batchActions.confirm")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{staleBatchAction.error ? (
|
||||
<p className="mt-2 border border-[#e2a29b] bg-[#fff0ef] px-2 py-1 text-[11px] leading-5 text-[#9f2f25]">
|
||||
{staleBatchAction.error}
|
||||
</p>
|
||||
) : null}
|
||||
{staleBatchPreview ? (
|
||||
<div className="mt-2 border border-[#d9b36f] bg-[#fff7e8] px-2 py-1.5 text-[11px] leading-5 text-[#8a5a08]">
|
||||
<p className="font-semibold">
|
||||
{t(
|
||||
`staleCandidates.batchActions.statuses.${staleBatchPreviewStatusKey}` as never
|
||||
)}
|
||||
</p>
|
||||
<p className="mt-1 text-[#5f5b52]">
|
||||
{t("staleCandidates.batchActions.summary", {
|
||||
candidates: staleBatchPreview.candidate_count,
|
||||
queued: staleBatchPreview.queued_count,
|
||||
already: staleBatchPreview.already_queued_count,
|
||||
skipped: staleBatchPreview.skipped_count,
|
||||
writesKm: String(staleBatchPreview.writes_km),
|
||||
writesAudit: String(staleBatchPreview.writes_governance_audit),
|
||||
})}
|
||||
</p>
|
||||
<p className="mt-1 break-all font-mono text-[11px] text-[#5f5b52]">
|
||||
{t("staleCandidates.batchActions.planFingerprint", {
|
||||
fingerprint: staleBatchPreview.dry_run_plan_fingerprint ?? "--",
|
||||
})}
|
||||
</p>
|
||||
{staleBatchPreview.stale_ratio_snapshot ? (
|
||||
<p className="mt-1 text-[#5f5b52]">
|
||||
{t("staleCandidates.completeActions.snapshot", {
|
||||
stale: staleBatchPreview.stale_ratio_snapshot.stale_count,
|
||||
total: staleBatchPreview.stale_ratio_snapshot.total_count,
|
||||
ratio: formatStaleRatio(staleBatchPreview.stale_ratio_snapshot.stale_ratio),
|
||||
threshold: formatStaleRatio(staleBatchPreview.stale_ratio_snapshot.threshold),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{staleBatchPreview.items.slice(0, 5).map((item) => (
|
||||
<span
|
||||
key={`${item.entry_id}-${item.status}`}
|
||||
className="border border-[#d8d3c7] bg-white px-2 py-0.5 font-mono text-[11px] leading-5 text-[#5f5b52]"
|
||||
>
|
||||
{item.priority_tier}:{t(
|
||||
`staleCandidates.batchActions.itemStatuses.${kmStaleBatchItemStatusKey(item.status)}` as never
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{staleBatchResult ? (
|
||||
<div className="mt-2 border border-[#9bc7a4] bg-[#f0faf2] px-2 py-1.5 text-[11px] leading-5 text-[#17602a]">
|
||||
<p className="font-semibold">
|
||||
{t(
|
||||
`staleCandidates.batchActions.statuses.${staleBatchResultStatusKey}` as never
|
||||
)}
|
||||
</p>
|
||||
<p className="mt-1 text-[#5f5b52]">
|
||||
{t("staleCandidates.batchActions.result", {
|
||||
batch: staleBatchResult.batch_dispatch_id ?? "--",
|
||||
event: staleBatchResult.batch_governance_event_id ?? "--",
|
||||
queued: staleBatchResult.queued_count,
|
||||
already: staleBatchResult.already_queued_count,
|
||||
skipped: staleBatchResult.skipped_count,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{staleCandidates === null ? (
|
||||
<p className="text-xs text-[#8a5a08]">
|
||||
{t("staleCandidates.unavailable")}
|
||||
|
||||
Reference in New Issue
Block a user