From 943093a49bd36fa27ff0f0236db702d4d756acf7 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 24 May 2026 20:47:31 +0800 Subject: [PATCH] feat(governance): batch queue stale km reviews --- apps/api/src/api/v1/ai_governance.py | 34 ++ apps/api/src/models/governance.py | 59 +++ .../governance_km_stale_review_service.py | 492 +++++++++++++++++- .../api/tests/test_ai_governance_endpoints.py | 162 ++++++ apps/web/messages/en.json | 30 ++ apps/web/messages/zh-TW.json | 30 ++ .../app/[locale]/awooop/work-items/page.tsx | 272 ++++++++++ 7 files changed, 1078 insertions(+), 1 deletion(-) diff --git a/apps/api/src/api/v1/ai_governance.py b/apps/api/src/api/v1/ai_governance.py index be3381f4..e1906a3d 100644 --- a/apps/api/src/api/v1/ai_governance.py +++ b/apps/api/src/api/v1/ai_governance.py @@ -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 # ============================================================================= diff --git a/apps/api/src/models/governance.py b/apps/api/src/models/governance.py index c00c6148..7cbb0b90 100644 --- a/apps/api/src/models/governance.py +++ b/apps/api/src/models/governance.py @@ -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, diff --git a/apps/api/src/services/governance_km_stale_review_service.py b/apps/api/src/services/governance_km_stale_review_service.py index 9e8eaae3..6f0d82d7 100644 --- a/apps/api/src/services/governance_km_stale_review_service.py +++ b/apps/api/src/services/governance_km_stale_review_service.py @@ -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, diff --git a/apps/api/tests/test_ai_governance_endpoints.py b/apps/api/tests/test_ai_governance_endpoints.py index c9da08c6..97ec013b 100644 --- a/apps/api/tests/test_ai_governance_endpoints.py +++ b/apps/api/tests/test_ai_governance_endpoints.py @@ -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", diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 76b97ebd..79ce6642 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -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", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 74bf24a7..6457792a 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -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", diff --git a/apps/web/src/app/[locale]/awooop/work-items/page.tsx b/apps/web/src/app/[locale]/awooop/work-items/page.tsx index ccdcc2c1..bef06f77 100644 --- a/apps/web/src/app/[locale]/awooop/work-items/page.tsx +++ b/apps/web/src/app/[locale]/awooop/work-items/page.tsx @@ -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>({}); const [staleReviewActions, setStaleReviewActions] = useState>({}); + const [staleBatchAction, setStaleBatchAction] = useState({ + previewLoading: false, + confirmLoading: false, + previewResult: null, + result: null, + error: null, + }); const [staleReviewCompletionActions, setStaleReviewCompletionActions] = useState>({}); 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( + `${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( + `${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 (
@@ -2557,6 +2720,115 @@ function KnowledgeGovernancePanel({
+
+
+
+

+ {t("staleCandidates.batchActions.title")} +

+

+ {t("staleCandidates.batchActions.subtitle")} +

+
+
+ + +
+
+ {staleBatchAction.error ? ( +

+ {staleBatchAction.error} +

+ ) : null} + {staleBatchPreview ? ( +
+

+ {t( + `staleCandidates.batchActions.statuses.${staleBatchPreviewStatusKey}` as never + )} +

+

+ {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), + })} +

+

+ {t("staleCandidates.batchActions.planFingerprint", { + fingerprint: staleBatchPreview.dry_run_plan_fingerprint ?? "--", + })} +

+ {staleBatchPreview.stale_ratio_snapshot ? ( +

+ {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), + })} +

+ ) : null} +
+ {staleBatchPreview.items.slice(0, 5).map((item) => ( + + {item.priority_tier}:{t( + `staleCandidates.batchActions.itemStatuses.${kmStaleBatchItemStatusKey(item.status)}` as never + )} + + ))} +
+
+ ) : null} + {staleBatchResult ? ( +
+

+ {t( + `staleCandidates.batchActions.statuses.${staleBatchResultStatusKey}` as never + )} +

+

+ {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, + })} +

+
+ ) : null} +
{staleCandidates === null ? (

{t("staleCandidates.unavailable")}