diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentReview.tsx b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentReview.tsx index 46bbebb96..05bfa1572 100644 --- a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentReview.tsx +++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentReview.tsx @@ -42,6 +42,7 @@ import { SUBMITTER, } from '../../../config/index.config' import { + isAiFailedReviewSubmission, isContestReviewPhaseSubmission, shouldIncludeInReviewPhase, } from '../../utils/reviewPhaseGuards' @@ -172,10 +173,6 @@ const sortSubmissionsByReviewScoreDesc = ( return entries.map(entry => entry.submission) } -const isAiFailedReviewSubmission = (submission?: SubmissionInfo): boolean => ( - (submission?.status || '').toUpperCase() === 'AI_FAILED_REVIEW' -) - const mergeSubmissionsById = ( primary: SubmissionInfo[], additional: SubmissionInfo[], diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/iterativeReviewFiltering.ts b/src/apps/review/src/lib/components/ChallengeDetailsContent/iterativeReviewFiltering.ts index d6ee96bb6..261cf4acf 100644 --- a/src/apps/review/src/lib/components/ChallengeDetailsContent/iterativeReviewFiltering.ts +++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/iterativeReviewFiltering.ts @@ -4,7 +4,10 @@ import { BackendResource, SubmissionInfo, } from '../../models' -import { shouldIncludeInReviewPhase } from '../../utils/reviewPhaseGuards' +import { + isAiFailedReviewSubmission, + shouldIncludeInReviewPhase, +} from '../../utils/reviewPhaseGuards' interface FilterIterativeReviewRowsArgs { aiReviewDecisionsBySubmissionId?: Record @@ -28,10 +31,6 @@ interface LimitFirst2FinishIterativeRowsOptions { forceSingleRow?: boolean } -function isAiFailedReviewSubmission(submission: SubmissionInfo): boolean { - return (submission.status ?? '').toUpperCase() === 'AI_FAILED_REVIEW' -} - function isAiLockedByDecision( submission: SubmissionInfo, aiReviewDecisionsBySubmissionId?: Record, diff --git a/src/apps/review/src/lib/components/TableReview/TableReview.tsx b/src/apps/review/src/lib/components/TableReview/TableReview.tsx index a06faab00..40af73fe0 100644 --- a/src/apps/review/src/lib/components/TableReview/TableReview.tsx +++ b/src/apps/review/src/lib/components/TableReview/TableReview.tsx @@ -83,7 +83,10 @@ import { isSubmissionReviewerActionRow, resolveSubmissionReviewResult, } from '../common/reviewResult' -import { shouldIncludeInReviewPhase } from '../../utils/reviewPhaseGuards' +import { + isAiFailedReviewSubmission, + shouldIncludeInReviewPhase, +} from '../../utils/reviewPhaseGuards' import { CollapsibleAiReviewsRow } from '../CollapsibleAiReviewsRow' import { EscalationModals } from './EscalationModals' @@ -149,9 +152,11 @@ export const TableReview: FC = (props: TableReviewProps) => { const isTablet = useMemo(() => screenWidth <= 744, [screenWidth]) const reviewPhaseDatas = useMemo( - () => datas.filter(submission => shouldIncludeInReviewPhase( - submission, - challengeInfo?.phases, + () => datas.filter(submission => ( + // AI-locked submissions may carry no Review-phase review yet, but reviewers and + // copilots still need the row to escalate, verify, or unlock them. + isAiFailedReviewSubmission(submission) + || shouldIncludeInReviewPhase(submission, challengeInfo?.phases) )), [challengeInfo?.phases, datas], ) @@ -278,7 +283,7 @@ export const TableReview: FC = (props: TableReviewProps) => { return true } - return (submission.status ?? '').toUpperCase() === 'AI_FAILED_REVIEW' + return isAiFailedReviewSubmission(submission) }, ), [props.screeningOutcome.failingSubmissionIds], @@ -370,7 +375,7 @@ export const TableReview: FC = (props: TableReviewProps) => { submission: SubmissionReviewerRow, decision?: AiReviewEscalationDecision, ): boolean => { - if (submission.status !== 'AI_FAILED_REVIEW') { + if (!isAiFailedReviewSubmission(submission)) { return false } @@ -839,16 +844,15 @@ export const TableReview: FC = (props: TableReviewProps) => { ) } - appendAction(buildPrimaryAction(), 'primary') if (submission.isFirstReviewerRow) { + appendAction(buildPrimaryAction(), 'primary') appendAction(buildEscalateAction(), 'escalate') appendAction(buildVerifyAction(), 'verify') appendAction(buildUnlockAction(), 'unlock') appendAction(buildHistoryAction(), 'history') + appendAction(buildReopenAction(), 'reopen') } - appendAction(buildReopenAction(), 'reopen') - if (!actionEntries.length) { return ( diff --git a/src/apps/review/src/lib/utils/reviewPhaseGuards.spec.ts b/src/apps/review/src/lib/utils/reviewPhaseGuards.spec.ts index 6071363ec..fcb0983bf 100644 --- a/src/apps/review/src/lib/utils/reviewPhaseGuards.spec.ts +++ b/src/apps/review/src/lib/utils/reviewPhaseGuards.spec.ts @@ -1,6 +1,10 @@ import type { BackendPhase, SubmissionInfo } from '../models' -import { isContestReviewPhaseSubmission } from './reviewPhaseGuards' +import { + isAiFailedReviewSubmission, + isContestReviewPhaseSubmission, + shouldIncludeInReviewPhase, +} from './reviewPhaseGuards' const reviewPhase: BackendPhase = { constraints: [], @@ -93,3 +97,34 @@ describe('isContestReviewPhaseSubmission', () => { .toBe(false) }) }) + +describe('isAiFailedReviewSubmission', () => { + it('detects AI-locked submissions regardless of status casing', () => { + expect(isAiFailedReviewSubmission({ status: 'AI_FAILED_REVIEW' } as SubmissionInfo)) + .toBe(true) + expect(isAiFailedReviewSubmission({ status: 'ai_failed_review' } as SubmissionInfo)) + .toBe(true) + }) + + it('ignores other submission statuses', () => { + expect(isAiFailedReviewSubmission({ status: 'ACTIVE' } as SubmissionInfo)) + .toBe(false) + expect(isAiFailedReviewSubmission(undefined)) + .toBe(false) + }) + + it('keeps AI-failed submissions visible even when the phase guard excludes them', () => { + const aiFailedSubmission = { + id: 'submission-ai-failed', + memberId: '1001', + status: 'AI_FAILED_REVIEW', + type: 'Contest Submission', + } as SubmissionInfo + + // No review-phase hints, so the phase guard alone would drop the row. + expect(shouldIncludeInReviewPhase(aiFailedSubmission, [reviewPhase])) + .toBe(false) + expect(isAiFailedReviewSubmission(aiFailedSubmission)) + .toBe(true) + }) +}) diff --git a/src/apps/review/src/lib/utils/reviewPhaseGuards.ts b/src/apps/review/src/lib/utils/reviewPhaseGuards.ts index 0e93dcf14..e027dd3fc 100644 --- a/src/apps/review/src/lib/utils/reviewPhaseGuards.ts +++ b/src/apps/review/src/lib/utils/reviewPhaseGuards.ts @@ -159,6 +159,19 @@ export const isContestReviewPhaseSubmission = ( return normalizedCandidates.has(normalizeReviewPhaseKey(targetPhaseName)) } +/** + * Detects submissions the AI reviewer failed and locked. + * + * @param submission - Submission candidate. + * @returns True when the submission status marks an AI review failure. + * @throws This helper does not throw. + * Such submissions must stay visible on the Review tab so reviewers and copilots + * can escalate, verify, or unlock them even without a Review-phase review record. + */ +export const isAiFailedReviewSubmission = (submission?: SubmissionInfo): boolean => ( + (submission?.status ?? '').toUpperCase() === 'AI_FAILED_REVIEW' +) + export const shouldIncludeInReviewPhase = ( submission?: SubmissionInfo, phases?: BackendPhase[],