diff --git a/apps/api/src/api/v1/ai_governance.py b/apps/api/src/api/v1/ai_governance.py index 158e734c8..de5628192 100644 --- a/apps/api/src/api/v1/ai_governance.py +++ b/apps/api/src/api/v1/ai_governance.py @@ -284,7 +284,25 @@ async def get_km_stale_candidates( project_id=project_id, limit=limit, ) - return await query_km_stale_candidates(project_id=project_id, limit=limit) + try: + return await query_km_stale_candidates(project_id=project_id, limit=limit) + except Exception as exc: + logger.warning( + "km_stale_candidates_readback_degraded", + project_id=project_id, + limit=limit, + error_type=type(exc).__name__, + ) + return KnowledgeStaleCandidatesResponse( + project_id=project_id, + total_stale=0, + returned=0, + threshold_days=0, + writes_on_read=False, + manual_review_required=True, + items=[], + generated_at=now_taipei(), + ) # ============================================================================= @@ -405,6 +423,38 @@ async def get_km_stale_owner_review_completion_queue( ) except KmStaleOwnerReviewError as exc: raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc + except Exception as exc: + logger.warning( + "km_stale_owner_review_completion_queue_readback_degraded", + 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, + error_type=type(exc).__name__, + ) + return KnowledgeStaleOwnerReviewCompletionQueueResponse( + project_id=project_id, + status_bucket=status_bucket, + priority_tiers=priority_tier or [], + recommended_completion_outcome=recommended_completion_outcome, + batch_governance_event_id=batch_governance_event_id, + can_preview=can_preview, + total=0, + returned=0, + pending_count=0, + ready_count=0, + blocked_count=0, + completed_count=0, + failed_count=0, + writes_on_read=False, + manual_review_required=True, + batch_writes_allowed=False, + items=[], + generated_at=now_taipei(), + ) # ============================================================================= diff --git a/apps/api/tests/test_ai_governance_endpoints.py b/apps/api/tests/test_ai_governance_endpoints.py index 2094b8ece..76e868dc0 100644 --- a/apps/api/tests/test_ai_governance_endpoints.py +++ b/apps/api/tests/test_ai_governance_endpoints.py @@ -742,6 +742,28 @@ class TestKmReviewDraftDedupe: "signoz", ] + def test_km_stale_candidates_endpoint_readback_exception_returns_empty_queue(self, client): + """stale candidates 背景讀取失敗時應 fail-soft,不讓 Work Items 收到 500.""" + with patch( + "src.api.v1.ai_governance.query_km_stale_candidates", + new=AsyncMock(side_effect=RuntimeError("temporary stale candidates failure")), + ): + r = client.get( + "/api/v1/ai/governance/km-stale-candidates" + "?project_id=awoooi&limit=20" + ) + + assert r.status_code == 200 + data = r.json() + assert data["schema_version"] == "km_stale_candidates_v1" + assert data["project_id"] == "awoooi" + assert data["total_stale"] == 0 + assert data["returned"] == 0 + assert data["writes_on_read"] is False + assert data["manual_review_required"] is True + assert data["items"] == [] + assert data["generated_at"] + def test_build_km_stale_candidate_prioritizes_linked_evidence(self): """有 Incident / PlayBook / Sentry / SigNoz 脈絡的 stale KM 應排前面。""" record = KnowledgeEntryRecord( @@ -1302,6 +1324,43 @@ class TestKmReviewDraftDedupe: assert data["items"][0]["can_preview"] is True assert data["items"][1]["writes_km_on_confirm"] is False + def test_owner_review_completion_queue_readback_exception_returns_empty_queue( + self, + client, + ): + """completion queue 背景讀取失敗時應 fail-soft,不讓 Work Items 收到 500.""" + with patch( + "src.api.v1.ai_governance.query_km_stale_owner_review_completion_queue", + new=AsyncMock(side_effect=RuntimeError("temporary completion queue failure")), + ): + r = client.get( + "/api/v1/ai/governance/km-stale-owner-review-completion-queue" + "?project_id=awoooi&status_bucket=all&priority_tier=P0" + "&recommended_completion_outcome=refresh_with_evidence" + "&can_preview=true&limit=30" + ) + + assert r.status_code == 200 + data = r.json() + assert data["schema_version"] == "km_stale_owner_review_completion_queue_v1" + assert data["project_id"] == "awoooi" + assert data["status_bucket"] == "all" + assert data["priority_tiers"] == ["P0"] + assert data["recommended_completion_outcome"] == "refresh_with_evidence" + assert data["can_preview"] is True + assert data["total"] == 0 + assert data["returned"] == 0 + assert data["pending_count"] == 0 + assert data["ready_count"] == 0 + assert data["blocked_count"] == 0 + assert data["completed_count"] == 0 + assert data["failed_count"] == 0 + assert data["writes_on_read"] is False + assert data["manual_review_required"] is True + assert data["batch_writes_allowed"] is False + assert data["items"] == [] + assert data["generated_at"] + def test_completion_queue_batch_preview_endpoint_is_dry_run_only(self, client): fake = KnowledgeStaleOwnerReviewCompletionBatchPreviewResponse( project_id="awoooi",