diff --git a/.circleci/config.yml b/.circleci/config.yml index 3c0c8c6..d8c0ec4 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -92,6 +92,7 @@ workflows: - develop - security - PM-3351 + - PM-3926_ai-screening-phase - "build-qa": context: org-global diff --git a/prisma/migrations/20260306100000_add_ai_screening_phase/migration.sql b/prisma/migrations/20260306100000_add_ai_screening_phase/migration.sql new file mode 100644 index 0000000..29e70f3 --- /dev/null +++ b/prisma/migrations/20260306100000_add_ai_screening_phase/migration.sql @@ -0,0 +1,23 @@ +-- Insert AI Screening phase +INSERT INTO "Phase" ( + "id", + "name", + "description", + "isOpen", + "duration", + "createdAt", + "createdBy", + "updatedAt", + "updatedBy" +) VALUES ( + '9f4e3b2a-7c1d-4e9f-b8a6-5d3c1a9f2b4e', + 'AI Screening', + 'AI Screening Phase', + true, + 14400, + '2025-03-10T13:08:02.378Z', + 'topcoder user', + '2025-03-10T13:08:02.378Z', + 'topcoder user' +) +ON CONFLICT DO NOTHING; diff --git a/src/common/challenge-helper.js b/src/common/challenge-helper.js index fd3e196..a11d2c4 100644 --- a/src/common/challenge-helper.js +++ b/src/common/challenge-helper.js @@ -3,9 +3,11 @@ const challengeTrackService = require("../services/ChallengeTrackService"); const timelineTemplateService = require("../services/TimelineTemplateService"); const HttpStatus = require("http-status-codes"); const _ = require("lodash"); +const moment = require("moment"); const errors = require("./errors"); const config = require("config"); const helper = require("./helper"); +const phaseHelper = require("./phase-helper"); const axios = require("axios"); const { getM2MToken } = require("./m2m-helper"); const { hasAdminRole } = require("./role-helper"); @@ -509,6 +511,138 @@ class ChallengeHelper { } } + /** + * Add AI Screening phase for challenges with AI reviewers. + * The AI screening phase is positioned after submission and allocated 4 hours by default. + * + * @param {Object} challenge challenge payload (mutated in-place) + * @param {Object} prisma Prisma client + * @param {Function} logDebugMessage optional logging function + */ + async addAIScreeningPhaseForChallengeCreation(challenge, prisma, logDebugMessage = () => {}) { + if (!challenge || !challenge.phases || !Array.isArray(challenge.reviewers)) { + return; + } + + // Check if there are any AI reviewers + const hasAIReviewers = challenge.reviewers.some((reviewer) => !reviewer.isMemberReview && reviewer.aiWorkflowId); + + if (!hasAIReviewers) { + logDebugMessage("no AI reviewers found, skipping AI screening phase creation"); + return; + } + + // Check if AI Screening phase already exists + const aiScreeningPhaseExists = challenge.phases.some((phase) => phase.name === "AI Screening"); + if (aiScreeningPhaseExists) { + logDebugMessage("AI screening phase already exists, skipping creation"); + return; + } + + // Find the submission phase + const submissionPhaseName = SUBMISSION_PHASE_PRIORITY.find((name) => + challenge.phases.some((phase) => phase.name === name) + ); + + if (!submissionPhaseName) { + throw new errors.BadRequestError( + `Cannot add AI screening phase: no submission phase found in challenge` + ); + } + + // Get the AI Screening phase definition from the database + const { phaseDefinitionMap } = await phaseHelper.getPhaseDefinitionsAndMap(); + const aiScreeningPhaseDefEntry = Array.from(phaseDefinitionMap.entries()).find( + ([_, phase]) => phase.name === "AI Screening" + ); + + if (!aiScreeningPhaseDefEntry) { + throw new errors.BadRequestError( + `AI Screening phase definition not found in the system` + ); + } + + const [aiScreeningPhaseId, aiScreeningPhaseDef] = aiScreeningPhaseDefEntry; + + // Find the submission phase in the challenge phases + const submissionPhase = challenge.phases.find((phase) => phase.name === submissionPhaseName); + if (!submissionPhase) { + throw new errors.BadRequestError( + `Cannot add AI screening phase: submission phase not found in challenge phases` + ); + } + + const reviewPhases = challenge.phases.filter( + (phase) => + phase.name && + phase.name.toLowerCase().includes("review") && + phase.predecessor === submissionPhase.phaseId + ); + + // Create the AI Screening challenge phase + const aiScreeningPhase = { + phaseId: aiScreeningPhaseId, + name: "AI Screening", + description: aiScreeningPhaseDef.description, + duration: 14400, // 4 hours in seconds + isOpen: false, + predecessor: submissionPhase.phaseId, // predecessor is the submission phase's phaseId + constraints: [], + scheduledStartDate: undefined, + scheduledEndDate: undefined, + actualStartDate: undefined, + actualEndDate: undefined, + }; + + // Ensure AI Screening is ordered between submission and review phases in the phases array. + const firstReviewIndex = challenge.phases.indexOf(reviewPhases[0]); + logDebugMessage(`(firstReviewIndex=${firstReviewIndex})`) + if (firstReviewIndex >= 0) { + challenge.phases.splice(firstReviewIndex, 0, aiScreeningPhase); + } else { + challenge.phases.push(aiScreeningPhase); + } + + // Re-link review phase(s) so they start after AI Screening instead of submission. + reviewPhases.forEach((phase) => { + phase.predecessor = aiScreeningPhase.phaseId; + }); + + // Recalculate phase dates to keep timeline in sync + if (submissionPhase.scheduledEndDate) { + aiScreeningPhase.scheduledStartDate = submissionPhase.scheduledEndDate; + aiScreeningPhase.scheduledEndDate = moment(aiScreeningPhase.scheduledStartDate) + .add(aiScreeningPhase.duration, "seconds") + .toDate() + .toISOString(); + + logDebugMessage( + `AI screening phase dates calculated (start=${aiScreeningPhase.scheduledStartDate}, end=${aiScreeningPhase.scheduledEndDate})` + ); + + // Update dates for review phases that now depend on AI Screening + reviewPhases.forEach((phase) => { + if (_.isNil(phase.actualStartDate)) { + phase.scheduledStartDate = aiScreeningPhase.scheduledEndDate; + if (phase.duration) { + phase.scheduledEndDate = moment(phase.scheduledStartDate) + .add(phase.duration, "seconds") + .toDate() + .toISOString(); + + logDebugMessage( + `Updated ${phase.name} phase dates (start=${phase.scheduledStartDate}, end=${phase.scheduledEndDate})` + ); + } + } + }); + } + + logDebugMessage( + `AI screening phase added (phaseId=${aiScreeningPhase.phaseId}), updated ${reviewPhases.length} review predecessor(s)` + ); + } + async validateChallengeUpdateRequest(currentUser, challenge, data, challengeResources) { if (process.env.LOCAL != "true") { await helper.ensureUserCanModifyChallenge(currentUser, challenge, challengeResources); diff --git a/src/common/phase-helper.js b/src/common/phase-helper.js index 9dfcaed..0cb8c65 100644 --- a/src/common/phase-helper.js +++ b/src/common/phase-helper.js @@ -8,6 +8,8 @@ const errors = require("./errors"); const timelineTemplateService = require("../services/TimelineTemplateService"); const prisma = require("../common/prisma").getClient(); +const SUBMISSION_PHASE_PRIORITY = ["Topgear Submission", "Topcoder Submission", "Submission"]; + class ChallengePhaseHelper { phaseDefinitionMap = {}; timelineTemplateMap = {}; @@ -98,9 +100,27 @@ class ChallengePhaseHelper { // to incorrectly push earlier phases forward. Sorting by template order prevents that. const orderIndex = new Map(); _.each(timelineTempate, (tplPhase, idx) => orderIndex.set(tplPhase.phaseId, idx)); + const submissionPhaseName = SUBMISSION_PHASE_PRIORITY.find((name) => + _.some(challengePhases, (phase) => phase.name === name) + ); + const submissionPhase = submissionPhaseName + ? _.find(challengePhases, (phase) => phase.name === submissionPhaseName) + : null; + const submissionOrderIndex = _.isNil(submissionPhase) + ? null + : orderIndex.get(submissionPhase.phaseId); const challengePhasesOrdered = _.sortBy( challengePhases, - (p) => orderIndex.get(p.phaseId) ?? Number.MAX_SAFE_INTEGER + (p) => { + const templateOrder = orderIndex.get(p.phaseId); + if (!_.isNil(templateOrder)) { + return templateOrder; + } + if (p.name === "AI Screening" && !_.isNil(submissionOrderIndex)) { + return submissionOrderIndex + 0.5; + } + return Number.MAX_SAFE_INTEGER; + } ); let fixedStartDate = undefined; @@ -159,6 +179,27 @@ class ChallengePhaseHelper { } return updatedPhase; }); + + const aiScreeningPhase = _.find(updatedPhases, (phase) => phase.name === "AI Screening"); + const updateSubmissionPhaseName = SUBMISSION_PHASE_PRIORITY.find((name) => + _.some(updatedPhases, (phase) => phase.name === name) + ); + const updateSubmissionPhase = updateSubmissionPhaseName + ? _.find(updatedPhases, (phase) => phase.name === updateSubmissionPhaseName) + : null; + if (!_.isNil(aiScreeningPhase) && !_.isNil(updateSubmissionPhase)) { + aiScreeningPhase.predecessor = updateSubmissionPhase.phaseId; + _.each(updatedPhases, (phase) => { + if ( + phase.name && + phase.name.toLowerCase().includes("review") && + phase.predecessor === updateSubmissionPhase.phaseId + ) { + phase.predecessor = aiScreeningPhase.phaseId; + } + }); + } + let iterativeReviewSet = false; for (let phase of updatedPhases) { if (_.isNil(phase.predecessor)) { diff --git a/src/scripts/seed/Phase.json b/src/scripts/seed/Phase.json index b89745e..49404ed 100644 --- a/src/scripts/seed/Phase.json +++ b/src/scripts/seed/Phase.json @@ -207,5 +207,16 @@ "createdBy": "topcoder user", "updatedAt": "2025-03-10T13:08:02.378Z", "updatedBy": "topcoder user" + }, + { + "id": "9f4e3b2a-7c1d-4e9f-b8a6-5d3c1a9f2b4e", + "name": "AI Screening", + "description": "AI Screening Phase", + "isOpen": true, + "duration": 14400, + "createdAt": "2025-03-10T13:08:02.378Z", + "createdBy": "topcoder user", + "updatedAt": "2025-03-10T13:08:02.378Z", + "updatedBy": "topcoder user" } ] diff --git a/src/services/ChallengeService.js b/src/services/ChallengeService.js index ccabb0c..9ea4ca8 100644 --- a/src/services/ChallengeService.js +++ b/src/services/ChallengeService.js @@ -1793,6 +1793,26 @@ async function createChallenge(currentUser, challenge, userToken) { debugLog, ) : []; + // Add AI screening phase if AI reviewers are assigned + logger.debug( + `createChallenge: checking if AI screening phase needs to be added ${buildLogContext()}` + ); + await challengeHelper.addAIScreeningPhaseForChallengeCreation( + challenge, + prisma, + debugLog, + ); + + // Recalculate end date after potentially adding AI screening phase + if (challenge.phases && challenge.phases.length > 0) { + challenge.endDate = helper.calculateChallengeEndDate(challenge); + logger.debug( + `createChallenge: recalculated endDate after phase adjustment (endDate=${ + challenge.endDate + }) ${buildLogContext()}` + ); + } + const prismaModel = prismaHelper.convertChallengeSchemaToPrisma(currentUser, challenge); logger.info( `createChallenge: creating challenge record via prisma ${buildLogContext()} phaseCount=${_.get(