Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ workflows:
- develop
- security
- PM-3351
- PM-3926_ai-screening-phase

- "build-qa":
context: org-global
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
134 changes: 134 additions & 0 deletions src/common/challenge-helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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)) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
Consider adding validation to ensure challenge.phases is an array before proceeding. This will prevent potential runtime errors if challenge.phases is not initialized as an array.

return;
}

// Check if there are any AI reviewers
const hasAIReviewers = challenge.reviewers.some((reviewer) => !reviewer.isMemberReview && reviewer.aiWorkflowId);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ security]
The condition reviewer.aiWorkflowId assumes that aiWorkflowId is a valid identifier. Ensure that aiWorkflowId is validated or sanitized to prevent potential security issues.

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;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[💡 performance]
The use of moment for date manipulation is correct, but consider using native JavaScript date methods or a lighter library if possible, as moment is a large dependency and may impact performance.

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);
Expand Down
43 changes: 42 additions & 1 deletion src/common/phase-helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {};
Expand Down Expand Up @@ -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) =>

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ performance]
The use of _.some to find the submission phase name is potentially inefficient as it iterates over challengePhases multiple times. Consider using a single loop to find both the phase name and the phase itself to improve performance.

_.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;
Expand Down Expand Up @@ -159,6 +179,27 @@ class ChallengePhaseHelper {
}
return updatedPhase;
});

const aiScreeningPhase = _.find(updatedPhases, (phase) => phase.name === "AI Screening");

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[💡 maintainability]
The logic for updating the predecessor of phases that include 'review' in their name is case-insensitive, which is good. However, it might be more robust to explicitly list the review phases if they are known, to avoid accidental matches with other phases that might include 'review' in their name.

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)) {
Expand Down
11 changes: 11 additions & 0 deletions src/scripts/seed/Phase.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
20 changes: 20 additions & 0 deletions src/services/ChallengeService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading