diff --git a/docs/assets/api/schemas.json b/docs/assets/api/schemas.json index 3746375a7..594820029 100644 --- a/docs/assets/api/schemas.json +++ b/docs/assets/api/schemas.json @@ -13,26 +13,12 @@ ], "properties": { "minParticipantsPerCohort": { - "anyOf": [ - { - "type": "null" - }, - { - "minimum": 0, - "type": "integer" - } - ] + "minimum": 0, + "type": ["null", "integer"] }, "maxParticipantsPerCohort": { - "anyOf": [ - { - "type": "null" - }, - { - "minimum": 1, - "type": "integer" - } - ] + "minimum": 1, + "type": ["null", "integer"] }, "includeAllParticipantsInCohortCount": { "type": "boolean" @@ -674,7 +660,7 @@ "descriptions", "progress", "timeLimitInMinutes", - "requireFullTime", + "timeMinimumInMinutes", "discussions" ], "properties": { @@ -697,24 +683,12 @@ "$ref": "#/$defs/StageProgressConfig" }, "timeLimitInMinutes": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] + "minimum": 1, + "type": ["integer", "null"] }, - "requireFullTime": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ] + "timeMinimumInMinutes": { + "minimum": 1, + "type": ["integer", "null"] }, "discussions": { "type": "array", @@ -1308,27 +1282,13 @@ "type": "number" }, "rankingStageId": { - "anyOf": [ - { - "type": "null" - }, - { - "type": "string" - } - ] + "type": ["null", "string"] }, "questionMap": { "type": "object", "patternProperties": { "^(.*)$": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] + "type": ["number", "null"] } }, "title": "QuestionMap" @@ -1345,7 +1305,8 @@ "name", "descriptions", "progress", - "timeLimitInMinutes" + "timeLimitInMinutes", + "timeMinimumInMinutes" ], "properties": { "id": { @@ -1367,17 +1328,12 @@ "$ref": "#/$defs/StageProgressConfig" }, "timeLimitInMinutes": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] + "minimum": 1, + "type": ["integer", "null"] }, - "requireFullTime": { - "type": "boolean" + "timeMinimumInMinutes": { + "minimum": 1, + "type": ["integer", "null"] }, "isTurnBasedChat": { "type": "boolean" @@ -1386,14 +1342,7 @@ "type": "number" }, "maxNumberOfTurns": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] + "type": ["number", "null"] }, "preventCancellation": { "type": "boolean" @@ -1819,14 +1768,7 @@ "type": "integer" }, "maxParticipants": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ] + "type": ["integer", "null"] } }, "title": "Role" @@ -2101,17 +2043,7 @@ ] }, "value": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "number" - }, - { - "type": "boolean" - } - ] + "type": ["string", "number", "boolean"] } } }, @@ -2273,14 +2205,7 @@ } }, "correctAnswerId": { - "anyOf": [ - { - "type": "null" - }, - { - "type": "string" - } - ] + "type": ["null", "string"] }, "displayType": { "title": "MultipleChoiceDisplayType", @@ -2798,34 +2723,13 @@ "required": ["pronouns", "avatar", "name"], "properties": { "pronouns": { - "anyOf": [ - { - "type": "null" - }, - { - "type": "string" - } - ] + "type": ["null", "string"] }, "avatar": { - "anyOf": [ - { - "type": "null" - }, - { - "type": "string" - } - ] + "type": ["null", "string"] }, "name": { - "anyOf": [ - { - "type": "null" - }, - { - "type": "string" - } - ] + "type": ["null", "string"] } } }, @@ -3135,14 +3039,7 @@ ] }, "reasoningBudget": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ] + "type": ["integer", "null"] }, "includeReasoning": { "type": "boolean" @@ -3784,14 +3681,7 @@ ], "properties": { "wordsPerMinute": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] + "type": ["number", "null"] }, "minMessagesBeforeResponding": { "type": "integer" @@ -3800,14 +3690,7 @@ "type": "boolean" }, "maxResponses": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ] + "type": ["integer", "null"] }, "initialMessage": { "type": "string" diff --git a/frontend/src/components/chat/chat_info_panel.ts b/frontend/src/components/chat/chat_info_panel.ts index 62104de2f..a7d3b90a8 100644 --- a/frontend/src/components/chat/chat_info_panel.ts +++ b/frontend/src/components/chat/chat_info_panel.ts @@ -18,14 +18,11 @@ import { ChatStageConfig, ChatStagePublicData, MediatorProfile, - MediatorStatus, ParticipantProfile, convertUnifiedTimestampToTime, + getTimeElapsed, } from '@deliberation-lab/utils'; -import { - getChatStartTimestamp, - getChatTimeRemainingInSeconds, -} from '../../shared/stage.utils'; +import {getChatStartTimestamp} from '../../shared/stage.utils'; import {getHashBasedColor} from '../../shared/utils'; import {styles} from './chat_info_panel.scss'; @@ -57,12 +54,12 @@ export class ChatPanel extends MobxLitElement { return html`
- ${this.renderTimer(true)} ${this.renderParticipantList()} + ${this.renderTimer()} ${this.renderParticipantList()}
`; } - private renderTimer(showDivider = false) { + private renderTimer() { if (!this.stage) return nothing; const publicStageData = this.cohortService.stagePublicDataMap[ @@ -93,8 +90,21 @@ export class ChatPanel extends MobxLitElement { class=${`countdown ${publicStageData.discussionEndTimestamp ? 'ended' : ''}`} > ā±ļø Timer: ${this.stage.timeLimitInMinutes} minutes ${renderStatus()} - ${this.stage.requireFullTime && !publicStageData.discussionEndTimestamp - ? 'You must stay on this chat for the full time.' + ${this.stage.timeMinimumInMinutes && + !publicStageData.discussionEndTimestamp && + publicStageData.discussionStartTimestamp && + getTimeElapsed(publicStageData.discussionStartTimestamp, 'm') < + this.stage.timeMinimumInMinutes + ? (() => { + const remaining = Math.ceil( + this.stage.timeMinimumInMinutes - + getTimeElapsed( + publicStageData.discussionStartTimestamp!, + 'm', + ), + ); + return `You must stay in this chat for at least ${remaining} more minute${remaining !== 1 ? 's' : ''}.`; + })() : ''} ${this.topLayout ? nothing : html`
`} diff --git a/frontend/src/components/stages/group_chat_editor.ts b/frontend/src/components/stages/group_chat_editor.ts index 6db48cdf5..08c8b3752 100644 --- a/frontend/src/components/stages/group_chat_editor.ts +++ b/frontend/src/components/stages/group_chat_editor.ts @@ -70,38 +70,35 @@ export class ChatEditor extends MobxLitElement { } private renderTimeLimit() { - const timeLimit = this.stage?.timeLimitInMinutes ?? null; - const requireFullTime = this.stage?.requireFullTime ?? false; + const timeLimit = this.stage?.timeLimitInMinutes; const updateCheck = () => { - if (!this.stage) return; - if (this.stage.timeLimitInMinutes) { - this.experimentEditor.updateStage({ - ...this.stage, - timeLimitInMinutes: null, - }); - } else { - this.experimentEditor.updateStage({ - ...this.stage, - timeLimitInMinutes: 20, // Default to 20 if checked - }); - } + const isSet = this.stage?.timeLimitInMinutes != null; + this.experimentEditor.updateStage({ + ...this.stage!, + timeLimitInMinutes: isSet ? null : 20, + timeMinimumInMinutes: null, + }); }; - const updateNum = (e: InputEvent) => { - if (!this.stage) return; - const timeLimit = Number((e.target as HTMLTextAreaElement).value); + const updateMaxTime = (e: InputEvent) => { + const val = (e.target as HTMLInputElement).valueAsNumber; + const timeLimitInMinutes = val > 0 ? Math.floor(val) : null; this.experimentEditor.updateStage({ - ...this.stage, - timeLimitInMinutes: timeLimit, + ...this.stage!, + timeLimitInMinutes, }); }; - const updateRequireFullTime = (e: Event) => { - if (!this.stage) return; + const updateMinTime = (e: InputEvent) => { + const val = (e.target as HTMLInputElement).valueAsNumber; + const minTime = val > 0 ? Math.floor(val) : null; + const max = this.stage?.timeLimitInMinutes; + const timeMinimumInMinutes = + minTime != null && max != null ? Math.min(minTime, max) : minTime; this.experimentEditor.updateStage({ - ...this.stage, - requireFullTime: (e.target as HTMLInputElement).checked, + ...this.stage!, + timeMinimumInMinutes, }); }; @@ -110,39 +107,49 @@ export class ChatEditor extends MobxLitElement {
Disable conversation after a fixed amount of time
- ${timeLimit !== null + ${timeLimit != null ? html`
-
- + + - -
Require participants to stay until time elapses
+ @input=${updateMinTime} + />
` : nothing} diff --git a/frontend/src/components/stages/group_chat_participant_view.ts b/frontend/src/components/stages/group_chat_participant_view.ts index 0d975a4da..80c6289b3 100644 --- a/frontend/src/components/stages/group_chat_participant_view.ts +++ b/frontend/src/components/stages/group_chat_participant_view.ts @@ -26,6 +26,7 @@ import { ChatStageConfig, DiscussionItem, StageKind, + getTimeElapsed, } from '@deliberation-lab/utils'; import {styles} from './group_chat_participant_view.scss'; @@ -164,7 +165,7 @@ export class GroupChatView extends MobxLitElement { } const onClick = async () => { - if (!this.stage) return; + if (!this.stage || !this.isMinimumTimeMet) return; this.readyToEndDiscussionLoading = true; try { @@ -183,13 +184,16 @@ export class GroupChatView extends MobxLitElement { this.participantService.isReadyToEndChatDiscussion( this.stage.id, currentDiscussionId, - ); + ) || + !this.isMinimumTimeMet; return html` { if (!this.stage?.progress.showParticipantProgress) { @@ -306,6 +293,41 @@ export class GroupChatView extends MobxLitElement { `; } + + get isMinimumTimeMet() { + if (!this.stage || !this.stage.timeMinimumInMinutes) return true; + + const publicStageData = this.cohortService.stagePublicDataMap[ + this.stage.id + ] as ChatStagePublicData; + + if (!publicStageData?.discussionStartTimestamp) { + return false; + } + + return ( + getTimeElapsed(publicStageData.discussionStartTimestamp, 'm') >= + this.stage.timeMinimumInMinutes + ); + } + + get minutesRemainingUntilMinimum(): number { + if (!this.stage?.timeMinimumInMinutes) return 0; + + const publicStageData = this.cohortService.stagePublicDataMap[ + this.stage.id + ] as ChatStagePublicData; + + if (!publicStageData?.discussionStartTimestamp) { + return this.stage.timeMinimumInMinutes; + } + + const elapsed = getTimeElapsed( + publicStageData.discussionStartTimestamp, + 'm', + ); + return Math.ceil(this.stage.timeMinimumInMinutes - elapsed); + } } declare global { diff --git a/frontend/src/components/stages/private_chat_editor.ts b/frontend/src/components/stages/private_chat_editor.ts index 6d9557d34..7020d615c 100644 --- a/frontend/src/components/stages/private_chat_editor.ts +++ b/frontend/src/components/stages/private_chat_editor.ts @@ -41,38 +41,35 @@ export class ChatEditor extends MobxLitElement { } private renderTimeLimit() { - const timeLimit = this.stage?.timeLimitInMinutes ?? null; - const requireFullTime = this.stage?.requireFullTime ?? false; + const timeLimit = this.stage?.timeLimitInMinutes; const updateCheck = () => { - if (!this.stage) return; - if (this.stage.timeLimitInMinutes) { - this.experimentEditor.updateStage({ - ...this.stage, - timeLimitInMinutes: null, - }); - } else { - this.experimentEditor.updateStage({ - ...this.stage, - timeLimitInMinutes: 20, // Default to 20 if checked - }); - } + const isSet = this.stage?.timeLimitInMinutes != null; + this.experimentEditor.updateStage({ + ...this.stage!, + timeLimitInMinutes: isSet ? null : 20, + timeMinimumInMinutes: null, + }); }; - const updateNum = (e: InputEvent) => { - if (!this.stage) return; - const timeLimit = Number((e.target as HTMLTextAreaElement).value); + const updateMaxTime = (e: InputEvent) => { + const val = (e.target as HTMLInputElement).valueAsNumber; + const timeLimitInMinutes = val > 0 ? Math.floor(val) : null; this.experimentEditor.updateStage({ - ...this.stage, - timeLimitInMinutes: timeLimit, + ...this.stage!, + timeLimitInMinutes, }); }; - const updateRequireFullTime = (e: Event) => { - if (!this.stage) return; + const updateMinTime = (e: InputEvent) => { + const val = (e.target as HTMLInputElement).valueAsNumber; + const minTime = val > 0 ? Math.floor(val) : null; + const max = this.stage?.timeLimitInMinutes; + const timeMinimumInMinutes = + minTime != null && max != null ? Math.min(minTime, max) : minTime; this.experimentEditor.updateStage({ - ...this.stage, - requireFullTime: (e.target as HTMLInputElement).checked, + ...this.stage!, + timeMinimumInMinutes, }); }; @@ -81,39 +78,49 @@ export class ChatEditor extends MobxLitElement {
Disable conversation after a fixed amount of time
- ${timeLimit !== null + ${timeLimit != null ? html`
-
- + + - -
Require participants to stay until time elapses
+ @input=${updateMinTime} + />
` : nothing} @@ -183,12 +190,17 @@ export class ChatEditor extends MobxLitElement { private renderMinNumberOfTurns() { const minNumberOfTurns = this.stage?.minNumberOfTurns ?? 0; + const maxNumberOfTurns = this.stage?.maxNumberOfTurns ?? null; + const updateNum = (e: InputEvent) => { if (!this.stage) return; - const value = Number((e.target as HTMLInputElement).value); + let value = Math.max(0, Number((e.target as HTMLInputElement).value)); + if (maxNumberOfTurns !== null) { + value = Math.min(value, maxNumberOfTurns); + } this.experimentEditor.updateStage({ ...this.stage, - minNumberOfTurns: Math.max(0, value), + minNumberOfTurns: value, }); }; @@ -196,13 +208,15 @@ export class ChatEditor extends MobxLitElement {
{ if (!this.stage) return; const value = (e.target as HTMLInputElement).value; - // If empty string, set to null; otherwise parse as number (minimum 1) - this.experimentEditor.updateStage({ - ...this.stage, - maxNumberOfTurns: value === '' ? null : Math.max(1, Number(value)), - }); + if (value === '') { + this.experimentEditor.updateStage({ + ...this.stage, + maxNumberOfTurns: null, + }); + } else { + const num = Math.max(minNumberOfTurns, Math.max(1, Number(value))); + this.experimentEditor.updateStage({ + ...this.stage, + maxNumberOfTurns: num, + }); + } }; return html` diff --git a/frontend/src/components/stages/private_chat_participant_view.ts b/frontend/src/components/stages/private_chat_participant_view.ts index c415f7a76..d01a7073c 100644 --- a/frontend/src/components/stages/private_chat_participant_view.ts +++ b/frontend/src/components/stages/private_chat_participant_view.ts @@ -14,6 +14,7 @@ import { ChatMessage, PrivateChatStageConfig, UserType, + getTimeElapsed, } from '@deliberation-lab/utils'; import {getHashBasedColor} from '../../shared/utils'; import {ResponseTimeoutTracker} from '../../shared/response_timeout'; @@ -86,8 +87,29 @@ export class PrivateChatView extends MobxLitElement { participantMessageCount >= this.stage.maxNumberOfTurns && !isWaitingForResponse; - // Check if conversation has ended (max turns reached and not waiting for response) - const isConversationOver = maxTurnsReached; + const discussionStartTimestamp = + chatMessages.length > 0 ? chatMessages[0].timestamp : null; + const elapsedMinutes = discussionStartTimestamp + ? getTimeElapsed(discussionStartTimestamp, 'm') + : 0; + + const maxTimeReached = + this.stage.timeLimitInMinutes !== null && + this.stage.timeLimitInMinutes > 0 && + elapsedMinutes >= this.stage.timeLimitInMinutes; + + // Check if minimum number of turns met for progression + // For turn-based chats, only count completed turns (where agent has responded) + const minTurnsMet = this.stage.isTurnBasedChat + ? participantMessageCount >= this.stage.minNumberOfTurns && + !isWaitingForResponse + : participantMessageCount >= this.stage.minNumberOfTurns; + + // Check if conversation has ended + // Min turns takes precedence: conversation stays open until min turns is met, + // even if max time has elapsed. + const isConversationOver = + maxTurnsReached || (maxTimeReached && minTurnsMet); // Disable input if turn-taking is set and latest message // is from participant OR if conversation is over @@ -104,12 +126,14 @@ export class PrivateChatView extends MobxLitElement { return isWaitingForResponse; }; - // Check if minimum number of turns met for progression - // For turn-based chats, only count completed turns (where agent has responded) - const minTurnsMet = this.stage.isTurnBasedChat - ? participantMessageCount >= this.stage.minNumberOfTurns && - !isWaitingForResponse - : participantMessageCount >= this.stage.minNumberOfTurns; + // Check if minimum time is met + const minTimeMet = + this.stage.timeMinimumInMinutes == null || + this.stage.timeMinimumInMinutes <= 0 || + (discussionStartTimestamp !== null && + elapsedMinutes >= this.stage.timeMinimumInMinutes); + + const isNextDisabled = !minTurnsMet || !minTimeMet; return html` @@ -117,14 +141,22 @@ export class PrivateChatView extends MobxLitElement { ${isWaitingForResponse && !isConversationOver ? this.renderAgentIndicator(chatMessages) : nothing} - ${isConversationOver ? this.renderConversationEndedMessage() : nothing} + ${isConversationOver && minTimeMet + ? this.renderConversationEndedMessage() + : nothing} + ${isConversationOver && !minTimeMet + ? this.renderWaitingForMinTimeMessage(elapsedMinutes) + : nothing} - + ${this.stage.progress.showParticipantProgress ? html`` : nothing} ${!minTurnsMet && !isConversationOver - ? this.renderMinTurnsMessage(participantMessageCount) + ? this.renderMinTurnsMessage(participantMessageCount, maxTimeReached) + : nothing} + ${!minTimeMet && minTurnsMet + ? this.renderMinTimeMessage(elapsedMinutes) : nothing} `; @@ -188,13 +220,38 @@ export class PrivateChatView extends MobxLitElement { `; } - private renderMinTurnsMessage(currentCount: number) { + private renderWaitingForMinTimeMessage(elapsedMinutes: number) { + const remaining = Math.ceil( + (this.stage?.timeMinimumInMinutes ?? 0) - elapsedMinutes, + ); + return html` +
+ The conversation has ended. Please wait ${remaining} more + minute${remaining !== 1 ? 's' : ''} before proceeding. +
+ `; + } + + private renderMinTurnsMessage(currentCount: number, maxTimeReached: boolean) { const remaining = this.stage!.minNumberOfTurns - currentCount; if (remaining <= 0) return nothing; return html`
- Please send at least ${remaining} more - message${remaining === 1 ? '' : 's'} before proceeding. + ${maxTimeReached ? 'Time is up, but please' : 'Please'} send at least + ${remaining} more message${remaining === 1 ? '' : 's'} before + proceeding. +
+ `; + } + + private renderMinTimeMessage(elapsedMinutes: number) { + const remaining = Math.ceil( + (this.stage?.timeMinimumInMinutes ?? 0) - elapsedMinutes, + ); + return html` +
+ You must stay in this chat for at least ${remaining} more + minute${remaining !== 1 ? 's' : ''}.
`; } diff --git a/frontend/src/shared/templates/agent_participant_integration_template.ts b/frontend/src/shared/templates/agent_participant_integration_template.ts index 89aa3f259..a2f608621 100644 --- a/frontend/src/shared/templates/agent_participant_integration_template.ts +++ b/frontend/src/shared/templates/agent_participant_integration_template.ts @@ -254,7 +254,7 @@ const INT_GROUP_CHAT_STAGE = createChatStage({ minParticipants: 2, }), timeLimitInMinutes: 2, - requireFullTime: true, + timeMinimumInMinutes: 2, }); // **************************************************************************** diff --git a/frontend/src/shared/templates/charity_allocations.ts b/frontend/src/shared/templates/charity_allocations.ts index 0f5f2b332..22b3cf5e1 100644 --- a/frontend/src/shared/templates/charity_allocations.ts +++ b/frontend/src/shared/templates/charity_allocations.ts @@ -1,8 +1,6 @@ import { - createTextPromptItem, createChatStage, createDefaultMediatorGroupChatPrompt, - createDefaultStageContextPromptItem, AgentMediatorTemplate, MediatorPromptConfig, createAgentMediatorPersonaConfig, @@ -11,9 +9,6 @@ import { StructuredOutputSchema, createStructuredOutputConfig, createAgentChatSettings, - PromptItemType, - ProfileInfoPromptItem, - ProfileContextPromptItem, DEFAULT_AGENT_MODEL_SETTINGS, DEFAULT_EXPLANATION_FIELD, DEFAULT_READY_TO_END_FIELD, @@ -461,9 +456,6 @@ export function getCharityDebateTemplate( discussionStageId, `${EMOJIS[roundNum - 1]} Round ${roundNum}: Discussion`, setting, - { - persona: {id: mediatorAgentId, name: mediatorFriendlyName}, - } as AgentMediatorTemplate, ); } else { discussionStage = createAllocationDiscussionStage( @@ -554,7 +546,6 @@ function createDiscussionStageWithMediator( stageId: string, stageName: string, setting: string, - mediatorTemplate: AgentMediatorTemplate, ): StageConfig { const mediatorText = `\n\nšŸ¤– An AI-based facilitator will be present in this discussion.`; const discussionText = `Discuss the ideal allocation of ${setting}.${mediatorText}`; @@ -565,7 +556,7 @@ function createDiscussionStageWithMediator( descriptions: createStageTextConfig({primaryText: discussionText}), progress: createStageProgressConfig({waitForAllParticipants: true}), timeLimitInMinutes: 5, - requireFullTime: true, // Setting this to True causes the timeLimit to be a min AND maximum. + timeMinimumInMinutes: 5, }); } @@ -935,7 +926,7 @@ function createAllocationDiscussionStage( descriptions: createStageTextConfig({primaryText: discussionText}), progress: createStageProgressConfig({waitForAllParticipants: true}), timeLimitInMinutes: 5, - requireFullTime: true, + timeMinimumInMinutes: 5, }); } diff --git a/frontend/src/shared/templates/charity_allocations_ootb.ts b/frontend/src/shared/templates/charity_allocations_ootb.ts index d2c8a57a1..3778e6cfd 100644 --- a/frontend/src/shared/templates/charity_allocations_ootb.ts +++ b/frontend/src/shared/templates/charity_allocations_ootb.ts @@ -1,9 +1,7 @@ import { ApiKeyType, AgentModelSettings, - createTextPromptItem, createChatStage, - createDefaultStageContextPromptItem, AgentMediatorTemplate, MediatorPromptConfig, createAgentMediatorPersonaConfig, @@ -12,10 +10,6 @@ import { StructuredOutputSchema, createStructuredOutputConfig, createAgentChatSettings, - PromptItemType, - ProfileInfoPromptItem, - ProfileContextPromptItem, - DEFAULT_AGENT_MODEL_SETTINGS, DEFAULT_EXPLANATION_FIELD, DEFAULT_READY_TO_END_FIELD, DEFAULT_RESPONSE_FIELD, @@ -70,32 +64,6 @@ const GEMINI_MEDIATOR_ID = 'gemini-mediator-agent'; const CLAUDE_MEDIATOR_ID = 'claude-mediator-agent'; const OPENAI_MEDIATOR_ID = 'openai-mediator-agent'; -const FAILURE_MODE_ENUMS = [ - 'NoFailureModeDetected', - 'LowEffortOrLowEngagement', - 'OffTopicDrift', - 'UnevenParticipation', - 'NoJustificationOrPrematureConsensus', - 'BinaryStuck', - 'SelfContainedReasoningOnly', -]; - -const SOLUTION_STRATEGY_ENUMS = [ - 'NoSolutionNeeded', // No failure mode / still early - // LowEffortOrLowEngagement - 'InviteBriefReasoningOrValues', - // OffTopicDrift - 'GentlyRefocusOnAllocationTask', - // UnevenParticipation - 'InviteQuietVoiceOpenSpace', - // NoJustificationOrPrematureConsensus - 'CheckConsensusElicitOneReason', - // BinaryStuck - 'ExploreMiddleGroundOrSharedGoals', - // SelfContainedReasoningOnly - 'PromptEngagementWithOthers', -]; - export const OOTB_CHARITY_DEBATE_METADATA = createMetadataConfig({ name: 'Out-of-the-box Mediated Charity Debate (3 Rounds)', publicName: 'Charity Allocation Debate', @@ -566,7 +534,7 @@ function createDiscussionStageWithMediator( descriptions: createStageTextConfig({primaryText: discussionText}), progress: createStageProgressConfig({waitForAllParticipants: true}), timeLimitInMinutes: 5, - requireFullTime: true, // Setting this to True causes the timeLimit to be a min AND maximum. + timeMinimumInMinutes: 5, }); } @@ -936,7 +904,7 @@ function createAllocationDiscussionStage( descriptions: createStageTextConfig({primaryText: discussionText}), progress: createStageProgressConfig({waitForAllParticipants: true}), timeLimitInMinutes: 5, - requireFullTime: true, + timeMinimumInMinutes: 5, }); } diff --git a/functions/package.json b/functions/package.json index 954307909..8c2fd4a6c 100644 --- a/functions/package.json +++ b/functions/package.json @@ -11,9 +11,11 @@ "deploy": "firebase deploy --only functions", "logs": "firebase functions:log", "test": "npm run test:unit && npm run test:firestore", - "test:firestore": "firebase -c ../firebase-test.json emulators:exec --only firestore,functions --project=demo-deliberate-lab \"npx jest --runInBand $npm_package_config_firestore_tests\"", + "test:firestore": "GOOGLE_CLOUD_PROJECT=demo-deliberate-lab firebase -c ../firebase-test.json emulators:exec --only firestore,functions --project=demo-deliberate-lab \"npx jest --runInBand $npm_package_config_firestore_tests\"", "test:unit": "npx jest --testPathIgnorePatterns=$npm_package_config_firestore_tests", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "migrate:require-full-time": "npx tsx src/migrations/migrate-require-full-time.ts", + "migrate:require-full-time:apply": "npx tsx src/migrations/migrate-require-full-time.ts --apply" }, "engines": { "node": "22" diff --git a/functions/src/chat/chat.utils.ts b/functions/src/chat/chat.utils.ts index ca5128c0f..260f6cd0d 100644 --- a/functions/src/chat/chat.utils.ts +++ b/functions/src/chat/chat.utils.ts @@ -1,7 +1,12 @@ import { ChatMessage, + ChatStageConfig, + ChatStagePublicData, + ChatStageParticipantAnswer, createChatMessage, + createChatStageParticipantAnswer, createSystemChatMessage, + getTimeElapsed, } from '@deliberation-lab/utils'; import {Timestamp} from 'firebase-admin/firestore'; import {app} from '../app'; @@ -14,12 +19,6 @@ import { getFirestoreExperiment, getFirestoreParticipantRef, } from '../utils/firestore'; -import { - ChatStageConfig, - ChatStagePublicData, - ChatStageParticipantAnswer, - createChatStageParticipantAnswer, -} from '@deliberation-lab/utils'; import {updateParticipantNextStage} from '../participant.utils'; /** Used for private chats if model response fails. */ @@ -71,6 +70,35 @@ export async function updateParticipantReadyToEndChat( ); if (!publicStageData) return; + const minTime = (stage as ChatStageConfig).timeMinimumInMinutes; + if (minTime != null && minTime > 0) { + let startTimestamp = (publicStageData as ChatStagePublicData) + .discussionStartTimestamp; + + // For private chats, fall back to the first message timestamp + if (stage.kind === 'privateChat' && !startTimestamp) { + const results = await app + .firestore() + .collection('experiments') + .doc(experimentId) + .collection('participants') + .doc(participantId) + .collection('stageData') + .doc(stage.id) + .collection('privateChats') + .orderBy('timestamp', 'asc') + .limit(1) + .get(); + + if (!results.empty) { + startTimestamp = results.docs[0].data().timestamp as Timestamp; + } + } + + if (!startTimestamp) return; + if (getTimeElapsed(startTimestamp, 'm') < minTime) return; + } + const participantAnswerDoc = getFirestoreParticipantAnswerRef( experimentId, participantId, @@ -111,6 +139,7 @@ export async function updateParticipantReadyToEndChat( } else { // Otherwise, move to next stage const experiment = await getFirestoreExperiment(experimentId); + if (!experiment) return; await updateParticipantNextStage( experimentId, participant, diff --git a/functions/src/dl_api/experiments.dl_api.integration.test.ts b/functions/src/dl_api/experiments.dl_api.integration.test.ts index 13c2e9660..9873cfce6 100644 --- a/functions/src/dl_api/experiments.dl_api.integration.test.ts +++ b/functions/src/dl_api/experiments.dl_api.integration.test.ts @@ -15,6 +15,7 @@ import { ProlificConfig, Visibility, createExperimentConfig, + createPrivateChatStage, } from '@deliberation-lab/utils'; import { TestContext, @@ -895,4 +896,25 @@ describe('API Experiment Creation Integration Tests', () => { } }); }); + + // ========================================================================== + // Stage Validation Tests + // ========================================================================== + + describe('Stage validation', () => { + it('should reject experiment creation with invalid stage config (minTurns > maxTurns)', async () => { + const invalidStage = createPrivateChatStage({ + name: 'Invalid chat', + minNumberOfTurns: 10, + maxNumberOfTurns: 3, + }); + + const response = await apiRequest('POST', '/v1/experiments', { + name: 'Invalid experiment', + stages: JSON.parse(JSON.stringify([invalidStage])), + }); + + expect(response.ok).toBe(false); + }); + }); }); diff --git a/functions/src/experiment.utils.ts b/functions/src/experiment.utils.ts index 955396264..1f99eb438 100644 --- a/functions/src/experiment.utils.ts +++ b/functions/src/experiment.utils.ts @@ -22,6 +22,7 @@ import {getExperimentDownload} from './data'; import {generateVariablesForScope} from './variables.utils'; import {AuthGuard} from './utils/auth-guard'; import {createCohortFromDefinition} from './cohort.utils'; +import {validateStages} from './utils/validation'; /** * Create cohorts from cohort definitions and update the definitions with generated IDs. @@ -80,6 +81,12 @@ export async function writeExperimentFromTemplate( ): Promise { const {collectionName = 'experiments'} = options; + // Validate stage configs (schema + cross-field business rules) + const stageValidation = validateStages(template.stageConfigs); + if (!stageValidation.valid) { + throw new Error(`Invalid stage configuration: ${stageValidation.error}`); + } + // Set up experiment config with stageIds const experimentConfig = createExperimentConfig( template.stageConfigs, @@ -298,7 +305,11 @@ export interface UpdateExperimentOptions { */ export interface UpdateExperimentResult { success: boolean; - error?: 'not-found' | 'not-owner' | 'cohort-definitions-locked'; + error?: + | 'not-found' + | 'not-owner' + | 'cohort-definitions-locked' + | 'invalid-stages'; } /** @@ -322,6 +333,12 @@ export async function updateExperimentFromTemplate( ): Promise { const {collectionName = 'experiments'} = options; + // Validate stage configs (schema + cross-field business rules) + const stageValidation = validateStages(template.stageConfigs); + if (!stageValidation.valid) { + return {success: false, error: 'invalid-stages'}; + } + // Set up experiment config with stageIds const experimentConfig = createExperimentConfig( template.stageConfigs, diff --git a/functions/src/migrations/migrate-require-full-time.ts b/functions/src/migrations/migrate-require-full-time.ts new file mode 100644 index 000000000..31d5a0798 --- /dev/null +++ b/functions/src/migrations/migrate-require-full-time.ts @@ -0,0 +1,199 @@ +/** + * One-time migration script to convert requireFullTime to timeMinimumInMinutes. + * + * For any stage with requireFullTime: true, this sets + * timeMinimumInMinutes = timeLimitInMinutes (making the minimum equal to the + * maximum, which is the equivalent behavior). + * + * Usage: + * cd functions + * npm run migrate:require-full-time # Preview changes (dry run) + * npm run migrate:require-full-time:apply # Apply changes + * + * Options: + * --apply Apply changes (default is dry run) + */ + +import * as admin from 'firebase-admin'; +import * as readline from 'readline'; + +if (!admin.apps.length) { + admin.initializeApp({ + projectId: process.env.GCLOUD_PROJECT || 'deliberate-lab', + }); +} + +const db = admin.firestore(); + +interface StageMigration { + stageId: string; + stageName: string; + stageKind: string; + timeLimitInMinutes: number | null; + error?: string; +} + +interface ExperimentMigration { + experimentId: string; + experimentName: string; + dateCreated: string; + stages: StageMigration[]; +} + +async function migrateStages(dryRun: boolean): Promise { + console.log(`\n${'='.repeat(60)}`); + console.log(`requireFullTime → timeMinimumInMinutes Migration`); + console.log( + `Mode: ${dryRun ? 'DRY RUN (no changes will be written)' : 'LIVE'}`, + ); + console.log(`${'='.repeat(60)}\n`); + + const experiments: ExperimentMigration[] = []; + + const experimentsSnapshot = await db.collection('experiments').get(); + console.log(`Found ${experimentsSnapshot.size} experiments to check.\n`); + + const sortedDocs = [...experimentsSnapshot.docs].sort((a, b) => { + const aTime = a.data()?.metadata?.dateCreated?.seconds ?? 0; + const bTime = b.data()?.metadata?.dateCreated?.seconds ?? 0; + return aTime - bTime; + }); + + for (const experimentDoc of sortedDocs) { + const experimentId = experimentDoc.id; + const experimentData = experimentDoc.data(); + const experimentName = experimentData?.metadata?.name || 'Unnamed'; + const dateCreated = experimentData?.metadata?.dateCreated?.seconds + ? new Date(experimentData.metadata.dateCreated.seconds * 1000) + .toISOString() + .split('T')[0] + : 'unknown'; + const stagesSnapshot = await db + .collection('experiments') + .doc(experimentId) + .collection('stages') + .get(); + + const stageDocs = stagesSnapshot.docs.filter( + (doc) => doc.data().requireFullTime, + ); + if (stageDocs.length === 0) continue; + + console.log( + `\n[${dateCreated}] [${experimentId}] ${experimentName} — ${stageDocs.length} stage(s):`, + ); + + const stages: StageMigration[] = []; + for (const stageDoc of stageDocs) { + const stage = stageDoc.data(); + const timeMinimumInMinutes = stage.timeLimitInMinutes ?? null; + + const result: StageMigration = { + stageId: stageDoc.id, + stageName: stage.name || 'Unnamed', + stageKind: stage.kind || 'unknown', + timeLimitInMinutes: stage.timeLimitInMinutes ?? null, + }; + + try { + console.log( + ` Stage "${stage.name}" (${stage.kind}): ` + + `requireFullTime: true → timeMinimumInMinutes: ${timeMinimumInMinutes}`, + ); + + if (!dryRun) { + await stageDoc.ref.update({ + timeMinimumInMinutes, + requireFullTime: admin.firestore.FieldValue.delete(), + }); + console.log(` Updated.`); + } else { + console.log(` [DRY RUN] Would update.`); + } + } catch (error) { + result.error = error instanceof Error ? error.message : String(error); + console.error(` Error: ${result.error}`); + } + + stages.push(result); + } + + experiments.push({experimentId, experimentName, dateCreated, stages}); + } + + return experiments; +} + +function confirm(prompt: string): Promise { + return new Promise((resolve) => { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + rl.question(prompt, (answer: string) => { + rl.close(); + resolve(['y', 'yes'].includes(answer.trim().toLowerCase())); + }); + }); +} + +async function main() { + const dryRun = !process.argv.includes('--apply'); + const projectId = process.env.GCLOUD_PROJECT || 'deliberate-lab'; + + console.log(`\nProject: ${projectId}`); + console.log( + `Mode: ${dryRun ? 'DRY RUN' : 'LIVE (will write to Firestore)'}\n`, + ); + + const confirmed = await confirm( + `Proceed with ${dryRun ? 'dry run' : 'LIVE migration'}? (y/N): `, + ); + if (!confirmed) { + console.log('Aborted.'); + process.exit(0); + } + + const experiments = await migrateStages(dryRun); + + console.log(`\n${'='.repeat(60)}`); + console.log(`Summary`); + console.log(`${'='.repeat(60)}`); + + const totalStages = experiments.reduce((sum, e) => sum + e.stages.length, 0); + const totalErrors = experiments.reduce( + (sum, e) => sum + e.stages.filter((s) => s.error).length, + 0, + ); + + console.log(`Experiments affected: ${experiments.length}`); + console.log(`Stages migrated: ${totalStages}`); + + if (experiments.length > 0) { + console.log(''); + for (const exp of experiments) { + const errors = exp.stages.filter((s) => s.error); + const status = errors.length > 0 ? `${errors.length} error(s)` : 'OK'; + console.log( + ` [${exp.dateCreated}] [${exp.experimentId}] ${exp.experimentName}: ${exp.stages.length} stage(s) — ${status}`, + ); + } + } + + if (totalErrors > 0) { + console.log(`\nErrors: ${totalErrors}`); + for (const exp of experiments) { + for (const s of exp.stages.filter((s) => s.error)) { + console.log(` "${exp.experimentName}" → "${s.stageName}": ${s.error}`); + } + } + } + + if (dryRun && totalStages > 0) { + console.log(`\nRun with --apply to apply changes.`); + } + + process.exit(totalErrors > 0 ? 1 : 0); +} + +main(); diff --git a/functions/src/stages/chat.endpoints.ts b/functions/src/stages/chat.endpoints.ts index ea59aca44..2d7660033 100644 --- a/functions/src/stages/chat.endpoints.ts +++ b/functions/src/stages/chat.endpoints.ts @@ -1,13 +1,19 @@ import {Value} from '@sinclair/typebox/value'; import { + ChatStageConfig, + ChatStagePublicData, StageKind, UpdateChatStageParticipantAnswerData, + getTimeElapsed, } from '@deliberation-lab/utils'; import {Timestamp} from 'firebase-admin/firestore'; import {onCall, HttpsError} from 'firebase-functions/v2/https'; import {app} from '../app'; -import {getFirestoreStage} from '../utils/firestore'; +import { + getFirestoreStage, + getFirestoreStagePublicData, +} from '../utils/firestore'; import { checkConfigDataUnionOnPath, isUnionError, @@ -114,6 +120,28 @@ export const updateChatStageParticipantAnswer = onCall(async (request) => { handleUpdateChatStageParticipantAnswerValidationErrors(data); } + // Check minimum time enforcement + const stageId = data.chatStageParticipantAnswer.id; + const stage = await getFirestoreStage(data.experimentId, stageId); + if (stage) { + const minTime = (stage as ChatStageConfig).timeMinimumInMinutes; + if (minTime != null && minTime > 0) { + const publicStageData = await getFirestoreStagePublicData( + data.experimentId, + data.cohortId, + stageId, + ); + const startTimestamp = (publicStageData as ChatStagePublicData) + ?.discussionStartTimestamp; + if (!startTimestamp || getTimeElapsed(startTimestamp, 'm') < minTime) { + throw new HttpsError( + 'failed-precondition', + 'Minimum time requirement has not been met.', + ); + } + } + } + // Define document reference const document = app .firestore() diff --git a/functions/src/utils/validation.ts b/functions/src/utils/validation.ts index ed19e60dd..9ad6738f6 100644 --- a/functions/src/utils/validation.ts +++ b/functions/src/utils/validation.ts @@ -1,6 +1,7 @@ /** Pretty printing and analysis utils for typebox validation */ import { + BaseStageConfig, CONFIG_DATA, CohortParticipantConfig, CohortParticipantConfigSchema, @@ -111,12 +112,17 @@ export const checkUnionErrorOnPath = ( : Value.Errors(validator, value); }; +/** Schema-only map extracted from CONFIG_DATA for union error resolution. */ +const CONFIG_DATA_SCHEMAS: Record = Object.fromEntries( + Object.entries(CONFIG_DATA).map(([key, entry]) => [key, entry.schema]), +); + // Variants export const checkConfigDataUnionOnPath = ( data: unknown, path: string, references?: TSchema[], -) => checkUnionErrorOnPath(data, path, CONFIG_DATA, references); +) => checkUnionErrorOnPath(data, path, CONFIG_DATA_SCHEMAS, references); // ************************************************************************* // // STAGE VALIDATION // @@ -179,7 +185,7 @@ export function validateStages(stages: unknown[]): ValidationResult { ` - ${nestedError.path}: ${nestedError.message}`, ); } - } catch (err) { + } catch { // If drilling into union fails, fall back to generic error errorMessages.push(` - ${error.path}: ${error.message}`); } @@ -187,6 +193,19 @@ export function validateStages(stages: unknown[]): ValidationResult { errorMessages.push(` - ${error.path}: ${error.message}`); } } + } else { + // Schema passed — run cross-field business rule validation + const stageObj = stage as BaseStageConfig; + const entry = CONFIG_DATA[stageObj.kind as string]; + if (entry?.validate) { + const result = entry.validate(stageObj); + if (!result.valid) { + const stageName = stageObj?.name || 'unnamed'; + errorMessages.push( + `Stage ${i} (name: "${stageName}", kind: "${stageObj.kind}"): ${result.error}`, + ); + } + } } } diff --git a/scripts/deliberate_lab/types.py b/scripts/deliberate_lab/types.py index 0d717a9c6..d72f9977c 100644 --- a/scripts/deliberate_lab/types.py +++ b/scripts/deliberate_lab/types.py @@ -61,26 +61,12 @@ class CohortDefinition(BaseModel): maxParticipantsPerCohort: Annotated[int | None, Field(ge=1)] = None -class MinParticipantsPerCohort(RootModel[int]): - model_config = ConfigDict( - populate_by_name=True, - ) - root: Annotated[int, Field(ge=0)] - - -class MaxParticipantsPerCohort(RootModel[int]): - model_config = ConfigDict( - populate_by_name=True, - ) - root: Annotated[int, Field(ge=1)] - - class CohortParticipantConfig(BaseModel): model_config = ConfigDict( populate_by_name=True, ) - minParticipantsPerCohort: MinParticipantsPerCohort | None = None - maxParticipantsPerCohort: MaxParticipantsPerCohort | None = None + minParticipantsPerCohort: Annotated[int | None, Field(ge=0)] = None + maxParticipantsPerCohort: Annotated[int | None, Field(ge=1)] = None includeAllParticipantsInCohortCount: bool botProtection: bool @@ -365,8 +351,8 @@ class PrivateChatStageConfig(BaseModel): name: Annotated[str, Field(min_length=1)] descriptions: StageTextConfig progress: StageProgressConfig - timeLimitInMinutes: float | None = None - requireFullTime: bool | None = None + timeLimitInMinutes: Annotated[int | None, Field(ge=1)] = None + timeMinimumInMinutes: Annotated[int | None, Field(ge=1)] = None isTurnBasedChat: bool | None = None minNumberOfTurns: float | None = None maxNumberOfTurns: float | None = None @@ -986,8 +972,8 @@ class ChatStageConfig(BaseModel): name: Annotated[str, Field(min_length=1)] descriptions: StageTextConfig progress: StageProgressConfig - timeLimitInMinutes: float | None = None - requireFullTime: bool | None = None + timeLimitInMinutes: Annotated[int | None, Field(ge=1)] = None + timeMinimumInMinutes: Annotated[int | None, Field(ge=1)] = None discussions: list[DefaultChatDiscussion | CompareChatDiscussion] diff --git a/utils/src/export-schemas.ts b/utils/src/export-schemas.ts index 9c449006c..791b1161e 100644 --- a/utils/src/export-schemas.ts +++ b/utils/src/export-schemas.ts @@ -54,8 +54,8 @@ function collectSchemasWithId( } // Verify all stage configs have $id for proper naming in $defs -for (const [key, schema] of Object.entries(CONFIG_DATA)) { - if (!(schema as Record).$id) { +for (const [key, entry] of Object.entries(CONFIG_DATA)) { + if (!(entry.schema as Record).$id) { throw new Error( `Stage config "${key}" is missing $id. Add $id to its TypeBox definition.`, ); @@ -266,7 +266,80 @@ function simplifyAllOf(obj: unknown): unknown { return result; } -const simplifiedSchema = simplifyAllOf(fixedSchema) as Record; +/** + * Simplify unions that consist only of simple primitive types (plus null). + * + * Pattern: anyOf: [{type: 'integer', ...}, {type: 'null'}] + * Simplified: {type: ['integer', 'null'], ...} + * + * This allows datamodel-codegen to inline these as Optional[primitive] instead + * of creating RootModels or subclasses. It safely avoids collapsing enums or literals. + */ +function simplifyUnions(obj: unknown): unknown { + if (obj === null || typeof obj !== 'object') { + return obj; + } + + if (Array.isArray(obj)) { + return obj.map((item) => simplifyUnions(item)); + } + + const record = obj as Record; + + // Check if this is an anyOf that can be simplified + if (Array.isArray(record.anyOf) && record.anyOf.length > 0) { + const members = record.anyOf as Record[]; + + // Check if all members are simple primitives (no objects, no arrays, no enums/consts) + const canCollapse = members.every( + (m) => + m.type && + typeof m.type === 'string' && + m.type !== 'object' && + m.type !== 'array' && + !m.const && + !m.enum && + !m.$ref && + !m.anyOf && + !m.oneOf && + !m.allOf, + ); + + if (canCollapse) { + // Separate members into those with constraints and those without (pure types) + const constrained = members.filter((m) => Object.keys(m).length > 1); + + // Only collapse if there's at most one member with constraints (e.g. integer + null) + // to avoid merging conflicting rules (like two different patterns). + if (constrained.length <= 1) { + const types = [...new Set(members.map((m) => m.type as string))]; + const base = constrained.length === 1 ? constrained[0] : members[0]; + const result: Record = { + ...base, + type: types.length === 1 ? types[0] : types, + }; + + // Preserve title if it exists on the union itself + if (record.title) result.title = record.title; + + return simplifyUnions(result); + } + } + } + + // Recursively process all properties + const result: Record = {}; + for (const [key, value] of Object.entries(record)) { + result[key] = simplifyUnions(value); + } + return result; +} + +const simplifiedAllOf = simplifyAllOf(fixedSchema) as Record; +const simplifiedSchema = simplifyUnions(simplifiedAllOf) as Record< + string, + unknown +>; simplifiedSchema.$id = 'DeliberateLabSchemas'; /** diff --git a/utils/src/index.ts b/utils/src/index.ts index 2816177cd..397dff758 100644 --- a/utils/src/index.ts +++ b/utils/src/index.ts @@ -73,6 +73,7 @@ export * from './stages/stage.handler'; export * from './stages/stage.manager'; export * from './stages/stage.prompts'; export * from './stages/stage.validation'; +export * from './stages/stage.schemas'; export * from './stages/chat_stage'; export * from './stages/chat_stage.manager'; diff --git a/utils/src/stages/chat_stage.ts b/utils/src/stages/chat_stage.ts index ae89949a7..17b283792 100644 --- a/utils/src/stages/chat_stage.ts +++ b/utils/src/stages/chat_stage.ts @@ -28,8 +28,9 @@ import { export interface ChatStageConfig extends BaseStageConfig { kind: StageKind.CHAT; discussions: ChatDiscussion[]; // ordered list of discussions - timeLimitInMinutes: number | null; // How long remaining in the chat. - requireFullTime: boolean; // Require participants to stay in chat until time limit is up + // TODO: Migrate to seconds for internal storage to avoid fractional-minute ambiguity. + timeLimitInMinutes: number | null; // Maximum duration in minutes (integer), or null if no limit. + timeMinimumInMinutes: number | null; // Minimum time participants must stay in minutes (integer), or null if no minimum. } /** Chat discussion. */ @@ -118,7 +119,7 @@ export function createChatStage( createStageProgressConfig({waitForAllParticipants: true}), discussions: config.discussions ?? [], timeLimitInMinutes: config.timeLimitInMinutes ?? null, - requireFullTime: config.requireFullTime ?? false, + timeMinimumInMinutes: config.timeMinimumInMinutes ?? null, }; } diff --git a/utils/src/stages/chat_stage.validation.ts b/utils/src/stages/chat_stage.validation.ts index 3b29f7849..79ff2ef30 100644 --- a/utils/src/stages/chat_stage.validation.ts +++ b/utils/src/stages/chat_stage.validation.ts @@ -1,9 +1,13 @@ import {Type, type Static} from '@sinclair/typebox'; import {UnifiedTimestampSchema} from '../shared.validation'; -import {StageKind} from './stage'; +import {BaseStageConfig, StageKind} from './stage'; import {ChatDiscussionType} from './chat_stage'; -import {BaseStageConfigSchema} from './stage.schemas'; +import { + BaseStageConfigSchema, + type StageValidationResult, +} from './stage.schemas'; import {UserType} from '../participant'; +import {ChatStageConfig} from './chat_stage'; /** Shorthand for strict TypeBox object validation */ const strict = {additionalProperties: false} as const; @@ -58,8 +62,14 @@ export const ChatStageConfigData = Type.Composite( Type.Object( { kind: Type.Literal(StageKind.CHAT), - timeLimitInMinutes: Type.Union([Type.Number(), Type.Null()]), - requireFullTime: Type.Union([Type.Boolean(), Type.Null()]), + timeLimitInMinutes: Type.Union([ + Type.Integer({minimum: 1}), + Type.Null(), + ]), + timeMinimumInMinutes: Type.Union([ + Type.Integer({minimum: 1}), + Type.Null(), + ]), discussions: Type.Array(ChatDiscussionData), }, strict, @@ -68,6 +78,23 @@ export const ChatStageConfigData = Type.Composite( {$id: 'ChatStageConfig', ...strict}, ); +/** Validate cross-field business rules for chat time configs. */ +export function validateChatStageConfig( + stage: BaseStageConfig, +): StageValidationResult { + const {timeMinimumInMinutes: min, timeLimitInMinutes: max} = + stage as ChatStageConfig; + + if (min != null && max != null && min > max) { + return { + valid: false, + error: `timeMinimumInMinutes (${min}) cannot exceed timeLimitInMinutes (${max})`, + }; + } + + return {valid: true}; +} + // ************************************************************************* // // updateChatMessage endpoint // // ************************************************************************* // diff --git a/utils/src/stages/private_chat_stage.ts b/utils/src/stages/private_chat_stage.ts index bb3836695..58712539c 100644 --- a/utils/src/stages/private_chat_stage.ts +++ b/utils/src/stages/private_chat_stage.ts @@ -23,11 +23,12 @@ import { */ export interface PrivateChatStageConfig extends BaseStageConfig { kind: StageKind.PRIVATE_CHAT; - // If defined, ends chat after specified time limit + // TODO: Migrate to seconds for internal storage to avoid fractional-minute ambiguity. + // If defined, ends chat after specified time limit (integer minutes) // (starting from when the first message is sent) timeLimitInMinutes: number | null; - // Require participants to stay in chat until time limit is up - requireFullTime: boolean; + // Minimum amount of time a participant must spend in chat (integer minutes) + timeMinimumInMinutes: number | null; // If true, requires participant to go back and forth with mediator(s) // (rather than being able to send multiple messages at once) isTurnBasedChat: boolean; @@ -58,7 +59,7 @@ export function createPrivateChatStage( config.progress ?? createStageProgressConfig({waitForAllParticipants: true}), timeLimitInMinutes: config.timeLimitInMinutes ?? null, - requireFullTime: config.requireFullTime ?? false, + timeMinimumInMinutes: config.timeMinimumInMinutes ?? null, isTurnBasedChat: config.isTurnBasedChat ?? true, minNumberOfTurns: config.minNumberOfTurns ?? 0, maxNumberOfTurns: config.maxNumberOfTurns ?? null, diff --git a/utils/src/stages/private_chat_stage.validation.ts b/utils/src/stages/private_chat_stage.validation.ts index a7a175a1f..1e6eb6709 100644 --- a/utils/src/stages/private_chat_stage.validation.ts +++ b/utils/src/stages/private_chat_stage.validation.ts @@ -1,7 +1,12 @@ import {Type, type Static} from '@sinclair/typebox'; import {UnifiedTimestampSchema} from '../shared.validation'; -import {StageKind} from './stage'; -import {BaseStageConfigSchema} from './stage.schemas'; +import {BaseStageConfig, StageKind} from './stage'; +import { + BaseStageConfigSchema, + type StageValidationResult, +} from './stage.schemas'; +import {validateChatStageConfig} from './chat_stage.validation'; +import {PrivateChatStageConfig} from './private_chat_stage'; /** Shorthand for strict TypeBox object validation */ const strict = {additionalProperties: false} as const; @@ -14,9 +19,14 @@ export const PrivateChatStageConfigData = Type.Composite( kind: Type.Literal(StageKind.PRIVATE_CHAT), // If defined, ends chat after specified time limit // (starting from when the first message is sent) - timeLimitInMinutes: Type.Union([Type.Number(), Type.Null()]), - // Require participants to stay in chat until time limit is up - requireFullTime: Type.Optional(Type.Boolean()), + timeLimitInMinutes: Type.Union([ + Type.Integer({minimum: 1}), + Type.Null(), + ]), + timeMinimumInMinutes: Type.Union([ + Type.Integer({minimum: 1}), + Type.Null(), + ]), // If true, requires participant to go back and forth with mediator(s) // (rather than being able to send multiple messages at once) isTurnBasedChat: Type.Optional(Type.Boolean()), @@ -36,3 +46,24 @@ export const PrivateChatStageConfigData = Type.Composite( ], {$id: 'PrivateChatStageConfig', ...strict}, ); + +/** Validate cross-field business rules for private chat stage configs. */ +export function validatePrivateChatStageConfig( + stage: BaseStageConfig, +): StageValidationResult { + // Check time constraints (shared with group chat) + const timeResult = validateChatStageConfig(stage); + if (!timeResult.valid) return timeResult; + + const {minNumberOfTurns: minTurns, maxNumberOfTurns: maxTurns} = + stage as PrivateChatStageConfig; + + if (minTurns != null && maxTurns != null && minTurns > maxTurns) { + return { + valid: false, + error: `minNumberOfTurns (${minTurns}) cannot exceed maxNumberOfTurns (${maxTurns})`, + }; + } + + return {valid: true}; +} diff --git a/utils/src/stages/stage.schemas.ts b/utils/src/stages/stage.schemas.ts index 6bba523c9..748a6f540 100644 --- a/utils/src/stages/stage.schemas.ts +++ b/utils/src/stages/stage.schemas.ts @@ -6,6 +6,11 @@ import {StageKind} from './stage'; * These are defined in a separate file to ensure they're bundled before Type.Ref() calls. */ +/** Result type for cross-field stage config validators. */ +export type StageValidationResult = + | {valid: true} + | {valid: false; error: string}; + /** StageTextConfig input validation. */ export const StageTextConfigSchema = Type.Object( { diff --git a/utils/src/stages/stage.validation.ts b/utils/src/stages/stage.validation.ts index 5a4a6c46b..72f151a5b 100644 --- a/utils/src/stages/stage.validation.ts +++ b/utils/src/stages/stage.validation.ts @@ -1,17 +1,24 @@ -import {Type} from '@sinclair/typebox'; -import {StageKind} from './stage'; +import {Type, type TSchema} from '@sinclair/typebox'; +import {BaseStageConfig, StageKind} from './stage'; +import {type StageValidationResult} from './stage.schemas'; import { AssetAllocationStageConfigData, MultiAssetAllocationStageConfigData, } from './asset_allocation_stage.validation'; -import {ChatStageConfigData} from './chat_stage.validation'; +import { + ChatStageConfigData, + validateChatStageConfig, +} from './chat_stage.validation'; import {ChipStageConfigData} from './chip_stage.validation'; import {ComprehensionStageConfigData} from './comprehension_stage.validation'; import {FlipCardStageConfigData} from './flipcard_stage.validation'; import {RankingStageConfigData} from './ranking_stage.validation'; import {InfoStageConfigData} from './info_stage.validation'; import {PayoutStageConfigData} from './payout_stage.validation'; -import {PrivateChatStageConfigData} from './private_chat_stage.validation'; +import { + PrivateChatStageConfigData, + validatePrivateChatStageConfig, +} from './private_chat_stage.validation'; import {ProfileStageConfigData} from './profile_stage.validation'; import {RevealStageConfigData} from './reveal_stage.validation'; import {RoleStageConfigData} from './role_stage.validation'; @@ -35,28 +42,39 @@ export const StageKindData = Type.Enum(StageKind, {$id: 'StageKind'}); // writeExperiment, updateStageConfig endpoints // // ************************************************************************* // -/** Map of stage kinds to their validators */ -export const CONFIG_DATA = { - assetAllocation: AssetAllocationStageConfigData, - multiAssetAllocation: MultiAssetAllocationStageConfigData, - chat: ChatStageConfigData, - chip: ChipStageConfigData, - comprehension: ComprehensionStageConfigData, - flipcard: FlipCardStageConfigData, - info: InfoStageConfigData, - payout: PayoutStageConfigData, - privateChat: PrivateChatStageConfigData, - profile: ProfileStageConfigData, - ranking: RankingStageConfigData, - reveal: RevealStageConfigData, - role: RoleStageConfigData, - salesperson: SalespersonStageConfigData, - stockinfo: StockInfoStageConfigData, - surveyPerParticipant: SurveyPerParticipantStageConfigData, - survey: SurveyStageConfigData, - tos: TOSStageConfigData, - transfer: TransferStageConfigData, +/** Stage config entry with schema and optional cross-field validator. */ +export interface StageConfigEntry { + schema: TSchema; + validate?: (stage: BaseStageConfig) => StageValidationResult; +} + +/** Map of stage kinds to their schema and optional validator */ +export const CONFIG_DATA: Record = { + assetAllocation: {schema: AssetAllocationStageConfigData}, + multiAssetAllocation: {schema: MultiAssetAllocationStageConfigData}, + chat: {schema: ChatStageConfigData, validate: validateChatStageConfig}, + chip: {schema: ChipStageConfigData}, + comprehension: {schema: ComprehensionStageConfigData}, + flipcard: {schema: FlipCardStageConfigData}, + info: {schema: InfoStageConfigData}, + payout: {schema: PayoutStageConfigData}, + privateChat: { + schema: PrivateChatStageConfigData, + validate: validatePrivateChatStageConfig, + }, + profile: {schema: ProfileStageConfigData}, + ranking: {schema: RankingStageConfigData}, + reveal: {schema: RevealStageConfigData}, + role: {schema: RoleStageConfigData}, + salesperson: {schema: SalespersonStageConfigData}, + stockinfo: {schema: StockInfoStageConfigData}, + surveyPerParticipant: {schema: SurveyPerParticipantStageConfigData}, + survey: {schema: SurveyStageConfigData}, + tos: {schema: TOSStageConfigData}, + transfer: {schema: TransferStageConfigData}, }; /** StageConfig input validation (union of all stage types) */ -export const StageConfigData = Type.Union(Object.values(CONFIG_DATA)); +export const StageConfigData = Type.Union( + Object.values(CONFIG_DATA).map((entry) => entry.schema), +);