From af82920e73b37f7611ac50a42076b91d1801a2fe Mon Sep 17 00:00:00 2001 From: rasmi Date: Tue, 17 Mar 2026 15:28:33 -0400 Subject: [PATCH 01/10] requireFullTime -> timeMinimumInMinutes --- docs/assets/api/schemas.json | 16 +- .../src/components/chat/chat_info_panel.ts | 5 +- .../components/stages/group_chat_editor.ts | 40 ++-- .../stages/group_chat_participant_view.ts | 46 ++-- .../components/stages/private_chat_editor.ts | 40 ++-- .../stages/private_chat_participant_view.ts | 47 ++++- .../agent_participant_integration_template.ts | 2 +- .../shared/templates/charity_allocations.ts | 4 +- .../templates/charity_allocations_ootb.ts | 4 +- functions/package.json | 4 +- functions/src/chat/chat.utils.ts | 31 +++ .../migrations/migrate-require-full-time.ts | 199 ++++++++++++++++++ functions/src/stages/chat.endpoints.ts | 30 ++- scripts/deliberate_lab/types.py | 4 +- utils/src/stages/chat_stage.ts | 4 +- utils/src/stages/chat_stage.validation.ts | 4 +- utils/src/stages/private_chat_stage.ts | 6 +- .../stages/private_chat_stage.validation.ts | 5 +- 18 files changed, 411 insertions(+), 80 deletions(-) create mode 100644 functions/src/migrations/migrate-require-full-time.ts diff --git a/docs/assets/api/schemas.json b/docs/assets/api/schemas.json index 3746375a7..917340aaf 100644 --- a/docs/assets/api/schemas.json +++ b/docs/assets/api/schemas.json @@ -674,7 +674,6 @@ "descriptions", "progress", "timeLimitInMinutes", - "requireFullTime", "discussions" ], "properties": { @@ -706,10 +705,10 @@ } ] }, - "requireFullTime": { + "timeMinimumInMinutes": { "anyOf": [ { - "type": "boolean" + "type": "number" }, { "type": "null" @@ -1376,8 +1375,15 @@ } ] }, - "requireFullTime": { - "type": "boolean" + "timeMinimumInMinutes": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] }, "isTurnBasedChat": { "type": "boolean" diff --git a/frontend/src/components/chat/chat_info_panel.ts b/frontend/src/components/chat/chat_info_panel.ts index 62104de2f..a0c7c6f51 100644 --- a/frontend/src/components/chat/chat_info_panel.ts +++ b/frontend/src/components/chat/chat_info_panel.ts @@ -93,8 +93,9 @@ 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 + ? `You must stay on this chat for at least ${this.stage.timeMinimumInMinutes} minutes.` : ''} ${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..bc10ae5c2 100644 --- a/frontend/src/components/stages/group_chat_editor.ts +++ b/frontend/src/components/stages/group_chat_editor.ts @@ -71,7 +71,6 @@ export class ChatEditor extends MobxLitElement { private renderTimeLimit() { const timeLimit = this.stage?.timeLimitInMinutes ?? null; - const requireFullTime = this.stage?.requireFullTime ?? false; const updateCheck = () => { if (!this.stage) return; @@ -79,6 +78,7 @@ export class ChatEditor extends MobxLitElement { this.experimentEditor.updateStage({ ...this.stage, timeLimitInMinutes: null, + timeMinimumInMinutes: null, }); } else { this.experimentEditor.updateStage({ @@ -88,7 +88,7 @@ export class ChatEditor extends MobxLitElement { } }; - const updateNum = (e: InputEvent) => { + const updateMaxTime = (e: InputEvent) => { if (!this.stage) return; const timeLimit = Number((e.target as HTMLTextAreaElement).value); this.experimentEditor.updateStage({ @@ -97,11 +97,14 @@ export class ChatEditor extends MobxLitElement { }); }; - const updateRequireFullTime = (e: Event) => { + const updateMinTime = (e: InputEvent) => { if (!this.stage) return; + const val = Number((e.target as HTMLInputElement).value); + const max = this.stage.timeLimitInMinutes; + const clamped = max !== null ? Math.min(val, max) : val; this.experimentEditor.updateStage({ ...this.stage, - requireFullTime: (e.target as HTMLInputElement).checked, + timeMinimumInMinutes: clamped > 0 ? clamped : null, }); }; @@ -120,10 +123,7 @@ export class ChatEditor extends MobxLitElement { ${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..112219a3a 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 +297,23 @@ 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 + ); + } } declare global { diff --git a/frontend/src/components/stages/private_chat_editor.ts b/frontend/src/components/stages/private_chat_editor.ts index 6d9557d34..833071edd 100644 --- a/frontend/src/components/stages/private_chat_editor.ts +++ b/frontend/src/components/stages/private_chat_editor.ts @@ -42,7 +42,6 @@ export class ChatEditor extends MobxLitElement { private renderTimeLimit() { const timeLimit = this.stage?.timeLimitInMinutes ?? null; - const requireFullTime = this.stage?.requireFullTime ?? false; const updateCheck = () => { if (!this.stage) return; @@ -50,6 +49,7 @@ export class ChatEditor extends MobxLitElement { this.experimentEditor.updateStage({ ...this.stage, timeLimitInMinutes: null, + timeMinimumInMinutes: null, }); } else { this.experimentEditor.updateStage({ @@ -59,7 +59,7 @@ export class ChatEditor extends MobxLitElement { } }; - const updateNum = (e: InputEvent) => { + const updateMaxTime = (e: InputEvent) => { if (!this.stage) return; const timeLimit = Number((e.target as HTMLTextAreaElement).value); this.experimentEditor.updateStage({ @@ -68,11 +68,14 @@ export class ChatEditor extends MobxLitElement { }); }; - const updateRequireFullTime = (e: Event) => { + const updateMinTime = (e: InputEvent) => { if (!this.stage) return; + const val = Number((e.target as HTMLInputElement).value); + const max = this.stage.timeLimitInMinutes; + const clamped = max !== null ? Math.min(val, max) : val; this.experimentEditor.updateStage({ ...this.stage, - requireFullTime: (e.target as HTMLInputElement).checked, + timeMinimumInMinutes: clamped > 0 ? clamped : null, }); }; @@ -91,10 +94,7 @@ export class ChatEditor extends MobxLitElement { ${timeLimit !== null ? html`
- +
-
- + + - -
Require participants to stay until time elapses
+ @input=${updateMinTime} + />
` : nothing} diff --git a/frontend/src/components/stages/private_chat_participant_view.ts b/frontend/src/components/stages/private_chat_participant_view.ts index c415f7a76..50b10151e 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,19 @@ 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 conversation has ended + const isConversationOver = maxTurnsReached || maxTimeReached; // Disable input if turn-taking is set and latest message // is from participant OR if conversation is over @@ -111,6 +123,23 @@ export class PrivateChatView extends MobxLitElement { !isWaitingForResponse : participantMessageCount >= this.stage.minNumberOfTurns; + // Check if minimum time is met + let minTimeMet = true; + if ( + this.stage.timeMinimumInMinutes !== null && + this.stage.timeMinimumInMinutes > 0 + ) { + if (!discussionStartTimestamp) { + minTimeMet = false; + } else { + if (elapsedMinutes < this.stage.timeMinimumInMinutes) { + minTimeMet = false; + } + } + } + + const isNextDisabled = !minTurnsMet || !minTimeMet; + return html` ${chatMessages.map((message) => this.renderChatMessage(message))} @@ -119,13 +148,16 @@ export class PrivateChatView extends MobxLitElement { : nothing} ${isConversationOver ? this.renderConversationEndedMessage() : nothing} - + ${this.stage.progress.showParticipantProgress ? html`` : nothing} ${!minTurnsMet && !isConversationOver ? this.renderMinTurnsMessage(participantMessageCount) : nothing} + ${!minTimeMet && minTurnsMet && !isConversationOver + ? this.renderMinTimeMessage() + : nothing} `; } @@ -198,6 +230,15 @@ export class PrivateChatView extends MobxLitElement { `; } + + private renderMinTimeMessage() { + return html` +
+ You must wait until ${this.stage?.timeMinimumInMinutes} minutes have + passed. +
+ `; + } } declare global { 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..5a57f4063 100644 --- a/frontend/src/shared/templates/charity_allocations.ts +++ b/frontend/src/shared/templates/charity_allocations.ts @@ -565,7 +565,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 +935,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..0bb9c65cc 100644 --- a/frontend/src/shared/templates/charity_allocations_ootb.ts +++ b/frontend/src/shared/templates/charity_allocations_ootb.ts @@ -566,7 +566,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 +936,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..6b25e599b 100644 --- a/functions/package.json +++ b/functions/package.json @@ -13,7 +13,9 @@ "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: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..cd19a4fff 100644 --- a/functions/src/chat/chat.utils.ts +++ b/functions/src/chat/chat.utils.ts @@ -19,6 +19,7 @@ import { ChatStagePublicData, ChatStageParticipantAnswer, createChatStageParticipantAnswer, + getTimeElapsed, } from '@deliberation-lab/utils'; import {updateParticipantNextStage} from '../participant.utils'; @@ -71,6 +72,35 @@ export async function updateParticipantReadyToEndChat( ); if (!publicStageData) return; + let startTimestamp = (publicStageData as ChatStagePublicData) + .discussionStartTimestamp; + + if (stage.kind === 'privateChat' && !startTimestamp) { + const chatRef = app + .firestore() + .collection('experiments') + .doc(experimentId) + .collection('participants') + .doc(participantId) + .collection('stageData') + .doc(stage.id) + .collection('privateChats') + .orderBy('timestamp', 'asc') + .limit(1); + + const chatSnap = await chatRef.get(); + if (!chatSnap.empty) { + startTimestamp = chatSnap.docs[0].data().timestamp as Timestamp; + } + } + + const minTime = (stage as ChatStageConfig).timeMinimumInMinutes; + if (minTime !== undefined && minTime !== null && minTime > 0) { + if (!startTimestamp) return; + const elapsedMinutes = getTimeElapsed(startTimestamp, 'm'); + if (elapsedMinutes < minTime) return; + } + const participantAnswerDoc = getFirestoreParticipantAnswerRef( experimentId, participantId, @@ -111,6 +141,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/migrations/migrate-require-full-time.ts b/functions/src/migrations/migrate-require-full-time.ts new file mode 100644 index 000000000..186bdebc5 --- /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 stages: StageMigration[] = []; + + for (const stageDoc of stagesSnapshot.docs) { + const stage = stageDoc.data(); + + if (!stage.requireFullTime) continue; + + const result: StageMigration = { + stageId: stageDoc.id, + stageName: stage.name || 'Unnamed', + stageKind: stage.kind || 'unknown', + timeLimitInMinutes: stage.timeLimitInMinutes ?? null, + }; + + try { + const timeMinimumInMinutes = stage.timeLimitInMinutes ?? null; + + 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); + } + + if (stages.length > 0) { + console.log( + `\n[${dateCreated}] ${experimentName} [${experimentId}] — ${stages.length} stage(s):`, + ); + 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.experimentName} [${exp.experimentId}]: ${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..0109a702d 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 !== undefined && 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/scripts/deliberate_lab/types.py b/scripts/deliberate_lab/types.py index 0d717a9c6..5bae275ed 100644 --- a/scripts/deliberate_lab/types.py +++ b/scripts/deliberate_lab/types.py @@ -366,7 +366,7 @@ class PrivateChatStageConfig(BaseModel): descriptions: StageTextConfig progress: StageProgressConfig timeLimitInMinutes: float | None = None - requireFullTime: bool | None = None + timeMinimumInMinutes: float | None = None isTurnBasedChat: bool | None = None minNumberOfTurns: float | None = None maxNumberOfTurns: float | None = None @@ -987,7 +987,7 @@ class ChatStageConfig(BaseModel): descriptions: StageTextConfig progress: StageProgressConfig timeLimitInMinutes: float | None = None - requireFullTime: bool | None = None + timeMinimumInMinutes: float | None = None discussions: list[DefaultChatDiscussion | CompareChatDiscussion] diff --git a/utils/src/stages/chat_stage.ts b/utils/src/stages/chat_stage.ts index ae89949a7..619ca7ff2 100644 --- a/utils/src/stages/chat_stage.ts +++ b/utils/src/stages/chat_stage.ts @@ -29,7 +29,7 @@ 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 + timeMinimumInMinutes: number | null; // Minimum amount of time participants must spend in chat. } /** Chat discussion. */ @@ -118,7 +118,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..ca4efaf2a 100644 --- a/utils/src/stages/chat_stage.validation.ts +++ b/utils/src/stages/chat_stage.validation.ts @@ -59,7 +59,9 @@ export const ChatStageConfigData = Type.Composite( { kind: Type.Literal(StageKind.CHAT), timeLimitInMinutes: Type.Union([Type.Number(), Type.Null()]), - requireFullTime: Type.Union([Type.Boolean(), Type.Null()]), + timeMinimumInMinutes: Type.Optional( + Type.Union([Type.Number(), Type.Null()]), + ), discussions: Type.Array(ChatDiscussionData), }, strict, diff --git a/utils/src/stages/private_chat_stage.ts b/utils/src/stages/private_chat_stage.ts index bb3836695..ebc5a0617 100644 --- a/utils/src/stages/private_chat_stage.ts +++ b/utils/src/stages/private_chat_stage.ts @@ -26,8 +26,8 @@ export interface PrivateChatStageConfig extends BaseStageConfig { // If defined, ends chat after specified time limit // (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 + 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 +58,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..b4c1c7603 100644 --- a/utils/src/stages/private_chat_stage.validation.ts +++ b/utils/src/stages/private_chat_stage.validation.ts @@ -15,8 +15,9 @@ export const PrivateChatStageConfigData = Type.Composite( // 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()), + timeMinimumInMinutes: Type.Optional( + Type.Union([Type.Number(), 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()), From 295944261ddc48d1ee13fe248daf3f50ff2da466 Mon Sep 17 00:00:00 2001 From: rasmi Date: Tue, 17 Mar 2026 15:36:27 -0400 Subject: [PATCH 02/10] Tidy up. --- .../stages/private_chat_participant_view.ts | 18 ++---- functions/src/chat/chat.utils.ts | 60 +++++++++---------- functions/src/stages/chat.endpoints.ts | 2 +- 3 files changed, 35 insertions(+), 45 deletions(-) diff --git a/frontend/src/components/stages/private_chat_participant_view.ts b/frontend/src/components/stages/private_chat_participant_view.ts index 50b10151e..94c21226d 100644 --- a/frontend/src/components/stages/private_chat_participant_view.ts +++ b/frontend/src/components/stages/private_chat_participant_view.ts @@ -124,19 +124,11 @@ export class PrivateChatView extends MobxLitElement { : participantMessageCount >= this.stage.minNumberOfTurns; // Check if minimum time is met - let minTimeMet = true; - if ( - this.stage.timeMinimumInMinutes !== null && - this.stage.timeMinimumInMinutes > 0 - ) { - if (!discussionStartTimestamp) { - minTimeMet = false; - } else { - if (elapsedMinutes < this.stage.timeMinimumInMinutes) { - minTimeMet = false; - } - } - } + const minTimeMet = + this.stage.timeMinimumInMinutes == null || + this.stage.timeMinimumInMinutes <= 0 || + (discussionStartTimestamp !== null && + elapsedMinutes >= this.stage.timeMinimumInMinutes); const isNextDisabled = !minTurnsMet || !minTimeMet; diff --git a/functions/src/chat/chat.utils.ts b/functions/src/chat/chat.utils.ts index cd19a4fff..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,13 +19,6 @@ import { getFirestoreExperiment, getFirestoreParticipantRef, } from '../utils/firestore'; -import { - ChatStageConfig, - ChatStagePublicData, - ChatStageParticipantAnswer, - createChatStageParticipantAnswer, - getTimeElapsed, -} from '@deliberation-lab/utils'; import {updateParticipantNextStage} from '../participant.utils'; /** Used for private chats if model response fails. */ @@ -72,33 +70,33 @@ export async function updateParticipantReadyToEndChat( ); if (!publicStageData) return; - let startTimestamp = (publicStageData as ChatStagePublicData) - .discussionStartTimestamp; - - if (stage.kind === 'privateChat' && !startTimestamp) { - const chatRef = app - .firestore() - .collection('experiments') - .doc(experimentId) - .collection('participants') - .doc(participantId) - .collection('stageData') - .doc(stage.id) - .collection('privateChats') - .orderBy('timestamp', 'asc') - .limit(1); - - const chatSnap = await chatRef.get(); - if (!chatSnap.empty) { - startTimestamp = chatSnap.docs[0].data().timestamp as Timestamp; + 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; + } } - } - const minTime = (stage as ChatStageConfig).timeMinimumInMinutes; - if (minTime !== undefined && minTime !== null && minTime > 0) { if (!startTimestamp) return; - const elapsedMinutes = getTimeElapsed(startTimestamp, 'm'); - if (elapsedMinutes < minTime) return; + if (getTimeElapsed(startTimestamp, 'm') < minTime) return; } const participantAnswerDoc = getFirestoreParticipantAnswerRef( diff --git a/functions/src/stages/chat.endpoints.ts b/functions/src/stages/chat.endpoints.ts index 0109a702d..2d7660033 100644 --- a/functions/src/stages/chat.endpoints.ts +++ b/functions/src/stages/chat.endpoints.ts @@ -125,7 +125,7 @@ export const updateChatStageParticipantAnswer = onCall(async (request) => { const stage = await getFirestoreStage(data.experimentId, stageId); if (stage) { const minTime = (stage as ChatStageConfig).timeMinimumInMinutes; - if (minTime !== undefined && minTime !== null && minTime > 0) { + if (minTime != null && minTime > 0) { const publicStageData = await getFirestoreStagePublicData( data.experimentId, data.cohortId, From 2fcb6986844aa8caead8fbd2a97ec0ccd9acc78d Mon Sep 17 00:00:00 2001 From: rasmi Date: Tue, 17 Mar 2026 15:41:39 -0400 Subject: [PATCH 03/10] Tidy up. --- frontend/src/components/stages/group_chat_participant_view.ts | 2 +- .../src/components/stages/private_chat_participant_view.ts | 4 ++-- utils/src/stages/chat_stage.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/stages/group_chat_participant_view.ts b/frontend/src/components/stages/group_chat_participant_view.ts index 112219a3a..359af0467 100644 --- a/frontend/src/components/stages/group_chat_participant_view.ts +++ b/frontend/src/components/stages/group_chat_participant_view.ts @@ -190,7 +190,7 @@ export class GroupChatView extends MobxLitElement { return html` - You must wait until ${this.stage?.timeMinimumInMinutes} minutes have - passed. + You must stay on this chat for at least + ${this.stage?.timeMinimumInMinutes} minutes. `; } diff --git a/utils/src/stages/chat_stage.ts b/utils/src/stages/chat_stage.ts index 619ca7ff2..afcb1c63d 100644 --- a/utils/src/stages/chat_stage.ts +++ b/utils/src/stages/chat_stage.ts @@ -28,8 +28,8 @@ import { export interface ChatStageConfig extends BaseStageConfig { kind: StageKind.CHAT; discussions: ChatDiscussion[]; // ordered list of discussions - timeLimitInMinutes: number | null; // How long remaining in the chat. - timeMinimumInMinutes: number | null; // Minimum amount of time participants must spend in chat. + timeLimitInMinutes: number | null; // Maximum duration in minutes, or null if no limit. + timeMinimumInMinutes: number | null; // Minimum time participants must stay, or null if no minimum. } /** Chat discussion. */ From d051840e8c8563c26a6804a757c80e2993fe8c72 Mon Sep 17 00:00:00 2001 From: rasmi Date: Tue, 17 Mar 2026 15:48:50 -0400 Subject: [PATCH 04/10] Tidy up --- .../migrations/migrate-require-full-time.ts | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/functions/src/migrations/migrate-require-full-time.ts b/functions/src/migrations/migrate-require-full-time.ts index 186bdebc5..31d5a0798 100644 --- a/functions/src/migrations/migrate-require-full-time.ts +++ b/functions/src/migrations/migrate-require-full-time.ts @@ -74,12 +74,19 @@ async function migrateStages(dryRun: boolean): Promise { .collection('stages') .get(); - const stages: StageMigration[] = []; + const stageDocs = stagesSnapshot.docs.filter( + (doc) => doc.data().requireFullTime, + ); + if (stageDocs.length === 0) continue; - for (const stageDoc of stagesSnapshot.docs) { - const stage = stageDoc.data(); + console.log( + `\n[${dateCreated}] [${experimentId}] ${experimentName} — ${stageDocs.length} stage(s):`, + ); - if (!stage.requireFullTime) continue; + const stages: StageMigration[] = []; + for (const stageDoc of stageDocs) { + const stage = stageDoc.data(); + const timeMinimumInMinutes = stage.timeLimitInMinutes ?? null; const result: StageMigration = { stageId: stageDoc.id, @@ -89,8 +96,6 @@ async function migrateStages(dryRun: boolean): Promise { }; try { - const timeMinimumInMinutes = stage.timeLimitInMinutes ?? null; - console.log( ` Stage "${stage.name}" (${stage.kind}): ` + `requireFullTime: true → timeMinimumInMinutes: ${timeMinimumInMinutes}`, @@ -113,12 +118,7 @@ async function migrateStages(dryRun: boolean): Promise { stages.push(result); } - if (stages.length > 0) { - console.log( - `\n[${dateCreated}] ${experimentName} [${experimentId}] — ${stages.length} stage(s):`, - ); - experiments.push({experimentId, experimentName, dateCreated, stages}); - } + experiments.push({experimentId, experimentName, dateCreated, stages}); } return experiments; @@ -175,7 +175,7 @@ async function main() { const errors = exp.stages.filter((s) => s.error); const status = errors.length > 0 ? `${errors.length} error(s)` : 'OK'; console.log( - ` [${exp.dateCreated}] ${exp.experimentName} [${exp.experimentId}]: ${exp.stages.length} stage(s) — ${status}`, + ` [${exp.dateCreated}] [${exp.experimentId}] ${exp.experimentName}: ${exp.stages.length} stage(s) — ${status}`, ); } } From 03891a573d23a1d5e3c50b84136603268fb14044 Mon Sep 17 00:00:00 2001 From: rasmi Date: Fri, 27 Mar 2026 10:46:01 -0400 Subject: [PATCH 05/10] Show dynamic min time remaining --- .../src/components/chat/chat_info_panel.ts | 17 ++++++++++++++-- .../stages/group_chat_participant_view.ts | 20 ++++++++++++++++++- .../stages/private_chat_participant_view.ts | 11 ++++++---- 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/chat/chat_info_panel.ts b/frontend/src/components/chat/chat_info_panel.ts index a0c7c6f51..1263e12fb 100644 --- a/frontend/src/components/chat/chat_info_panel.ts +++ b/frontend/src/components/chat/chat_info_panel.ts @@ -21,6 +21,7 @@ import { MediatorStatus, ParticipantProfile, convertUnifiedTimestampToTime, + getTimeElapsed, } from '@deliberation-lab/utils'; import { getChatStartTimestamp, @@ -94,8 +95,20 @@ export class ChatPanel extends MobxLitElement { > ⏱️ Timer: ${this.stage.timeLimitInMinutes} minutes ${renderStatus()} ${this.stage.timeMinimumInMinutes && - !publicStageData.discussionEndTimestamp - ? `You must stay on this chat for at least ${this.stage.timeMinimumInMinutes} minutes.` + !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_participant_view.ts b/frontend/src/components/stages/group_chat_participant_view.ts index 359af0467..3de7f0c4b 100644 --- a/frontend/src/components/stages/group_chat_participant_view.ts +++ b/frontend/src/components/stages/group_chat_participant_view.ts @@ -190,7 +190,7 @@ export class GroupChatView extends MobxLitElement { return html` `; @@ -223,11 +223,14 @@ export class PrivateChatView extends MobxLitElement { `; } - private renderMinTimeMessage() { + private renderMinTimeMessage(elapsedMinutes: number) { + const remaining = Math.ceil( + (this.stage?.timeMinimumInMinutes ?? 0) - elapsedMinutes, + ); return html`
- You must stay on this chat for at least - ${this.stage?.timeMinimumInMinutes} minutes. + You must stay in this chat for at least ${remaining} more + minute${remaining !== 1 ? 's' : ''}.
`; } From ca6dad536cb3a801e7054df94ee8c63509aef16c Mon Sep 17 00:00:00 2001 From: rasmi Date: Fri, 27 Mar 2026 10:46:34 -0400 Subject: [PATCH 06/10] Force integer minutes in UI editor (add todo about migrating to seconds). --- frontend/src/components/stages/group_chat_editor.ts | 12 +++++++++--- .../src/components/stages/private_chat_editor.ts | 12 +++++++++--- utils/src/stages/chat_stage.ts | 5 +++-- utils/src/stages/private_chat_stage.ts | 5 +++-- 4 files changed, 24 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/stages/group_chat_editor.ts b/frontend/src/components/stages/group_chat_editor.ts index bc10ae5c2..439d6172d 100644 --- a/frontend/src/components/stages/group_chat_editor.ts +++ b/frontend/src/components/stages/group_chat_editor.ts @@ -90,7 +90,9 @@ export class ChatEditor extends MobxLitElement { const updateMaxTime = (e: InputEvent) => { if (!this.stage) return; - const timeLimit = Number((e.target as HTMLTextAreaElement).value); + const timeLimit = Math.floor( + Number((e.target as HTMLTextAreaElement).value), + ); this.experimentEditor.updateStage({ ...this.stage, timeLimitInMinutes: timeLimit, @@ -99,7 +101,7 @@ export class ChatEditor extends MobxLitElement { const updateMinTime = (e: InputEvent) => { if (!this.stage) return; - const val = Number((e.target as HTMLInputElement).value); + const val = Math.floor(Number((e.target as HTMLInputElement).value)); const max = this.stage.timeLimitInMinutes; const clamped = max !== null ? Math.min(val, max) : val; this.experimentEditor.updateStage({ @@ -123,12 +125,15 @@ export class ChatEditor extends MobxLitElement { ${timeLimit !== null ? html`
- + { if (!this.stage) return; - const timeLimit = Number((e.target as HTMLTextAreaElement).value); + const timeLimit = Math.floor( + Number((e.target as HTMLTextAreaElement).value), + ); this.experimentEditor.updateStage({ ...this.stage, timeLimitInMinutes: timeLimit, @@ -70,7 +72,7 @@ export class ChatEditor extends MobxLitElement { const updateMinTime = (e: InputEvent) => { if (!this.stage) return; - const val = Number((e.target as HTMLInputElement).value); + const val = Math.floor(Number((e.target as HTMLInputElement).value)); const max = this.stage.timeLimitInMinutes; const clamped = max !== null ? Math.min(val, max) : val; this.experimentEditor.updateStage({ @@ -94,12 +96,15 @@ export class ChatEditor extends MobxLitElement { ${timeLimit !== null ? html`
- + Date: Fri, 27 Mar 2026 11:05:21 -0400 Subject: [PATCH 07/10] Enforce minimums (messages, minutes) over maximums. --- .../components/stages/private_chat_editor.ts | 39 +++++++++++---- .../stages/private_chat_participant_view.ts | 49 +++++++++++++------ 2 files changed, 64 insertions(+), 24 deletions(-) diff --git a/frontend/src/components/stages/private_chat_editor.ts b/frontend/src/components/stages/private_chat_editor.ts index 366d1cc0e..3b8a144b1 100644 --- a/frontend/src/components/stages/private_chat_editor.ts +++ b/frontend/src/components/stages/private_chat_editor.ts @@ -97,7 +97,9 @@ export class ChatEditor extends MobxLitElement { ? html`
{ 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, }); }; @@ -208,13 +216,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 b9d5de5cf..d01a7073c 100644 --- a/frontend/src/components/stages/private_chat_participant_view.ts +++ b/frontend/src/components/stages/private_chat_participant_view.ts @@ -98,8 +98,18 @@ export class PrivateChatView extends MobxLitElement { 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 - const isConversationOver = maxTurnsReached || maxTimeReached; + // 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 @@ -116,13 +126,6 @@ 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 || @@ -138,16 +141,21 @@ 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 && !isConversationOver + ${!minTimeMet && minTurnsMet ? this.renderMinTimeMessage(elapsedMinutes) : nothing} @@ -212,13 +220,26 @@ 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.
`; } From 33b71f5dcbe51e9d91dc37afd62e8f64ea33f641 Mon Sep 17 00:00:00 2001 From: rasmi Date: Fri, 27 Mar 2026 12:52:12 -0400 Subject: [PATCH 08/10] Add cross-parameter stage validation. --- functions/package.json | 2 +- .../experiments.dl_api.integration.test.ts | 22 ++++++ functions/src/experiment.utils.ts | 19 ++++- functions/src/utils/validation.ts | 21 +++++- utils/src/export-schemas.ts | 4 +- utils/src/index.ts | 1 + utils/src/stages/chat_stage.validation.ts | 25 ++++++- .../stages/private_chat_stage.validation.ts | 30 +++++++- utils/src/stages/stage.schemas.ts | 5 ++ utils/src/stages/stage.validation.ts | 70 ++++++++++++------- 10 files changed, 164 insertions(+), 35 deletions(-) diff --git a/functions/package.json b/functions/package.json index 6b25e599b..8c2fd4a6c 100644 --- a/functions/package.json +++ b/functions/package.json @@ -11,7 +11,7 @@ "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", "migrate:require-full-time": "npx tsx src/migrations/migrate-require-full-time.ts", 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/utils/validation.ts b/functions/src/utils/validation.ts index ed19e60dd..eaa7e1598 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 // @@ -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/utils/src/export-schemas.ts b/utils/src/export-schemas.ts index 9c449006c..b40b05197 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.`, ); 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.validation.ts b/utils/src/stages/chat_stage.validation.ts index ca4efaf2a..f013fd153 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; @@ -70,6 +74,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.validation.ts b/utils/src/stages/private_chat_stage.validation.ts index b4c1c7603..b2d8b3f2f 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; @@ -37,3 +42,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), +); From 7f0a948b602db75166ebdebb544fbaa68e62ea25 Mon Sep 17 00:00:00 2001 From: rasmi Date: Fri, 27 Mar 2026 13:10:46 -0400 Subject: [PATCH 09/10] Clean up lint warnings / unused variables --- .../src/components/chat/chat_info_panel.ts | 10 ++---- .../stages/group_chat_participant_view.ts | 4 --- .../shared/templates/charity_allocations.ts | 9 ------ .../templates/charity_allocations_ootb.ts | 32 ------------------- functions/src/utils/validation.ts | 2 +- 5 files changed, 4 insertions(+), 53 deletions(-) diff --git a/frontend/src/components/chat/chat_info_panel.ts b/frontend/src/components/chat/chat_info_panel.ts index 1263e12fb..a7d3b90a8 100644 --- a/frontend/src/components/chat/chat_info_panel.ts +++ b/frontend/src/components/chat/chat_info_panel.ts @@ -18,15 +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'; @@ -58,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[ diff --git a/frontend/src/components/stages/group_chat_participant_view.ts b/frontend/src/components/stages/group_chat_participant_view.ts index 3de7f0c4b..80c6289b3 100644 --- a/frontend/src/components/stages/group_chat_participant_view.ts +++ b/frontend/src/components/stages/group_chat_participant_view.ts @@ -220,10 +220,6 @@ export class GroupChatView extends MobxLitElement { private renderIndicators() { if (!this.stage) return nothing; - const publicStageData = this.cohortService.stagePublicDataMap[ - this.stage.id - ] as ChatStagePublicData; - // Check if all other participants have completed the stage const completed = this.cohortService.getStageCompletedParticipants( this.stage.id, diff --git a/frontend/src/shared/templates/charity_allocations.ts b/frontend/src/shared/templates/charity_allocations.ts index 5a57f4063..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}`; diff --git a/frontend/src/shared/templates/charity_allocations_ootb.ts b/frontend/src/shared/templates/charity_allocations_ootb.ts index 0bb9c65cc..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', diff --git a/functions/src/utils/validation.ts b/functions/src/utils/validation.ts index eaa7e1598..9ad6738f6 100644 --- a/functions/src/utils/validation.ts +++ b/functions/src/utils/validation.ts @@ -185,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}`); } From 37afaf1776fb9d47cdd8d0dd40f8617f6b70d2d5 Mon Sep 17 00:00:00 2001 From: rasmi Date: Wed, 1 Apr 2026 17:07:59 -0400 Subject: [PATCH 10/10] Enforce timeLimitInMinutes, timeMinimumInMinutes to be >=1 if set --- docs/assets/api/schemas.json | 177 +++--------------- .../components/stages/group_chat_editor.ts | 59 +++--- .../components/stages/private_chat_editor.ts | 52 +++-- scripts/deliberate_lab/types.py | 26 +-- utils/src/export-schemas.ts | 75 +++++++- utils/src/stages/chat_stage.validation.ts | 12 +- .../stages/private_chat_stage.validation.ts | 12 +- 7 files changed, 172 insertions(+), 241 deletions(-) diff --git a/docs/assets/api/schemas.json b/docs/assets/api/schemas.json index 917340aaf..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,6 +660,7 @@ "descriptions", "progress", "timeLimitInMinutes", + "timeMinimumInMinutes", "discussions" ], "properties": { @@ -696,24 +683,12 @@ "$ref": "#/$defs/StageProgressConfig" }, "timeLimitInMinutes": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] + "minimum": 1, + "type": ["integer", "null"] }, "timeMinimumInMinutes": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] + "minimum": 1, + "type": ["integer", "null"] }, "discussions": { "type": "array", @@ -1307,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" @@ -1344,7 +1305,8 @@ "name", "descriptions", "progress", - "timeLimitInMinutes" + "timeLimitInMinutes", + "timeMinimumInMinutes" ], "properties": { "id": { @@ -1366,24 +1328,12 @@ "$ref": "#/$defs/StageProgressConfig" }, "timeLimitInMinutes": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] + "minimum": 1, + "type": ["integer", "null"] }, "timeMinimumInMinutes": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] + "minimum": 1, + "type": ["integer", "null"] }, "isTurnBasedChat": { "type": "boolean" @@ -1392,14 +1342,7 @@ "type": "number" }, "maxNumberOfTurns": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] + "type": ["number", "null"] }, "preventCancellation": { "type": "boolean" @@ -1825,14 +1768,7 @@ "type": "integer" }, "maxParticipants": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ] + "type": ["integer", "null"] } }, "title": "Role" @@ -2107,17 +2043,7 @@ ] }, "value": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "number" - }, - { - "type": "boolean" - } - ] + "type": ["string", "number", "boolean"] } } }, @@ -2279,14 +2205,7 @@ } }, "correctAnswerId": { - "anyOf": [ - { - "type": "null" - }, - { - "type": "string" - } - ] + "type": ["null", "string"] }, "displayType": { "title": "MultipleChoiceDisplayType", @@ -2804,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"] } } }, @@ -3141,14 +3039,7 @@ ] }, "reasoningBudget": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ] + "type": ["integer", "null"] }, "includeReasoning": { "type": "boolean" @@ -3790,14 +3681,7 @@ ], "properties": { "wordsPerMinute": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] + "type": ["number", "null"] }, "minMessagesBeforeResponding": { "type": "integer" @@ -3806,14 +3690,7 @@ "type": "boolean" }, "maxResponses": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ] + "type": ["integer", "null"] }, "initialMessage": { "type": "string" diff --git a/frontend/src/components/stages/group_chat_editor.ts b/frontend/src/components/stages/group_chat_editor.ts index 439d6172d..08c8b3752 100644 --- a/frontend/src/components/stages/group_chat_editor.ts +++ b/frontend/src/components/stages/group_chat_editor.ts @@ -70,43 +70,35 @@ export class ChatEditor extends MobxLitElement { } private renderTimeLimit() { - const timeLimit = this.stage?.timeLimitInMinutes ?? null; + const timeLimit = this.stage?.timeLimitInMinutes; const updateCheck = () => { - if (!this.stage) return; - if (this.stage.timeLimitInMinutes) { - this.experimentEditor.updateStage({ - ...this.stage, - timeLimitInMinutes: null, - timeMinimumInMinutes: 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 updateMaxTime = (e: InputEvent) => { - if (!this.stage) return; - const timeLimit = Math.floor( - Number((e.target as HTMLTextAreaElement).value), - ); + 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 updateMinTime = (e: InputEvent) => { - if (!this.stage) return; - const val = Math.floor(Number((e.target as HTMLInputElement).value)); - const max = this.stage.timeLimitInMinutes; - const clamped = max !== null ? Math.min(val, max) : val; + 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, - timeMinimumInMinutes: clamped > 0 ? clamped : null, + ...this.stage!, + timeMinimumInMinutes, }); }; @@ -115,24 +107,26 @@ export class ChatEditor extends MobxLitElement {
Disable conversation after a fixed amount of time
- ${timeLimit !== null + ${timeLimit != null ? html`
{ - if (!this.stage) return; - if (this.stage.timeLimitInMinutes) { - this.experimentEditor.updateStage({ - ...this.stage, - timeLimitInMinutes: null, - timeMinimumInMinutes: 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 updateMaxTime = (e: InputEvent) => { - if (!this.stage) return; - const timeLimit = Math.floor( - Number((e.target as HTMLTextAreaElement).value), - ); + 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 updateMinTime = (e: InputEvent) => { - if (!this.stage) return; - const val = Math.floor(Number((e.target as HTMLInputElement).value)); - const max = this.stage.timeLimitInMinutes; - const clamped = max !== null ? Math.min(val, max) : val; + 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, - timeMinimumInMinutes: clamped > 0 ? clamped : null, + ...this.stage!, + timeMinimumInMinutes, }); }; @@ -86,14 +78,14 @@ export class ChatEditor extends MobxLitElement {
Disable conversation after a fixed amount of time
- ${timeLimit !== null + ${timeLimit != null ? html`