diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6a2c83f5f..0ad3df0d3 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -77,77 +77,3 @@ jobs: - name: Test frontend working-directory: ./frontend run: npm test - - update-schemas: - name: Update generated schemas - - runs-on: ubuntu-latest - - # Only run on push to main/master, not on PRs - if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') - - permissions: - contents: write - pull-requests: write - - steps: - - uses: actions/checkout@v4 - with: - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Use Node.js 22.x - uses: actions/setup-node@v4 - with: - node-version: 22.x - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.12' - - - name: Install uv - uses: astral-sh/setup-uv@v4 - - - name: Install dependencies - run: | - npm install - cd scripts && uv sync - - - name: Build utils - working-directory: ./utils - run: npm run build - - - name: Regenerate schemas - run: | - cd utils && npx tsx src/export-schemas.ts - npx prettier --write ../docs/assets/api/schemas.json - cd ../scripts && uv run datamodel-codegen \ - --input ../docs/assets/api/schemas.json \ - --output deliberate_lab/types.py \ - --input-file-type jsonschema \ - --reuse-model \ - --collapse-root-models \ - --use-union-operator \ - --use-title-as-name \ - --output-model-type pydantic_v2.BaseModel \ - --custom-file-header $'# pyright: reportInvalidTypeForm=false\n# pylint: disable=missing-module-docstring,missing-class-docstring,invalid-name,too-few-public-methods' - - - name: Format Python files - run: | - cd scripts && uvx black deliberate_lab/ - - - name: Validate generated Python - run: | - cd scripts && uv run pyright deliberate_lab/ - - - name: Create PR if schemas changed - uses: peter-evans/create-pull-request@v8 - with: - token: ${{ secrets.GITHUB_TOKEN }} - commit-message: "chore: update generated schemas and Python types" - title: "chore: update generated schemas and Python types" - body: "🤖 Auto-generated by GitHub Actions" - branch: auto-update-schemas - add-paths: | - docs/assets/api/schemas.json - scripts/deliberate_lab/ diff --git a/docs/assets/api/schemas.json b/docs/assets/api/schemas.json index 13235d2cb..3052bad8f 100644 --- a/docs/assets/api/schemas.json +++ b/docs/assets/api/schemas.json @@ -341,6 +341,9 @@ "items": { "type": "string" } + }, + "youtubeVideoId": { + "type": "string" } }, "required": [ @@ -1005,28 +1008,9 @@ "type": "string" }, "type": { - "allOf": [ - { - "anyOf": [ - { - "const": "static", - "type": "string" - }, - { - "const": "random_permutation", - "type": "string" - }, - { - "const": "balanced_assignment", - "type": "string" - } - ] - }, - { - "const": "static", - "type": "string" - } - ] + "default": "static", + "const": "static", + "type": "string" }, "scope": { "anyOf": [ @@ -1255,28 +1239,9 @@ "type": "string" }, "type": { - "allOf": [ - { - "anyOf": [ - { - "const": "static", - "type": "string" - }, - { - "const": "random_permutation", - "type": "string" - }, - { - "const": "balanced_assignment", - "type": "string" - } - ] - }, - { - "const": "random_permutation", - "type": "string" - } - ] + "default": "random_permutation", + "const": "random_permutation", + "type": "string" }, "scope": { "anyOf": [ @@ -1363,28 +1328,9 @@ "type": "string" }, "type": { - "allOf": [ - { - "anyOf": [ - { - "const": "static", - "type": "string" - }, - { - "const": "random_permutation", - "type": "string" - }, - { - "const": "balanced_assignment", - "type": "string" - } - ] - }, - { - "const": "balanced_assignment", - "type": "string" - } - ] + "default": "balanced_assignment", + "const": "balanced_assignment", + "type": "string" }, "scope": { "anyOf": [ @@ -1894,9 +1840,45 @@ }, "progress": { "$ref": "#/$defs/StageProgressConfig" + }, + "timeLimitInMinutes": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "requireFullTime": { + "type": "boolean" + }, + "isTurnBasedChat": { + "type": "boolean" + }, + "minNumberOfTurns": { + "type": "number" + }, + "maxNumberOfTurns": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] } }, - "required": ["id", "kind", "name", "descriptions", "progress"], + "required": [ + "id", + "kind", + "name", + "descriptions", + "progress", + "timeLimitInMinutes" + ], "title": "PrivateChatStageConfig" }, "ProfileStageConfig": { diff --git a/package.json b/package.json index dc5d02155..23e37b516 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ ] }, "scripts": { - "prepare": "husky" + "prepare": "husky", + "update-schemas": "npm run build --workspace=utils && npx tsx utils/src/export-schemas.ts && npx prettier --write docs/assets/api/schemas.json && cd scripts && uv run datamodel-codegen --input ../docs/assets/api/schemas.json --output deliberate_lab/types.py --input-file-type jsonschema --reuse-model --collapse-root-models --use-union-operator --use-title-as-name --use-one-literal-as-default --output-model-type pydantic_v2.BaseModel --custom-file-header '# pyright: reportInvalidTypeForm=false\n# pylint: disable=missing-module-docstring,missing-class-docstring,invalid-name,too-few-public-methods' && uv run black deliberate_lab/ && uv run pyright deliberate_lab/" }, "workspaces": [ "frontend", diff --git a/scripts/deliberate_lab/types.py b/scripts/deliberate_lab/types.py index 97a36cc2a..a7dd6708c 100644 --- a/scripts/deliberate_lab/types.py +++ b/scripts/deliberate_lab/types.py @@ -138,10 +138,7 @@ class CohortParticipantConfig(BaseModel): minParticipantsPerCohort: confloat(ge=0.0) | None = None maxParticipantsPerCohort: confloat(ge=1.0) | None = None includeAllParticipantsInCohortCount: bool - - -class Type(BaseModel): - pass + botProtection: bool class Scope(Enum): @@ -154,28 +151,28 @@ class String(BaseModel): model_config = ConfigDict( extra="allow", ) - type: Literal["string"] + type: Literal["string"] = "string" class Number(BaseModel): model_config = ConfigDict( extra="allow", ) - type: Literal["number"] + type: Literal["number"] = "number" class Integer(BaseModel): model_config = ConfigDict( extra="allow", ) - type: Literal["integer"] + type: Literal["integer"] = "integer" class Boolean(BaseModel): model_config = ConfigDict( extra="allow", ) - type: Literal["boolean"] + type: Literal["boolean"] = "boolean" class Seed(Enum): @@ -233,7 +230,7 @@ class TextQuestion(BaseModel): extra="forbid", ) id: constr(min_length=1) - kind: Literal["text"] + kind: Literal["text"] = "text" questionTitle: str correctAnswer: str @@ -243,7 +240,7 @@ class McQuestion(BaseModel): extra="forbid", ) id: constr(min_length=1) - kind: Literal["mc"] + kind: Literal["mc"] = "mc" questionTitle: str options: List[MultipleChoiceItem] correctAnswerId: str @@ -260,7 +257,7 @@ class DefaultStageConfig(BaseModel): extra="forbid", ) id: constr(min_length=1) - type: Literal["DEFAULT"] + type: Literal["DEFAULT"] = "DEFAULT" name: str description: str isActive: bool @@ -330,7 +327,7 @@ class CohortUpdate(BaseModel): class ChatStageConfig(BaseModel): id: str - kind: Literal["chat"] + kind: Literal["chat"] = "chat" name: str descriptions: Any progress: Any @@ -343,7 +340,7 @@ class ChipStageConfig(BaseModel): extra="forbid", ) id: str - kind: Literal["chip"] + kind: Literal["chip"] = "chip" name: str descriptions: Any progress: Any @@ -357,7 +354,7 @@ class FlipCardStageConfig(BaseModel): extra="forbid", ) id: constr(min_length=1) - kind: Literal["flipcard"] + kind: Literal["flipcard"] = "flipcard" name: constr(min_length=1) descriptions: Any progress: Any @@ -374,11 +371,12 @@ class InfoStageConfig(BaseModel): extra="forbid", ) id: constr(min_length=1) - kind: Literal["info"] + kind: Literal["info"] = "info" name: constr(min_length=1) descriptions: Any progress: Any infoLines: List[str] + youtubeVideoId: str | None = None class ItemRankingStageConfig(BaseModel): @@ -386,11 +384,11 @@ class ItemRankingStageConfig(BaseModel): extra="forbid", ) id: constr(min_length=1) - kind: Literal["ranking"] + kind: Literal["ranking"] = "ranking" name: constr(min_length=1) descriptions: Any progress: Any - rankingType: Literal["items"] + rankingType: Literal["items"] = "items" strategy: Strategy rankingItems: List[RankingItem] @@ -400,11 +398,11 @@ class ParticipantRankingStageConfig(BaseModel): extra="forbid", ) id: constr(min_length=1) - kind: Literal["ranking"] + kind: Literal["ranking"] = "ranking" name: constr(min_length=1) descriptions: Any progress: Any - rankingType: Literal["participants"] + rankingType: Literal["participants"] = "participants" strategy: Strategy enableSelfVoting: bool @@ -414,7 +412,7 @@ class ComparisonCondition(BaseModel): extra="forbid", ) id: constr(min_length=1) - type: Literal["comparison"] + type: Literal["comparison"] = "comparison" target: ConditionTargetReference operator: ComparisonOperator = Field(..., title="ComparisonOperator") value: str | float | bool @@ -425,7 +423,7 @@ class AssetAllocationStageConfig(BaseModel): extra="forbid", ) id: constr(min_length=1) - kind: Literal["assetAllocation"] + kind: Literal["assetAllocation"] = "assetAllocation" name: constr(min_length=1) descriptions: Any progress: Any @@ -437,7 +435,7 @@ class MultiAssetAllocationStageConfig(BaseModel): extra="forbid", ) id: constr(min_length=1) - kind: Literal["multiAssetAllocation"] + kind: Literal["multiAssetAllocation"] = "multiAssetAllocation" name: constr(min_length=1) descriptions: Any progress: Any @@ -450,7 +448,7 @@ class ComprehensionStageConfig(BaseModel): extra="forbid", ) id: constr(min_length=1) - kind: Literal["comprehension"] + kind: Literal["comprehension"] = "comprehension" name: constr(min_length=1) descriptions: Any progress: Any @@ -459,10 +457,15 @@ class ComprehensionStageConfig(BaseModel): class PrivateChatStageConfig(BaseModel): id: str - kind: Literal["privateChat"] + kind: Literal["privateChat"] = "privateChat" name: str descriptions: Any progress: Any + timeLimitInMinutes: float | None = None + requireFullTime: bool | None = None + isTurnBasedChat: bool | None = None + minNumberOfTurns: float | None = None + maxNumberOfTurns: float | None = None class ProfileStageConfig(BaseModel): @@ -470,7 +473,7 @@ class ProfileStageConfig(BaseModel): extra="forbid", ) id: constr(min_length=1) - kind: Literal["profile"] + kind: Literal["profile"] = "profile" name: constr(min_length=1) descriptions: Any progress: Any @@ -482,7 +485,7 @@ class RevealStageConfig(BaseModel): extra="forbid", ) id: constr(min_length=1) - kind: Literal["reveal"] + kind: Literal["reveal"] = "reveal" name: constr(min_length=1) descriptions: Any progress: Any @@ -494,7 +497,7 @@ class RoleStageConfig(BaseModel): extra="forbid", ) id: constr(min_length=1) - kind: Literal["role"] + kind: Literal["role"] = "role" name: constr(min_length=1) descriptions: Any progress: Any @@ -503,7 +506,7 @@ class RoleStageConfig(BaseModel): class SalespersonStageConfig(BaseModel): id: str - kind: Literal["salesperson"] + kind: Literal["salesperson"] = "salesperson" name: str descriptions: Any progress: Any @@ -514,7 +517,7 @@ class StockinfoStageConfig(BaseModel): extra="forbid", ) id: constr(min_length=1) - kind: Literal["stockinfo"] + kind: Literal["stockinfo"] = "stockinfo" name: constr(min_length=1) descriptions: Any progress: Any @@ -529,7 +532,7 @@ class TosStageConfig(BaseModel): extra="forbid", ) id: constr(min_length=1) - kind: Literal["tos"] + kind: Literal["tos"] = "tos" name: constr(min_length=1) descriptions: Any progress: Any @@ -541,7 +544,7 @@ class TransferStageConfig(BaseModel): extra="forbid", ) id: constr(min_length=1) - kind: Literal["transfer"] + kind: Literal["transfer"] = "transfer" name: constr(min_length=1) descriptions: Any progress: Any @@ -654,7 +657,7 @@ class SurveyPerParticipantStageConfig(BaseModel): extra="forbid", ) id: constr(min_length=1) - kind: Literal["surveyPerParticipant"] + kind: Literal["surveyPerParticipant"] = "surveyPerParticipant" name: constr(min_length=1) descriptions: Any progress: Any @@ -672,7 +675,7 @@ class TextSurveyQuestion(BaseModel): extra="forbid", ) id: constr(min_length=1) - kind: Literal["text"] + kind: Literal["text"] = "text" questionTitle: str condition: ComparisonCondition | ConditionGroup | None = Field( None, title="Condition" @@ -686,7 +689,7 @@ class ConditionGroup(BaseModel): extra="forbid", ) id: constr(min_length=1) - type: Literal["group"] + type: Literal["group"] = "group" operator: ConditionOperator = Field(..., title="ConditionOperator") conditions: List[ComparisonCondition | ConditionGroup] @@ -696,7 +699,7 @@ class CheckSurveyQuestion(BaseModel): extra="forbid", ) id: constr(min_length=1) - kind: Literal["check"] + kind: Literal["check"] = "check" questionTitle: str isRequired: bool condition: ComparisonCondition | ConditionGroup | None = Field( @@ -709,7 +712,7 @@ class MultipleChoiceSurveyQuestion(BaseModel): extra="forbid", ) id: constr(min_length=1) - kind: Literal["mc"] + kind: Literal["mc"] = "mc" questionTitle: str options: List[MultipleChoiceItem] correctAnswerId: str | None = None @@ -723,7 +726,7 @@ class ScaleSurveyQuestion(BaseModel): extra="forbid", ) id: constr(min_length=1) - kind: Literal["scale"] + kind: Literal["scale"] = "scale" questionTitle: str upperValue: float upperText: str @@ -742,7 +745,7 @@ class SurveyStageConfig(BaseModel): extra="forbid", ) id: constr(min_length=1) - kind: Literal["survey"] + kind: Literal["survey"] = "survey" name: constr(min_length=1) descriptions: Any progress: Any @@ -756,7 +759,7 @@ class SurveyStageConfig(BaseModel): class StaticVariableConfig(BaseModel): id: constr(min_length=1) - type: Type + type: Literal["static"] = "static" scope: Scope definition: VariableDefinition value: str @@ -766,7 +769,7 @@ class Object(BaseModel): model_config = ConfigDict( extra="allow", ) - type: Literal["object"] + type: Literal["object"] = "object" properties: ( Dict[ constr(pattern=r"^(.*)$"), @@ -780,7 +783,7 @@ class Array(BaseModel): model_config = ConfigDict( extra="allow", ) - type: Literal["array"] + type: Literal["array"] = "array" items: String | Number | Integer | Boolean | Object | Array | None = Field( None, title="JSONSchemaDefinition" ) @@ -799,7 +802,7 @@ class VariableDefinition(BaseModel): class RandomPermutationVariableConfig(BaseModel): id: constr(min_length=1) - type: Type + type: Literal["random_permutation"] = "random_permutation" scope: Scope definition: VariableDefinition shuffleConfig: ShuffleConfig = Field(..., title="ShuffleConfig") @@ -810,7 +813,7 @@ class RandomPermutationVariableConfig(BaseModel): class BalancedAssignmentVariableConfig(BaseModel): id: constr(min_length=1) - type: Type + type: Literal["balanced_assignment"] = "balanced_assignment" scope: Scope definition: VariableDefinition values: List[str] @@ -824,7 +827,7 @@ class PayoutStageConfig(BaseModel): extra="forbid", ) id: constr(min_length=1) - kind: Literal["payout"] + kind: Literal["payout"] = "payout" name: constr(min_length=1) descriptions: Any progress: Any diff --git a/utils/src/export-schemas.ts b/utils/src/export-schemas.ts index 5cefb48c3..44cbca195 100644 --- a/utils/src/export-schemas.ts +++ b/utils/src/export-schemas.ts @@ -232,6 +232,55 @@ const fixedSchema = fixRefs(combinedSchema) as Record; // Restore root $id fixedSchema.$id = 'DeliberateLabSchemas'; +/** + * Simplify allOf structures created by Type.Composite. + * + * When Type.Composite combines a base schema (with anyOf for multiple type options) + * and a specific schema (with const for one specific type), it creates: + * { allOf: [{ anyOf: [...] }, { const: "specific", default: "specific" }] } + * + * This is semantically correct but too complex for datamodel-codegen to parse. + * We simplify it to just the const schema since it's the more specific constraint. + */ +function simplifyAllOf(obj: unknown): unknown { + if (obj === null || typeof obj !== 'object') { + return obj; + } + + if (Array.isArray(obj)) { + return obj.map((item) => simplifyAllOf(item)); + } + + const record = obj as Record; + + // Check if this is an allOf that can be simplified + if (Array.isArray(record.allOf) && record.allOf.length === 2) { + const [first, second] = record.allOf as [ + Record, + Record, + ]; + + // Pattern: allOf with anyOf (union) + const (specific value) + // Keep only the const schema since it's more specific + if (first.anyOf && second.const !== undefined) { + return simplifyAllOf(second); + } + if (second.anyOf && first.const !== undefined) { + return simplifyAllOf(first); + } + } + + // Recursively process all properties + const result: Record = {}; + for (const [key, value] of Object.entries(record)) { + result[key] = simplifyAllOf(value); + } + return result; +} + +const simplifiedSchema = simplifyAllOf(fixedSchema) as Record; +simplifiedSchema.$id = 'DeliberateLabSchemas'; + /** * Second pass: replace inline schemas that have the same title as a $defs entry with $refs. * This deduplicates schemas that appear multiple times inline. @@ -292,13 +341,13 @@ function deduplicateWithRefs( } // Get $defs from the schema -const defs = (fixedSchema.$defs || {}) as Record; +const defs = (simplifiedSchema.$defs || {}) as Record; // Deduplicate inline schemas -const deduplicatedSchema = deduplicateWithRefs(fixedSchema, defs) as Record< - string, - unknown ->; +const deduplicatedSchema = deduplicateWithRefs( + simplifiedSchema, + defs, +) as Record; deduplicatedSchema.$id = 'DeliberateLabSchemas'; // Write to docs/assets/api for public access diff --git a/utils/src/stages/info_stage.validation.ts b/utils/src/stages/info_stage.validation.ts index 23972f315..9db03dd8e 100644 --- a/utils/src/stages/info_stage.validation.ts +++ b/utils/src/stages/info_stage.validation.ts @@ -21,6 +21,8 @@ export const InfoStageConfigData = Type.Object( descriptions: Type.Ref(StageTextConfigSchema), progress: Type.Ref(StageProgressConfigSchema), infoLines: Type.Array(Type.String()), + // Optional YouTube video ID to display + youtubeVideoId: Type.Optional(Type.String()), }, {$id: 'InfoStageConfig', ...strict}, ); diff --git a/utils/src/stages/private_chat_stage.validation.ts b/utils/src/stages/private_chat_stage.validation.ts index af639d2e1..88e3e5c10 100644 --- a/utils/src/stages/private_chat_stage.validation.ts +++ b/utils/src/stages/private_chat_stage.validation.ts @@ -15,4 +15,17 @@ export const PrivateChatStageConfigData = Type.Object({ name: Type.String(), descriptions: Type.Ref(StageTextConfigSchema), progress: Type.Ref(StageProgressConfigSchema), + // 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()), + // 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()), + // Minimum number of messages participant must send to move on + minNumberOfTurns: Type.Optional(Type.Number()), + // If turn based chat set to true, this specifies the max + // number of messages the participant can send + maxNumberOfTurns: Type.Optional(Type.Union([Type.Number(), Type.Null()])), }); diff --git a/utils/src/variables.validation.ts b/utils/src/variables.validation.ts index 0958dc2e4..66383f6fc 100644 --- a/utils/src/variables.validation.ts +++ b/utils/src/variables.validation.ts @@ -93,7 +93,9 @@ export const StaticVariableConfigData = Type.Composite( BaseVariableConfigData, Type.Object( { - type: Type.Literal(VariableConfigType.STATIC), + type: Type.Literal(VariableConfigType.STATIC, { + default: VariableConfigType.STATIC, + }), value: Type.String(), }, strict, @@ -108,7 +110,9 @@ export const RandomPermutationVariableConfigData = Type.Composite( BaseVariableConfigData, Type.Object( { - type: Type.Literal(VariableConfigType.RANDOM_PERMUTATION), + type: Type.Literal(VariableConfigType.RANDOM_PERMUTATION, { + default: VariableConfigType.RANDOM_PERMUTATION, + }), shuffleConfig: ShuffleConfigData, values: Type.Array(Type.String()), numToSelect: Type.Optional(Type.Number({minimum: 1})), @@ -126,7 +130,9 @@ export const BalancedAssignmentVariableConfigData = Type.Composite( BaseVariableConfigData, Type.Object( { - type: Type.Literal(VariableConfigType.BALANCED_ASSIGNMENT), + type: Type.Literal(VariableConfigType.BALANCED_ASSIGNMENT, { + default: VariableConfigType.BALANCED_ASSIGNMENT, + }), values: Type.Array(Type.String()), weights: Type.Optional(Type.Array(Type.Number({minimum: 1}))), balanceStrategy: Type.Union([