diff --git a/apps/nextjs/src/components/AppComponents/Chat/chat-lessonPlanDisplay.stories.tsx b/apps/nextjs/src/components/AppComponents/Chat/chat-lessonPlanDisplay.stories.tsx index 7cff21bc8..5d03e5921 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/chat-lessonPlanDisplay.stories.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/chat-lessonPlanDisplay.stories.tsx @@ -14,7 +14,7 @@ const chatContext: Partial = { keyStage: "Key Stage 2", subject: "Science", topic: "Amphibians", - basedOn: { title: "Frogs in Modern Britain" }, + basedOn: { title: "Frogs in Modern Britain", id: "123" }, learningOutcome: "To understand the importance of frogs in British society and culture", }, diff --git a/apps/nextjs/src/middlewares/auth.middleware.ts b/apps/nextjs/src/middlewares/auth.middleware.ts index 04e1e69b0..0235b4cf3 100644 --- a/apps/nextjs/src/middlewares/auth.middleware.ts +++ b/apps/nextjs/src/middlewares/auth.middleware.ts @@ -72,11 +72,9 @@ const isPreloadableRoute = createRouteMatcher([ const isOnboardingRoute = createRouteMatcher([ "/onboarding", - "/sign-in", // NOTE: Be careful that this request doesn't batch as it will change the path "/api/trpc/main/auth.setDemoStatus", "/api/trpc/main/auth.acceptTerms", - "/api/trpc/main", ]); const isHomepage = createRouteMatcher(["/"]); @@ -120,6 +118,14 @@ function conditionallyProtectRoute( return NextResponse.redirect(new URL("/onboarding", req.url)); } } + if ( + userId && + isOnboardingRoute(req) && + !needsToCompleteOnboarding(sessionClaims) + ) { + log("Already onboarded: REDIRECT"); + return NextResponse.redirect(new URL("/", req.url)); + } if (isPublicRoute(req)) { log("Public route: ALLOW"); diff --git a/apps/nextjs/tests-e2e/helpers/auth/index.ts b/apps/nextjs/tests-e2e/helpers/auth/index.ts index 58f38846e..c358273c2 100644 --- a/apps/nextjs/tests-e2e/helpers/auth/index.ts +++ b/apps/nextjs/tests-e2e/helpers/auth/index.ts @@ -32,7 +32,9 @@ export async function prepareUser( | "nearly-banned" | "modify-lesson-plan" | "nearly-rate-limited" - | "sharing-chat", + | "sharing-chat" + | "needs-onboarding" + | "needs-demo-status", ) { return await test.step("Prepare user", async () => { const [login] = await Promise.all([ diff --git a/apps/nextjs/tests-e2e/tests/onboarding.test.ts b/apps/nextjs/tests-e2e/tests/onboarding.test.ts new file mode 100644 index 000000000..3a6030aa2 --- /dev/null +++ b/apps/nextjs/tests-e2e/tests/onboarding.test.ts @@ -0,0 +1,64 @@ +import { test, expect } from "@playwright/test"; + +import { TEST_BASE_URL } from "../config/config"; +import { prepareUser } from "../helpers/auth"; +import { bypassVercelProtection } from "../helpers/vercel"; + +test("Landing on the site when not onboarded", async ({ page }) => { + await test.step("Setup", async () => { + await bypassVercelProtection(page); + await prepareUser(page, "needs-onboarding"); + await page.goto(`${TEST_BASE_URL}/aila`); + + await page.waitForURL(`${TEST_BASE_URL}/onboarding`); + await expect( + page.getByText("This product is experimental and uses AI"), + ).toBeVisible(); + }); + + await test.step("Toggle terms", async () => { + const termsHeading = page.getByRole("heading", { + name: "Terms and Conditions", + exact: true, + }); + await expect(termsHeading).not.toBeVisible(); + + await page.getByText("See terms").click(); + await expect(termsHeading).toBeVisible(); + await page.getByText("See terms").click(); + await expect(termsHeading).not.toBeVisible(); + }); + + await test.step("Submit", async () => { + await page.getByText("I understand").click(); + + await page.waitForURL(`${TEST_BASE_URL}/?reason=onboarded`); + }); +}); + +test("Onboarded without a demo status", async ({ page }) => { + await test.step("Setup", async () => { + await bypassVercelProtection(page); + await prepareUser(page, "needs-demo-status"); + await page.goto(`${TEST_BASE_URL}/aila`); + + await page.waitForURL(`${TEST_BASE_URL}/onboarding`); + await expect(page.getByText("Preparing your account")).toBeVisible(); + }); + + await test.step("Redirect", async () => { + await page.waitForURL(`${TEST_BASE_URL}/?reason=metadata-upgraded`); + }); +}); + +test("Loading onboarding when already onboarded", async ({ page }) => { + await test.step("Setup", async () => { + await bypassVercelProtection(page); + await prepareUser(page, "typical"); + await page.goto(`${TEST_BASE_URL}/onboarding`); + }); + + await test.step("Redirect", async () => { + await page.waitForURL(`${TEST_BASE_URL}/`); + }); +}); diff --git a/packages/aila/package.json b/packages/aila/package.json index 020372e30..ec8e36caa 100644 --- a/packages/aila/package.json +++ b/packages/aila/package.json @@ -35,6 +35,7 @@ "ai": "^3.3.26", "american-british-english-translator": "^0.2.1", "cloudinary": "^1.41.1", + "dedent": "^1.5.3", "dotenv-cli": "^6.0.0", "jsonrepair": "^3.8.0", "openai": "^4.58.1", @@ -51,6 +52,7 @@ "@pollyjs/adapter-node-http": "^6.0.6", "@pollyjs/core": "^6.0.6", "@pollyjs/persister-fs": "^6.0.6", + "@types/dedent": "^0.7.2", "@types/jest": "^29.5.14", "jest": "^29.7.0", "setup-polly-jest": "^0.11.0", diff --git a/packages/aila/src/protocol/jsonPatchProtocol.ts b/packages/aila/src/protocol/jsonPatchProtocol.ts index f3c07e02f..1c5f9d7a1 100644 --- a/packages/aila/src/protocol/jsonPatchProtocol.ts +++ b/packages/aila/src/protocol/jsonPatchProtocol.ts @@ -516,7 +516,7 @@ export type MessagePartType = | "id"; export const MessagePartDocumentSchemaByType: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars + // eslint-disable-next-line @typescript-eslint/no-explicit-any [K in MessagePartType]: z.ZodSchema; } = { moderation: ModerationDocumentSchema, @@ -533,7 +533,7 @@ export const MessagePartDocumentSchemaByType: { id: MessageIdDocumentSchema, }; -const MessagePartSchema = z.object({ +export const MessagePartSchema = z.object({ type: z.literal("message-part"), id: z.string(), isPartial: z.boolean(), @@ -843,21 +843,5 @@ export function applyLessonPlanPatch( }); } - // #TODO This is slightly ridiculous! Remove this once we are live - if (updatedLessonPlan.misconceptions) { - const working = updatedLessonPlan.misconceptions; - for (const misconception of working) { - misconception.description = - misconception.description ?? misconception.definition; - } - updatedLessonPlan.misconceptions = working; - } - if (updatedLessonPlan.keywords) { - const working = updatedLessonPlan.keywords; - for (const keyword of working) { - keyword.definition = keyword.definition ?? keyword.description; - } - updatedLessonPlan.keywords = working; - } return updatedLessonPlan; } diff --git a/packages/aila/src/protocol/schema.ts b/packages/aila/src/protocol/schema.ts index 9e4a6fcbd..242cdd58f 100644 --- a/packages/aila/src/protocol/schema.ts +++ b/packages/aila/src/protocol/schema.ts @@ -1,79 +1,64 @@ +import dedent from "dedent"; import z from "zod"; import { zodToJsonSchema } from "zod-to-json-schema"; +import { minMaxText } from "./schemaHelpers"; + +// ********** BASED_ON ********** +export const BASED_ON_DESCRIPTIONS = { + id: dedent`The database ID of an existing lesson plan that this lesson plan is based upon. + It should be the ID of the lesson plan that was used to inform the content of this lesson plan. + It should not be the number that the user has selected when choosing which lesson on which to base their new lesson.`, + title: "The human-readable title of the lesson.", + schema: dedent`A reference to a lesson plan that this lesson is based on. + This value should only be set if the user has explicitly chosen to base their lesson on an existing lesson plan by selecting one from a selection of options, otherwise this should be blank.`, +} as const; + export const BasedOnSchema = z .object({ - id: z - .string() - .describe( - "The database ID of an existing lesson plan that this lesson plan is based upon. It should be the ID of the lesson plan that was used to inform the content of this lesson plan. It should not be the number that the user has selected when choosing which lesson on which to base their new lesson.", - ), - title: z.string().describe("The human-readable title of the lesson."), + id: z.string().describe(BASED_ON_DESCRIPTIONS.id), + title: z.string().describe(BASED_ON_DESCRIPTIONS.title), }) - .describe( - "A reference to a lesson plan that this lesson is based on. This value should only be set if the user has explicitly chosen to base their lesson on an existing lesson plan by selecting one from a selection of options, otherwise this should be blank.", - ); - -export const BasedOnOptionalSchema = z.object({ - id: z.string().optional(), - title: z.string().optional(), -}); - + .describe(BASED_ON_DESCRIPTIONS.schema); export type BasedOn = z.infer; + +export const BasedOnOptionalSchema = BasedOnSchema.partial(); export type BasedOnOptional = z.infer; -export const MisconceptionSchema = z.object({ - misconception: z - .string() - .describe( - "a single sentence describing a common misconception about the topic. Do not end with a full stop.", - ), - description: z - .string() - .max(250) - .describe( - "No more than 2 sentences addressing the reason for the misconception and how it can be addressed in the lesson.", - ), - definition: z - .string() - .optional() - .describe("Not to be used. Only included here for legacy support."), // There is some confusion here about what the LLM generates -}); +// ********** MISCONCEPTIONS ********** +export const MISCONCEPTION_DESCRIPTIONS = { + misconception: dedent`a single sentence describing a common misconception about the topic. Do not end with a full stop.`, + description: dedent`No more than 2 sentences addressing the reason for the misconception and how it can be addressed in the lesson.`, +} as const; + +export const MISCONCEPTIONS_DESCRIPTION = { + schema: dedent` + A set of potential misconceptions which students might have about the content that is delivered in the lesson. + Written in the EXPERT_TEACHER voice. + ${minMaxText({ min: 1, max: 3, entity: "elements" })}`, +} as const as { schema: string }; -// When using Structured Outputs we cannot specify the length of arrays or strings export const MisconceptionSchemaWithoutLength = z.object({ - misconception: z - .string() - .describe( - "a single sentence describing a common misconception about the topic. Do not end with a full stop.", - ), - description: z - .string() - .describe( - "No more than 2 sentences addressing the reason for the misconception and how it can be addressed in the lesson. Maximum 250 characters in length.", - ), + misconception: z.string().describe(MISCONCEPTION_DESCRIPTIONS.misconception), + description: z.string().describe(MISCONCEPTION_DESCRIPTIONS.description), }); -export const MisconceptionOptionalSchema = z.object({ - misconception: z.string().optional(), - description: z.string().optional(), - definition: z.string().optional(), // There is some confusion here about what the LLM generates +export const MisconceptionSchema = MisconceptionSchemaWithoutLength.extend({ + description: MisconceptionSchemaWithoutLength.shape.description.max(250), }); -export const MisconceptionsSchema = z - .array(MisconceptionSchema) - .min(1) - .max(3) - .describe( - "An array of misconceptions which a student might have about the topic covered in the lesson.", - ); +export const MisconceptionOptionalSchema = + MisconceptionSchemaWithoutLength.extend({ + definition: z.string().optional(), + }).partial(); -// When using Structured Outputs we cannot specify the length of arrays or strings export const MisconceptionsSchemaWithoutLength = z .array(MisconceptionSchemaWithoutLength) - .describe( - "An array of misconceptions which a student might have about the topic covered in the lesson. Minimum 1, maximum 3 items.", - ); + .describe(MISCONCEPTIONS_DESCRIPTION.schema); + +export const MisconceptionsSchema = + MisconceptionsSchemaWithoutLength.min(1).max(3); + export const MisconceptionsOptionalSchema = z.array( MisconceptionOptionalSchema, ); @@ -84,29 +69,31 @@ export type MisconceptionsOptional = z.infer< typeof MisconceptionsOptionalSchema >; -export const QuizQuestionOptionalSchema = z.object({ - question: z.string().optional(), - answers: z.array(z.string()).optional(), - distractors: z.array(z.string()).optional(), -}); +// ********** QUIZ ********** -export const QuizQuestionSchema = z.object({ - question: z.string().describe("The question to be asked in the quiz."), - answers: z.array(z.string()).length(1).describe("The correct answer."), - distractors: z.array(z.string()).length(2).describe("A set of distractors."), -}); +export const QUIZ_DESCRIPTIONS = { + question: "The question to be asked in the quiz.", + answers: "The correct answer. This should be an array of only one item.", + distractors: "A set of distractors. This must be an array with two items.", +} as const as { + question: string; + answers: string; + distractors: string; +}; -// When using Structured Outputs we cannot specify the length of arrays or strings export const QuizQuestionSchemaWithoutLength = z.object({ - question: z.string().describe("The question to be asked in the quiz."), - answers: z - .array(z.string()) - .describe("The correct answer. This should be an array of only one item."), - distractors: z - .array(z.string()) - .describe("A set of distractors. This must be an array with two items."), + question: z.string().describe(QUIZ_DESCRIPTIONS.question), + answers: z.array(z.string()).describe(QUIZ_DESCRIPTIONS.answers), + distractors: z.array(z.string()).describe(QUIZ_DESCRIPTIONS.distractors), +}); + +export const QuizQuestionSchema = QuizQuestionSchemaWithoutLength.extend({ + answers: QuizQuestionSchemaWithoutLength.shape.answers.length(1), + distractors: QuizQuestionSchemaWithoutLength.shape.distractors.length(2), }); +export const QuizQuestionOptionalSchema = QuizQuestionSchema.partial(); + export type QuizQuestion = z.infer; export type QuizQuestionOptional = z.infer; @@ -117,392 +104,295 @@ export const QuizOptionalSchema = z.array(QuizQuestionOptionalSchema); export type Quiz = z.infer; export type QuizOptional = z.infer; +// ********** EXPLANATION ********** +export const EXPLANATION_DESCRIPTIONS = { + spokenExplanation: dedent`The spoken teacher explanation in the EXPERT_TEACHER voice. + About five or six sentences or 5-12 markdown list items. + In the spoken explanation, give guidance to the teacher on which key points should be covered in their explanation to students. + This should be teacher facing and not include a script or narrative for the teacher. + Should bear in mind the following requirements: + - teacher facing + - outline the key points that the teacher should address during their explanation + - include as much detail as possible + - written in bullet points + - break concepts into small, manageable chunks + - if appropriate, the method for explanation should be included for e.g. demonstrate to pupils how to set up the apparatus for the experiment or model how to complete the pass for students or use a bar model to show the addition of these three numbers. + - abstract concepts are made concrete + - the explanation should be clear + - images will be used to support dual coding for pupils`, + accompanyingSlideDetails: dedent`A description of the image or video to be used. + Should describe what should be displayed on the slides for pupils to look at during the explanation. + This is to enable dual coding of a visual image and the teachers explanation being given verbally. + Written in the EXPERT_TEACHER voice.`, + imagePrompt: dedent`A prompt to generate an image, or a search term to use to find an image or video. + Should tell the teacher what images to search for to display on the slides. + Written in the EXPERT_TEACHER voice.`, + slideText: dedent`The slide text would appear on the slide when the teacher delivers this part of the lesson. + It should be a short, succinct summary of the content in the explanation. + It should be no longer than 2 sentences. + Written in the TEACHER_TO_PUPIL_SLIDES voice.`, + schema: dedent`The TEACHER EXPLANATION, obeying the learning cycle rules for this part of the lesson.`, +} as const; + export const ExplanationSchema = z .object({ - spokenExplanation: z.union([z.string(), z.array(z.string())]).describe( - `The spoken teacher explanation in the EXPERT_TEACHER voice. About five or six sentences or 5-12 markdown list items. In the spoken explanation, give guidance to the teacher on which key points should be covered in their explanation to students. This should be teacher facing and not include a script or narrative for the teacher. - -Should bear in mind the following requirements: -- teacher facing -- outline the key points that the teacher should address during their explanation -- include as much detail as possible -- written in bullet points -- break concepts into small, manageable chunks -- if appropriate, the method for explanation should be included for e.g. demonstrate to pupils how to set up the apparatus for the experiment or model how to complete the pass for students or use a bar model to show the addition of these three numbers. -- abstract concepts are made concrete -- the explanation should be clear -- images will be used to support dual coding for pupils -- this is not a script!`, - ), + spokenExplanation: z + .union([z.string(), z.array(z.string())]) + .describe(EXPLANATION_DESCRIPTIONS.spokenExplanation), accompanyingSlideDetails: z .string() - .describe( - "A description of the image or video to be used. Should describe what should be displayed on the slides for pupils to look at during the explanation. This is to enable dual coding of a visual image and the teachers explanation being given verbally. Written in the EXPERT_TEACHER voice.", - ), - imagePrompt: z - .string() - .describe( - "A prompt to generate an image, or a search term to use to find an image or video. Should tell the teacher what images to search for to display on the slides. Written in the EXPERT_TEACHER voice.", - ), - slideText: z - .string() - .describe( - "The slide text would appear on the slide when the teacher delivers this part of the lesson. It should be a short, succinct summary of the content in the explanation. It should be no longer than 2 sentences. Written in the TEACHER_TO_PUPIL_SLIDES voice.", - ), + .describe(EXPLANATION_DESCRIPTIONS.accompanyingSlideDetails), + imagePrompt: z.string().describe(EXPLANATION_DESCRIPTIONS.imagePrompt), + slideText: z.string().describe(EXPLANATION_DESCRIPTIONS.slideText), }) - .describe( - "The TEACHER EXPLANATION, obeying the learning cycle rules for this part of the lesson.", - ); - -export const ExplanationOptionalSchema = z.object({ - spokenExplanation: z.union([z.string(), z.array(z.string())]).optional(), // (about five or six sentences or 5-12 markdown list items) - accompanyingSlideDetails: z.string().optional(), // (a description of the image or video to be used) - imagePrompt: z.string().optional(), // (a prompt to generate an image, or a search term to use to find an image or video), - slideText: z.string().optional(), // The text that would appear on the slide when the teacher delivers this part of the lesson -}); + .describe(EXPLANATION_DESCRIPTIONS.schema); export type Explanation = z.infer; -export type ExplanationOptional = z.infer; -export const CheckForUnderstandingSchema = z.object({ - question: z - .string() - .describe( - "A multiple choice question to ask as a check to see if the students have understood the content of this cycle. Written in the TEACHER_TO_PUPIL_SLIDES voice.", - ), - answers: z - .array(z.string()) - .length(1) - .describe( - "The correct answer to the question. If this is of length ANSWER_LENGTH, then all distractor strings should be very close in length to ANSWER_LENGTH.", - ), - distractors: z - .array(z.string()) - .min(2) - .describe( - "Two incorrect distractors which could be the answer to the question but are not correct. These strings should be of similar length to ANSWER_LENGTH so that the correct answer does not stand out because it is obviously longer than the distractors.", - ), -}); +export const ExplanationOptionalSchema = ExplanationSchema.partial(); +export type ExplanationOptional = z.infer; +// ********** CHECK FOR UNDERSTANDING ********** // When using Structured Outputs we cannot specify the length of arrays or strings +export const CHECK_FOR_UNDERSTANDING_DESCRIPTIONS = { + question: dedent`A multiple choice question to ask as a check to see if the students have understood the content of this cycle. + Written in the TEACHER_TO_PUPIL_SLIDES voice.`, + answers: dedent`The correct answer to the question. + If this is of length ANSWER_LENGTH, then all distractor strings should be very close in length to ANSWER_LENGTH.`, + distractors: dedent`Two incorrect distractors which could be the answer to the question but are not correct. + These strings should be of similar length to ANSWER_LENGTH so that the correct answer does not stand out because it is obviously longer than the distractors.`, +} as const; + export const CheckForUnderstandingSchemaWithoutLength = z.object({ - question: z - .string() - .describe( - "A multiple choice question to ask as a check to see if the students have understood the content of this cycle. Written in the TEACHER_TO_PUPIL_SLIDES voice.", - ), + question: z.string().describe(CHECK_FOR_UNDERSTANDING_DESCRIPTIONS.question), answers: z .array(z.string()) - .describe( - "The correct answer to the question. If this is of length ANSWER_LENGTH, then all distractor strings should be very close in length to ANSWER_LENGTH.", - ), + .describe(CHECK_FOR_UNDERSTANDING_DESCRIPTIONS.answers), distractors: z .array(z.string()) - .describe( - "Two incorrect distractors which could be the answer to the question but are not correct. These strings should be of similar length to ANSWER_LENGTH so that the correct answer does not stand out because it is obviously longer than the distractors.", - ), + .describe(CHECK_FOR_UNDERSTANDING_DESCRIPTIONS.distractors), }); -export const CheckForUnderstandingOptionalSchema = z.object({ - question: z.string().optional(), - answers: z.array(z.string()).optional(), - distractors: z.array(z.string()).optional(), -}); - -export const CycleSchema = z.object({ - title: z.string().describe("The title of the learning cycle"), - durationInMinutes: z - .number() - .describe( - "An estimated duration for how long it would take the teacher to deliver this part of the lesson.", - ), - explanation: ExplanationSchema.describe( - "An object describing how the teacher would explain the content of this cycle to the students. Written in the TEACHER_TO_PUPIL_SLIDES voice.", - ), - checkForUnderstanding: z - .array(CheckForUnderstandingSchema) - .min(2) - .describe( - "Two or more questions to check that students have understood the content of this cycle. Written in the TEACHER_TO_PUPIL_SLIDES voice.", - ), - practice: z - .string() - .describe( - "The activity that the pupils are asked to do to practice what they have learnt. Should be pupil facing and include all details that the pupils need to complete the task. Should be linked to the learning cycle command word and should enable pupils to practice the key learning points that have been taught during this learning cycle. Should include calculations if this is appropriate. Written in the TEACHER_TO_PUPIL_SLIDES voice.", - ), - feedback: z - .string() - .describe( - "Student-facing feedback which will be presented on a slide, giving the correct answer to the practice task. This should adhere to the rules as specified in the LEARNING CYCLES: FEEDBACK section of the lesson plan guidance. Written in the TEACHER_TO_PUPIL_SLIDES voice.", - ), -}); +export const CheckForUnderstandingSchema = + CheckForUnderstandingSchemaWithoutLength.extend({ + answers: CheckForUnderstandingSchemaWithoutLength.shape.answers.length(1), + distractors: + CheckForUnderstandingSchemaWithoutLength.shape.distractors.min(2), + }); + +export const CheckForUnderstandingOptionalSchema = + CheckForUnderstandingSchema.partial(); + +// ********** CYCLE ********** +export const CYCLE_DESCRIPTIONS = { + title: `The title of the learning cycle written in sentence case starting with a capital letter and not ending with a full stop.`, + durationInMinutes: `An estimated duration for how long it would take the teacher to deliver this part of the lesson.`, + explanation: dedent`An object describing how the teacher would explain the content of this cycle to the students. + Written in the TEACHER_TO_PUPIL_SLIDES voice.`, + checkForUnderstanding: dedent`Two or more questions to check that students have understood the content of this cycle. + Written in the TEACHER_TO_PUPIL_SLIDES voice.`, + practice: dedent`The activity that the pupils are asked to do to practice what they have learnt. + Should be pupil facing and include all details that the pupils need to complete the task. + Should be linked to the learning cycle command word and should enable pupils to practice the key learning points that have been taught during this learning cycle. + Should include calculations if this is appropriate. + Written in the TEACHER_TO_PUPIL_SLIDES voice.`, + feedback: dedent`Student-facing feedback which will be presented on a slide, giving the correct answer to the practice task. + This should adhere to the rules as specified in the LEARNING CYCLES: FEEDBACK section of the lesson plan guidance. + Written in the TEACHER_TO_PUPIL_SLIDES voice.`, +} as const; -// When using Structured Outputs we cannot specify the length of arrays or strings export const CycleSchemaWithoutLength = z.object({ - title: z - .string() - .describe( - "The title of the learning cycle written in sentence case starting with a capital letter and not ending with a full stop.", - ), - durationInMinutes: z - .number() - .describe( - "An estimated duration for how long it would take the teacher to deliver this part of the lesson.", - ), - explanation: ExplanationSchema.describe( - "An object describing how the teacher would explain the content of this cycle to the students. Written in the TEACHER_TO_PUPIL_SLIDES voice.", - ), + title: z.string().describe(CYCLE_DESCRIPTIONS.title), + durationInMinutes: z.number().describe(CYCLE_DESCRIPTIONS.durationInMinutes), + explanation: ExplanationSchema.describe(CYCLE_DESCRIPTIONS.explanation), checkForUnderstanding: z .array(CheckForUnderstandingSchemaWithoutLength) - .describe( - "Two or more questions to check that students have understood the content of this cycle. Written in the TEACHER_TO_PUPIL_SLIDES voice.", - ), - practice: z - .string() - .describe( - "The activity that the pupils are asked to do to practice what they have learnt. Should be pupil facing and include all details that the pupils need to complete the task. Should be linked to the learning cycle command word and should enable pupils to practice the key learning points that have been taught during this learning cycle. Should include calculations if this is appropriate. Written in the TEACHER_TO_PUPIL_SLIDES voice.", - ), - feedback: z - .string() - .describe( - "Student-facing feedback which will be presented on a slide, giving the correct answer to the practice task. This should adhere to the rules as specified in the LEARNING CYCLES: FEEDBACK section of the lesson plan guidance. Written in the TEACHER_TO_PUPIL_SLIDES voice.", - ), + .describe(CYCLE_DESCRIPTIONS.checkForUnderstanding), + practice: z.string().describe(CYCLE_DESCRIPTIONS.practice), + feedback: z.string().describe(CYCLE_DESCRIPTIONS.feedback), +}); + +export const CycleSchema = CycleSchemaWithoutLength.extend({ + checkForUnderstanding: z.array(CheckForUnderstandingSchema).min(2), }); -export const CycleOptionalSchema = z.object({ - title: z.string().optional(), - durationInMinutes: z.number().optional(), +export const CycleOptionalSchema = CycleSchemaWithoutLength.extend({ explanation: ExplanationOptionalSchema.optional(), checkForUnderstanding: z .array(CheckForUnderstandingOptionalSchema) .optional(), - practice: z.string().optional(), - feedback: z.string().optional(), -}); +}).partial(); export type CycleOptional = z.infer; export type Cycle = z.infer; -// "keyword" and "definition", where keyword is a word to be included throughout the lesson, and "definition" gives a short definition of the keyword including the keyword itself. Provide no more than 5 total keywords. Each keyword should be a maximum of 30 characters long. Each definition should be a maximum of 200 characters long. -export const KeywordSchema = z - .object({ - keyword: z - .string() - .max(30) - .describe( - "The keyword itself. Should be in sentence case starting with a capital letter and not end with a full stop.", - ), - definition: z - .string() - .max(200) - .describe( - "A short definition of the keyword including the keyword itself. Should be in sentence case starting with a capital letter and not end with a full stop. Written in TEACHER_TO_PUPIL_SLIDES voice.", - ), - description: z - .string() - .optional() - .describe("Not to be used, and included here only for legacy purposes."), // There is some confusion here about what the LLM generates - }) - .describe( - "A keyword that is used in the lesson. Written in the TEACHER_TO_PUPIL_SLIDES voice.", - ); +// ********** KEYWORDS ********** +export const KEYWORD_DESCRIPTIONS = { + keyword: dedent`The keyword itself. + Should be in sentence case starting with a capital letter and not end with a full stop.`, + definition: dedent`A short definition of the keyword including the keyword itself. + Should be in sentence case starting with a capital letter and not end with a full stop. + Written in TEACHER_TO_PUPIL_SLIDES voice.`, + description: "Not to be used, and included here only for legacy purposes.", + schema: dedent`A keyword that is used in the lesson. + Written in the TEACHER_TO_PUPIL_SLIDES voice.`, + keywords: dedent`The keywords that are used in the lesson. + Written in the TEACHER_TO_PUPIL_SLIDES voice. + ${minMaxText({ min: 1, max: 5, entity: "elements" })}`, +} as const; -// When using Structured Outputs we cannot specify the length of arrays or strings export const KeywordSchemaWithoutLength = z .object({ - keyword: z - .string() - .describe( - "The keyword itself. Should be in sentence case starting with a capital letter and not end with a full stop. Maximum 30 characters long.", - ), - definition: z - .string() - .describe( - "A short definition of the keyword including the keyword itself. Should be in sentence case starting with a capital letter and not end with a full stop. Written in TEACHER_TO_PUPIL_SLIDES voice. Maximum 200 characters long.", - ), + keyword: z.string().describe(KEYWORD_DESCRIPTIONS.keyword), + definition: z.string().describe(KEYWORD_DESCRIPTIONS.definition), }) - .describe( - "A keyword that is used in the lesson. Written in the TEACHER_TO_PUPIL_SLIDES voice.", - ); - -export const KeywordOptionalSchema = z.object({ - keyword: z.string().optional(), - definition: z.string().optional(), - description: z.string().optional(), // There is some confusion here about what the LLM generates + .describe(KEYWORD_DESCRIPTIONS.schema); + +export const KeywordSchema = KeywordSchemaWithoutLength.extend({ + keyword: KeywordSchemaWithoutLength.shape.keyword.max(30), + definition: KeywordSchemaWithoutLength.shape.definition.max(200), + description: z.string().optional().describe(KEYWORD_DESCRIPTIONS.description), }); -export const KeywordsSchema = z - .array(KeywordSchema) - .min(1) - .max(5) - .describe( - "A set of keywords where each is a word to be included throughout the lesson.", - ); +export const KeywordOptionalSchema = KeywordSchema.partial(); -// When using Structured Outputs we cannot specify the length of arrays or strings export const KeywordsSchemaWithoutLength = z .array(KeywordSchemaWithoutLength) - .describe( - "A set of keywords where each is a word to be included throughout the lesson. Minimum 1 keyword, maximum 5.", - ); + .describe(KEYWORD_DESCRIPTIONS.keywords); + +export const KeywordsSchema = KeywordsSchemaWithoutLength.min(1).max(5); + export const KeywordsOptionalSchema = z.array(KeywordOptionalSchema); export type KeywordOptional = z.infer; export type Keyword = z.infer; -function minMaxText({ - min, - max, - entity = "elements", -}: { - min?: number; - max?: number; - entity?: "elements" | "characters"; -}) { - if (typeof min !== "number" && typeof max !== "number") { - throw new Error("min or max must be provided"); - } - if (typeof min === "number" && typeof max === "number") { - return `Minimum ${min}, maximum ${max} ${entity}`; - } - if (typeof min === "number") { - return `Minimum ${min} ${entity}`; - } - return `Maximum ${max} ${entity}`; -} - +// ********** LESSON PLAN ********** +export const LESSON_PLAN_DESCRIPTIONS = { + title: dedent`The title of the lesson. Lesson titles should be a unique and succinct statement, not a question. + Can include special characters if appropriate but should not use & sign instead of 'and'. + Written in the TEACHER_TO_PUPIL_SLIDES voice. + The title should be in sentence case starting with a capital letter and not end with a full stop. + ${minMaxText({ + max: 80, + entity: "characters", + })}`, + keyStage: + "The lesson's Key Stage as defined by UK educational standards. In slug format (kebab-case).", + subject: + "The subject that this lesson is included within, for instance English or Geography.", + topic: + "A topic that this lesson would sit within, which might cover several lessons with a shared theme.", + learningOutcome: dedent`What the pupils will have learnt by the end of the lesson. + Should start with 'I can' and outline what pupils should be able to know/and or be able to do by the end of the lesson. + Written in age appropriate language. + May include a command word. + Written in the PUPIL voice. + ${minMaxText({ max: 190, entity: "characters" })}`, + learningCycles: dedent`An array of learning cycle outcomes. + Should include a command word. + Should be succinct. + Should outline what pupils should be able to do/understand/know by the end of the learning cycle. + Written in the TEACHER_TO_PUPIL_SLIDES voice. + ${minMaxText({ + min: 1, + max: 3, + entity: "elements", + })}`, + priorKnowledge: dedent`An array of prior knowledge statements, each being a succinct sentence. + Written in the EXPERT_TEACHER voice. + ${minMaxText({ + min: 1, + max: 5, + entity: "elements", + })}`, + keyLearningPoints: dedent`An array of learning points, each being a succinct sentence. + ${minMaxText({ + min: 3, + max: 5, + entity: "elements", + })}`, + starterQuiz: dedent`The starter quiz for the lesson, which tests prior knowledge only, ignoring the content that is delivered in the lesson. + Obey the rules as specified in the STARTER QUIZ section of the lesson plan guidance. + Written in the TEACHER_TO_PUPIL_SLIDES voice.`, + exitQuiz: dedent`The exit quiz for the lesson, which tests the content that is delivered in the lesson. + Written in the TEACHER_TO_PUPIL_SLIDES voice.`, + additionalMaterials: + "Any additional materials or notes that are required or useful for the lesson", +} as const; + +export const LessonTitleSchema = z + .string() + .describe(LESSON_PLAN_DESCRIPTIONS.title); + +export const KeyStageSchema = z + .union([ + z.enum([ + "key-stage-1", + "key-stage-2", + "key-stage-3", + "key-stage-4", + "key-stage-5", + "early-years-foundation-stage", + "specialist", + "further-education", + "higher-education", + ]), + z.string(), + ]) + .describe(LESSON_PLAN_DESCRIPTIONS.keyStage); + +export const SubjectSchema = z + .string() + .describe(LESSON_PLAN_DESCRIPTIONS.subject); + +export const TopicSchema = z.string().describe(LESSON_PLAN_DESCRIPTIONS.topic); + +export const LearningOutcomeSchema = z + .string() + .describe(LESSON_PLAN_DESCRIPTIONS.learningOutcome); + +export const LearningCyclesSchema = z + .array(z.string()) + .describe(LESSON_PLAN_DESCRIPTIONS.learningCycles); + +export const PriorKnowledgeSchema = z + .array(z.string()) + .describe(LESSON_PLAN_DESCRIPTIONS.priorKnowledge); + +export const KeyLearningPointsSchema = z + .array(z.string()) + .describe(LESSON_PLAN_DESCRIPTIONS.keyLearningPoints); + +export const AdditionalMaterialsSchema = z + .string() + .optional() + .describe(LESSON_PLAN_DESCRIPTIONS.additionalMaterials); + +// Main schema export const CompletedLessonPlanSchema = z.object({ - title: z.string().describe( - `The title of the lesson. Lesson titles should be a unique and succinct statement, not a question. Can include special characters if appropriate but should not use & sign instead of 'and'. Written in the TEACHER_TO_PUPIL_SLIDES voice. The title should be in sentence case starting with a capital letter and not end with a full stop. ${minMaxText( - { - max: 80, - entity: "characters", - }, - )}`, - ), - keyStage: z - .union([ - z.enum([ - "key-stage-1", - "key-stage-2", - "key-stage-3", - "key-stage-4", - "key-stage-5", - "early-years-foundation-stage", - "specialist", - "further-education", - "higher-education", - ]), - z.string(), - ]) - .describe( - "The lesson's Key Stage as defined by UK educational standards. In slug format (kebab-case).", - ), - subject: z - .string() - .describe( - "The subject that this lesson is included within, for instance English or Geography.", - ), - - topic: z - .string() - .describe( - "A topic that this lesson would sit within, which might cover several lessons with a shared theme.", - ), - learningOutcome: z.string().describe( - `What the pupils will have learnt by the end of the lesson. Should start with 'I can' and outline what pupils should be able to know/and or be able to do by the end of the lesson. Written in age appropriate language. May include a command word. Written in the PUPIL voice. ${minMaxText( - { - max: 190, - entity: "characters", - }, - )}`, - ), - learningCycles: z - .array(z.string().describe(minMaxText({ max: 120, entity: "characters" }))) - .describe( - `An array of learning cycle outcomes. Should include a command word. Should be succinct. Should outline what pupils should be able to do/understand/know by the end of the learning cycle. Written in the TEACHER_TO_PUPIL_SLIDES voice. ${minMaxText( - { - min: 1, - max: 3, - entity: "elements", - }, - )}`, - ), - priorKnowledge: z - .array(z.string().describe(minMaxText({ max: 190, entity: "characters" }))) - .describe( - `An array of prior knowledge statements, each being a succinct sentence. Written in the EXPERT_TEACHER voice. ${minMaxText( - { - min: 1, - max: 5, - entity: "elements", - }, - )}`, - ), - keyLearningPoints: z - .array( - z.string().describe( - `The misconception itself, in a single sentence, not ending with a full stop. Written in the EXPERT_TEACHER voice. ${minMaxText( - { - max: 120, - entity: "characters", - }, - )}`, - ), - ) - .describe( - `An array of learning points, each being a succinct sentence. ${minMaxText( - { - min: 3, - max: 5, - entity: "elements", - }, - )}`, - ), - misconceptions: MisconceptionsSchemaWithoutLength.describe( - "A set of potential misconceptions which students might have about the content that is delivered in the lesson. Written in the EXPERT_TEACHER voice.", - ), - keywords: KeywordsSchemaWithoutLength.describe( - "The keywords that are used in the lesson. Written in the TEACHER_TO_PUPIL_SLIDES voice.", - ), + title: LessonTitleSchema, + keyStage: KeyStageSchema, + subject: SubjectSchema, + topic: TopicSchema, + learningOutcome: LearningOutcomeSchema, + learningCycles: LearningCyclesSchema, + priorKnowledge: PriorKnowledgeSchema, + keyLearningPoints: KeyLearningPointsSchema, + misconceptions: MisconceptionsSchema, + keywords: KeywordsSchema, basedOn: BasedOnSchema.optional(), - starterQuiz: QuizSchemaWithoutLength.describe( - "The starter quiz for the lesson, which tests prior knowledge only, ignoring the content that is delivered in the lesson. Obey the rules as specified in the STARTER QUIZ section of the lesson plan guidance. Written in the TEACHER_TO_PUPIL_SLIDES voice.", - ), - cycle1: CycleSchemaWithoutLength.describe("The first learning cycle"), - cycle2: CycleSchemaWithoutLength.describe("The second learning cycle"), - cycle3: CycleSchemaWithoutLength.describe("The third learning cycle"), - exitQuiz: QuizSchemaWithoutLength.describe( - "The exit quiz for the lesson, which tests the content that is delivered in the lesson. Written in the TEACHER_TO_PUPIL_SLIDES voice.", - ), - additionalMaterials: z - .string() - .optional() - .describe( - "Any additional materials or notes that are required or useful for the lesson", - ), + starterQuiz: QuizSchema.describe(LESSON_PLAN_DESCRIPTIONS.starterQuiz), + cycle1: CycleSchema.describe("The first learning cycle"), + cycle2: CycleSchema.describe("The second learning cycle"), + cycle3: CycleSchema.describe("The third learning cycle"), + exitQuiz: QuizSchema.describe(LESSON_PLAN_DESCRIPTIONS.exitQuiz), + additionalMaterials: AdditionalMaterialsSchema, }); export type CompletedLessonPlan = z.infer; -export const LessonPlanSchema = z.object({ - title: z.string().optional(), - subject: z.string().optional(), - keyStage: z.string().optional(), - topic: z.string().optional(), - learningOutcome: z.string().optional(), - learningCycles: z.array(z.string()).optional(), - priorKnowledge: z.array(z.string()).optional(), - keyLearningPoints: z.array(z.string()).optional(), - misconceptions: MisconceptionsOptionalSchema.optional(), - keywords: KeywordsOptionalSchema.optional(), - basedOn: BasedOnOptionalSchema.optional(), - starterQuiz: QuizOptionalSchema.optional(), - cycle1: CycleOptionalSchema.optional(), - cycle2: CycleOptionalSchema.optional(), - cycle3: CycleOptionalSchema.optional(), - exitQuiz: QuizOptionalSchema.optional(), - additionalMaterials: z.string().optional(), +export const LessonPlanSchema = CompletedLessonPlanSchema.partial().extend({ _experimental_starterQuizMathsV0: QuizOptionalSchema.optional(), _experimental_exitQuizMathsV0: QuizOptionalSchema.optional(), }); @@ -512,63 +402,8 @@ export const LessonPlanSchemaWhilstStreaming = LessonPlanSchema; // TODO old - refactor these to the new types export type LooseLessonPlan = z.infer; -export const LessonPlanKeysSchema = z.enum([ - "title", - "subject", - "keyStage", - "topic", - "learningOutcome", - "learningCycles", - "priorKnowledge", - "keyLearningPoints", - "misconceptions", - "keywords", - "basedOn", - "starterQuiz", - "exitQuiz", - "cycle1", - "cycle2", - "cycle3", - "additionalMaterials", -]); - -export type LessonPlanKeys = z.infer; -export const quizSchema = z.array(QuizSchema); - -export const cycleSchema = CycleSchema; - -export const keywordSchema = z.object({ - keyword: z.string(), - definition: z.string(), -}); - -export const misconceptionSchema = z.object({ - misconception: z.string(), - description: z.string(), -}); - -// #TODO Is this unused? -export const lessonPlanForDocsSchema = z - .object({ - title: z.string(), - keyStage: z.string().optional(), - subject: z.string().optional(), - learningOutcome: z.string().optional(), - learningCycles: z.array(z.string()).optional(), - priorKnowledge: z.array(z.string()).optional(), - keyLearningPoints: z.array(z.string()).optional(), - misconceptions: z.array(MisconceptionSchema).optional(), - keywords: z.array(KeywordSchema).optional(), - starterQuiz: QuizSchema.optional(), - cycle1: CycleSchema.optional(), - cycle2: CycleSchema.optional(), - cycle3: CycleSchema.optional(), - exitQuiz: QuizSchema.optional(), - additionalMaterials: z.string().optional(), - }) - .passthrough(); -export type LessonPlanForDocsSchema = z.infer; +export type LessonPlanKeys = keyof typeof CompletedLessonPlanSchema.shape; export const LessonPlanJsonSchema = zodToJsonSchema( CompletedLessonPlanSchema, diff --git a/packages/aila/src/protocol/schemaDescriptions.ts b/packages/aila/src/protocol/schemaDescriptions.ts new file mode 100644 index 000000000..e69de29bb diff --git a/packages/aila/src/protocol/schemaHelpers.ts b/packages/aila/src/protocol/schemaHelpers.ts new file mode 100644 index 000000000..88193bdc2 --- /dev/null +++ b/packages/aila/src/protocol/schemaHelpers.ts @@ -0,0 +1,20 @@ +export function minMaxText({ + min, + max, + entity = "elements", +}: { + min?: number; + max?: number; + entity?: "elements" | "characters"; +}) { + if (typeof min !== "number" && typeof max !== "number") { + throw new Error("min or max must be provided"); + } + if (typeof min === "number" && typeof max === "number") { + return `Minimum ${min}, maximum ${max} ${entity}`; + } + if (typeof min === "number") { + return `Minimum ${min} ${entity}`; + } + return `Maximum ${max} ${entity}`; +} diff --git a/packages/api/src/router/testSupport/personas.ts b/packages/api/src/router/testSupport/personas.ts new file mode 100644 index 000000000..58fb9e3fe --- /dev/null +++ b/packages/api/src/router/testSupport/personas.ts @@ -0,0 +1,97 @@ +const GENERATIONS_PER_24H = parseInt( + process.env.RATELIMIT_GENERATIONS_PER_24H ?? "120", + 10, +); + +export const personaNames = [ + "typical", + "demo", + "nearly-banned", + "nearly-rate-limited", + "sharing-chat", + "modify-lesson-plan", + "needs-onboarding", + "needs-demo-status", +] as const; + +export type PersonaName = (typeof personaNames)[number]; +export type Persona = { + isOnboarded: boolean; + isDemoUser: boolean | null; + region: "GB" | "US"; + chatFixture: "typical" | null; + safetyViolations: number; + rateLimitTokens: number; +}; + +export const personas: Record = { + // A user with no issues and a completed lesson plan + typical: { + isOnboarded: true, + isDemoUser: false, + region: "GB", + chatFixture: "typical", + safetyViolations: 0, + rateLimitTokens: 0, + }, + // A user from a demo region + demo: { + isOnboarded: true, + isDemoUser: true, + region: "US", + chatFixture: null, + safetyViolations: 0, + rateLimitTokens: 0, + }, + // A user with 3 safety violations - will be banned with one more + "nearly-banned": { + isOnboarded: true, + isDemoUser: false, + region: "GB", + chatFixture: null, + safetyViolations: 3, + rateLimitTokens: 0, + }, + // A user with 119 of their 120 generations remaining + "nearly-rate-limited": { + isOnboarded: true, + isDemoUser: false, + region: "GB", + chatFixture: null, + safetyViolations: 0, + rateLimitTokens: GENERATIONS_PER_24H - 1, + }, + // Allows `chat.isShared` to be set/reset without leaking between tests/retries + "sharing-chat": { + isOnboarded: true, + isDemoUser: false, + region: "GB", + chatFixture: "typical", + safetyViolations: 0, + rateLimitTokens: 0, + }, + "modify-lesson-plan": { + isOnboarded: true, + isDemoUser: false, + region: "GB", + chatFixture: "typical", + safetyViolations: 0, + rateLimitTokens: 0, + }, + "needs-onboarding": { + isOnboarded: false, + isDemoUser: false, + region: "GB", + chatFixture: null, + safetyViolations: 0, + rateLimitTokens: 0, + }, + "needs-demo-status": { + isOnboarded: true, + isDemoUser: null, + region: "GB", + chatFixture: null, + safetyViolations: 0, + rateLimitTokens: 0, + }, +} as const; diff --git a/packages/api/src/router/testSupport/prepareUser.ts b/packages/api/src/router/testSupport/prepareUser.ts index 4059173a5..b5066e8a1 100644 --- a/packages/api/src/router/testSupport/prepareUser.ts +++ b/packages/api/src/router/testSupport/prepareUser.ts @@ -1,92 +1,21 @@ import { clerkClient } from "@clerk/nextjs/server"; -import { isClerkAPIResponseError } from "@clerk/shared"; import { aiLogger } from "@oakai/logger"; import { waitUntil } from "@vercel/functions"; import os from "os"; import { z } from "zod"; import { publicProcedure } from "../../trpc"; -import { setRateLimitTokens } from "./rateLimiting"; -import { setSafetyViolations } from "./safetyViolations"; -import { seedChat } from "./seedChat"; +import { personaNames, personas } from "./personas"; +import { deleteOldTestUser } from "./userPreparation/clerkUsers"; +import { setClerkMetadata } from "./userPreparation/metadata"; +import { setRateLimitTokens } from "./userPreparation/rateLimiting"; +import { setSafetyViolations } from "./userPreparation/safetyViolations"; +import { seedChat } from "./userPreparation/seedChat"; const log = aiLogger("testing"); const branch = process.env.VERCEL_GIT_COMMIT_REF ?? os.hostname(); -const GENERATIONS_PER_24H = parseInt( - process.env.RATELIMIT_GENERATIONS_PER_24H ?? "120", - 10, -); - -const personaNames = [ - "typical", - "demo", - "nearly-banned", - "nearly-rate-limited", - "sharing-chat", - "modify-lesson-plan", -] as const; - -type PersonaName = (typeof personaNames)[number]; -type Persona = { - isDemoUser: boolean; - region: "GB" | "US"; - chatFixture: "typical" | null; - safetyViolations: number; - rateLimitTokens: number; -}; - -const personas: Record = { - // A user with no issues and a completed lesson plan - typical: { - isDemoUser: false, - region: "GB", - chatFixture: "typical", - safetyViolations: 0, - rateLimitTokens: 0, - }, - // A user from a demo region - demo: { - isDemoUser: true, - region: "US", - chatFixture: null, - safetyViolations: 0, - rateLimitTokens: 0, - }, - // A user with 3 safety violations - will be banned with one more - "nearly-banned": { - isDemoUser: false, - region: "GB", - chatFixture: null, - safetyViolations: 3, - rateLimitTokens: 0, - }, - // A user with 119 of their 120 generations remaining - "nearly-rate-limited": { - isDemoUser: false, - region: "GB", - chatFixture: null, - safetyViolations: 0, - rateLimitTokens: GENERATIONS_PER_24H - 1, - }, - // Allows `chat.isShared` to be set/reset without leaking between tests/retries - "sharing-chat": { - isDemoUser: false, - region: "GB", - chatFixture: "typical", - safetyViolations: 0, - rateLimitTokens: 0, - }, - "modify-lesson-plan": { - isDemoUser: false, - region: "GB", - chatFixture: "typical", - safetyViolations: 0, - rateLimitTokens: 0, - }, -} as const; - /** * @example test+adams-macbook-pro-local+typical+clerk_test@thenational.academy */ @@ -104,59 +33,10 @@ const generateEmailAddress = (personaName: keyof typeof personas) => { return `${parts.join("+")}@thenational.academy`; }; -const deleteOldTestUser = async () => { - const result = await clerkClient.users.getUserList({ - limit: 500, - }); - - const NUMBERS_USER = /\d{5,10}.*@/; // jim+010203@thenational.academy - const testUsers = result.data.filter((u) => { - const email = u.primaryEmailAddress?.emailAddress ?? ""; - return email.startsWith("test+") || email.match(NUMBERS_USER); - }); - - if (testUsers.length < 100) { - log.info(`less than 100 test users. Skipping cleanup.`); - return; - } - - const users = testUsers.toSorted( - (a, b) => - new Date(a.lastActiveAt ?? a.createdAt).getTime() - - new Date(b.lastActiveAt ?? b.createdAt).getTime(), - ); - - // If multiple personas are created at the same time and both try to delete the - // oldest user they will conflict. Add some randomness to reduce conflicts - const randomOffset = Math.floor(Math.random() * 8); - const userToDelete = users[randomOffset]; - - if (userToDelete) { - try { - await clerkClient.users.deleteUser(userToDelete.id); - log.info( - "Deleted old test user", - userToDelete.primaryEmailAddress?.emailAddress, - ); - } catch (e) { - if (isClerkAPIResponseError(e) && e.status === 404) { - log.info( - `${userToDelete.primaryEmailAddress?.emailAddress} already deleted, retrying`, - ); - await deleteOldTestUser(); - } else { - throw e; - } - } - } -}; - const findOrCreateUser = async ( email: string, personaName: keyof typeof personas, ) => { - const persona = personas[personaName]; - const existingUser = ( await clerkClient.users.getUserList({ emailAddress: [email], @@ -175,18 +55,6 @@ const findOrCreateUser = async ( emailAddress: [email], firstName: branch, lastName: personaName, - - publicMetadata: { - labs: { - isOnboarded: true, - isDemoUser: persona.isDemoUser, - }, - }, - privateMetadata: { - acceptedPrivacyPolicy: new Date(), - acceptedTermsOfUse: new Date(), - region: persona.region, - }, }); waitUntil(deleteOldTestUser()); @@ -210,8 +78,15 @@ export const prepareUser = publicProcedure if (persona.chatFixture) { chatId = await seedChat(user.id, persona.chatFixture); } - await setSafetyViolations(user.id, persona.safetyViolations); - await setRateLimitTokens(user.id, persona.rateLimitTokens); + await Promise.all([ + setSafetyViolations(user.id, persona.safetyViolations), + setRateLimitTokens(user.id, persona.rateLimitTokens), + setClerkMetadata(user.id, user, { + isOnboarded: persona.isOnboarded, + isDemoUser: persona.isDemoUser, + region: persona.region, + }), + ]); return { email, chatId }; }); diff --git a/packages/api/src/router/testSupport/userPreparation/clerkUsers.ts b/packages/api/src/router/testSupport/userPreparation/clerkUsers.ts new file mode 100644 index 000000000..27eb7d554 --- /dev/null +++ b/packages/api/src/router/testSupport/userPreparation/clerkUsers.ts @@ -0,0 +1,52 @@ +import { clerkClient } from "@clerk/nextjs/server"; +import { isClerkAPIResponseError } from "@clerk/shared"; +import { aiLogger } from "@oakai/logger"; + +const log = aiLogger("testing"); + +export const deleteOldTestUser = async () => { + const result = await clerkClient.users.getUserList({ + limit: 500, + }); + + const NUMBERS_USER = /\d{5,10}.*@/; // jim+010203@thenational.academy + const testUsers = result.data.filter((u) => { + const email = u.primaryEmailAddress?.emailAddress ?? ""; + return email.startsWith("test+") || email.match(NUMBERS_USER); + }); + + if (testUsers.length < 100) { + log.info(`less than 100 test users. Skipping cleanup.`); + return; + } + + const users = testUsers.toSorted( + (a, b) => + new Date(a.lastActiveAt ?? a.createdAt).getTime() - + new Date(b.lastActiveAt ?? b.createdAt).getTime(), + ); + + // If multiple personas are created at the same time and both try to delete the + // oldest user they will conflict. Add some randomness to reduce conflicts + const randomOffset = Math.floor(Math.random() * 8); + const userToDelete = users[randomOffset]; + + if (userToDelete) { + try { + await clerkClient.users.deleteUser(userToDelete.id); + log.info( + "Deleted old test user", + userToDelete.primaryEmailAddress?.emailAddress, + ); + } catch (e) { + if (isClerkAPIResponseError(e) && e.status === 404) { + log.info( + `${userToDelete.primaryEmailAddress?.emailAddress} already deleted, retrying`, + ); + await deleteOldTestUser(); + } else { + throw e; + } + } + } +}; diff --git a/packages/api/src/router/testSupport/userPreparation/metadata.ts b/packages/api/src/router/testSupport/userPreparation/metadata.ts new file mode 100644 index 000000000..5206a47a4 --- /dev/null +++ b/packages/api/src/router/testSupport/userPreparation/metadata.ts @@ -0,0 +1,83 @@ +import { clerkClient } from "@clerk/nextjs/server"; +import type { User } from "@clerk/nextjs/server"; +import { aiLogger } from "@oakai/logger"; +import { difference, equals } from "remeda"; + +type MetadataArgs = { + isOnboarded: boolean; + isDemoUser: boolean | null; + region: "GB" | "US"; +}; +const ONBOARDING_DATE = new Date("2025-01-01"); +const log = aiLogger("testing"); + +const buildMetadata = (metadata: MetadataArgs) => { + if (!metadata.isOnboarded) { + return { + publicMetadata: {}, + privateMetadata: {}, + }; + } + + return { + publicMetadata: { + labs: { + isOnboarded: true, + isDemoUser: metadata.isDemoUser, + }, + }, + privateMetadata: { + acceptedPrivacyPolicy: ONBOARDING_DATE, + acceptedTermsOfUse: ONBOARDING_DATE, + region: metadata.region, + }, + }; +}; + +const deleteExtraKeys = ( + proposedMetadata: Record, + currentMetadata: Record, +) => { + const keysToDelete = difference( + Object.keys(currentMetadata), + Object.keys(proposedMetadata), + ); + return { + ...proposedMetadata, + ...Object.fromEntries(keysToDelete.map((key) => [key, null])), + }; +}; + +export const setClerkMetadata = async ( + userId: string, + user: User, + metadata: MetadataArgs, +) => { + const proposedMetadata = buildMetadata(metadata); + const currentMetadata = { + publicMetadata: user.publicMetadata, + privateMetadata: user.privateMetadata, + }; + + if (equals(proposedMetadata, currentMetadata)) { + log.info("Clerk metadata is already correct"); + } else { + log.info( + "Updating clerk metadata", + deleteExtraKeys( + proposedMetadata.publicMetadata, + currentMetadata.publicMetadata, + ), + ); + await clerkClient.users.updateUserMetadata(userId, { + publicMetadata: deleteExtraKeys( + proposedMetadata.publicMetadata, + currentMetadata.publicMetadata, + ), + privateMetadata: deleteExtraKeys( + proposedMetadata.privateMetadata, + currentMetadata.privateMetadata, + ), + }); + } +}; diff --git a/packages/api/src/router/testSupport/rateLimiting/index.ts b/packages/api/src/router/testSupport/userPreparation/rateLimiting.ts similarity index 100% rename from packages/api/src/router/testSupport/rateLimiting/index.ts rename to packages/api/src/router/testSupport/userPreparation/rateLimiting.ts diff --git a/packages/api/src/router/testSupport/safetyViolations/index.ts b/packages/api/src/router/testSupport/userPreparation/safetyViolations.ts similarity index 100% rename from packages/api/src/router/testSupport/safetyViolations/index.ts rename to packages/api/src/router/testSupport/userPreparation/safetyViolations.ts diff --git a/packages/api/src/router/testSupport/seedChat/index.ts b/packages/api/src/router/testSupport/userPreparation/seedChat/index.ts similarity index 100% rename from packages/api/src/router/testSupport/seedChat/index.ts rename to packages/api/src/router/testSupport/userPreparation/seedChat/index.ts diff --git a/packages/api/src/router/testSupport/seedChat/typical.json b/packages/api/src/router/testSupport/userPreparation/seedChat/typical.json similarity index 100% rename from packages/api/src/router/testSupport/seedChat/typical.json rename to packages/api/src/router/testSupport/userPreparation/seedChat/typical.json diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8d6362414..93fddf59f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -501,6 +501,9 @@ importers: cloudinary: specifier: ^1.41.1 version: 1.41.1 + dedent: + specifier: ^1.5.3 + version: 1.5.3 dotenv-cli: specifier: ^6.0.0 version: 6.0.0 @@ -544,6 +547,9 @@ importers: '@pollyjs/persister-fs': specifier: ^6.0.6 version: 6.0.6 + '@types/dedent': + specifier: ^0.7.2 + version: 0.7.2 '@types/jest': specifier: ^29.5.14 version: 29.5.14 @@ -6745,8 +6751,8 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false - /@radix-ui/react-alert-dialog@1.1.3(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-5xzWppXTNZe6zFrTTwAJIoMJeZmdFe0l8ZqQrPGKAVvhdyOWR4r53/G7SZqx6/uf1J441oxK7GzmTkrrWDroHA==} + /@radix-ui/react-alert-dialog@1.1.4(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-A6Kh23qZDLy3PSU4bh2UJZznOrUdHImIXqF8YtUa6CN73f8EOO9XlXSCd9IHyPvIquTaa/kwaSWzZTtUvgXVGw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -6761,7 +6767,7 @@ packages: '@radix-ui/primitive': 1.1.1 '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.2.57)(react@18.2.0) '@radix-ui/react-context': 1.1.1(@types/react@18.2.57)(react@18.2.0) - '@radix-ui/react-dialog': 1.1.3(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-dialog': 1.1.4(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-primitive': 2.0.1(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-slot': 1.1.1(@types/react@18.2.57)(react@18.2.0) '@types/react': 18.2.57 @@ -6983,8 +6989,8 @@ packages: react: 18.2.0 dev: false - /@radix-ui/react-context-menu@2.2.3(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-i4ZjZNoiAKwxcaKBR5XdiOyEJQdBT4P6TeMtzP4fjlcDJpxwIcmmWkdd13YEzCHHcWYZOyl7fVHKT8dFMHdo3w==} + /@radix-ui/react-context-menu@2.2.4(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-ap4wdGwK52rJxGkwukU1NrnEodsUFQIooANKu+ey7d6raQ2biTcEf8za1zr0mgFHieevRTB2nK4dJeN8pTAZGQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -6998,7 +7004,7 @@ packages: dependencies: '@radix-ui/primitive': 1.1.1 '@radix-ui/react-context': 1.1.1(@types/react@18.2.57)(react@18.2.0) - '@radix-ui/react-menu': 2.1.3(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-menu': 2.1.4(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-primitive': 2.0.1(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.2.57)(react@18.2.0) '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.2.57)(react@18.2.0) @@ -7035,8 +7041,8 @@ packages: react: 18.2.0 dev: false - /@radix-ui/react-dialog@1.1.3(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-ujGvqQNkZ0J7caQyl8XuZRj2/TIrYcOGwqz5TeD1OMcCdfBuEMP0D12ve+8J5F9XuNUth3FAKFWo/wt0E/GJrQ==} + /@radix-ui/react-dialog@1.1.4(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-Ur7EV1IwQGCyaAuyDRiOLA5JIUZxELJljF+MbM/2NC0BYwfuRrbpS30BiQBJrVruscgUkieKkqXYDOoByaxIoA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -7051,7 +7057,7 @@ packages: '@radix-ui/primitive': 1.1.1 '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.2.57)(react@18.2.0) '@radix-ui/react-context': 1.1.1(@types/react@18.2.57)(react@18.2.0) - '@radix-ui/react-dismissable-layer': 1.1.2(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-dismissable-layer': 1.1.3(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-focus-guards': 1.1.1(@types/react@18.2.57)(react@18.2.0) '@radix-ui/react-focus-scope': 1.1.1(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-id': 1.1.0(@types/react@18.2.57)(react@18.2.0) @@ -7065,7 +7071,7 @@ packages: aria-hidden: 1.2.4 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - react-remove-scroll: 2.6.0(@types/react@18.2.57)(react@18.2.0) + react-remove-scroll: 2.6.2(@types/react@18.2.57)(react@18.2.0) dev: false /@radix-ui/react-direction@1.0.1(@types/react@18.2.57)(react@18.2.0): @@ -7120,8 +7126,8 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false - /@radix-ui/react-dismissable-layer@1.1.2(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-kEHnlhv7wUggvhuJPkyw4qspXLJOdYoAP4dO2c8ngGuXTq1w/HZp1YeVB+NQ2KbH1iEG+pvOCGYSqh9HZOz6hg==} + /@radix-ui/react-dismissable-layer@1.1.3(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-onrWn/72lQoEucDmJnr8uczSNTujT0vJnA/X5+3AkChVPowr8n1yvIKIabhWyMQeMvvmdpsvcyDqx3X1LEXCPg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -7144,8 +7150,8 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false - /@radix-ui/react-dropdown-menu@2.1.3(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-eKyAfA9e4HOavzyGJC6kiDIlHMPzAU0zqSqTg+VwS0Okvb9nkTo7L4TugkCUqM3I06ciSpdtYQ73cgB7tyUgVw==} + /@radix-ui/react-dropdown-menu@2.1.4(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-iXU1Ab5ecM+yEepGAWK8ZhMyKX4ubFdCNtol4sT9D0OVErG9PNElfx3TQhjw7n7BC5nFVz68/5//clWy+8TXzA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -7161,7 +7167,7 @@ packages: '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.2.57)(react@18.2.0) '@radix-ui/react-context': 1.1.1(@types/react@18.2.57)(react@18.2.0) '@radix-ui/react-id': 1.1.0(@types/react@18.2.57)(react@18.2.0) - '@radix-ui/react-menu': 2.1.3(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-menu': 2.1.4(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-primitive': 2.0.1(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.2.57)(react@18.2.0) '@types/react': 18.2.57 @@ -7230,8 +7236,8 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false - /@radix-ui/react-hover-card@1.1.3(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-D+o67Fd7fjkW10ycdsse1sYuGV9dNQKOhoVii7ksSfUYqQiTPxz9bP/Vu1g6huJ1651/2j8q7JGGWSIBIuGO1Q==} + /@radix-ui/react-hover-card@1.1.4(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-QSUUnRA3PQ2UhvoCv3eYvMnCAgGQW+sTu86QPuNb+ZMi+ZENd6UWpiXbcWDQ4AEaKF9KKpCHBeaJz9Rw6lRlaQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -7246,7 +7252,7 @@ packages: '@radix-ui/primitive': 1.1.1 '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.2.57)(react@18.2.0) '@radix-ui/react-context': 1.1.1(@types/react@18.2.57)(react@18.2.0) - '@radix-ui/react-dismissable-layer': 1.1.2(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-dismissable-layer': 1.1.3(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-popper': 1.2.1(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-portal': 1.1.3(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-presence': 1.1.2(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) @@ -7315,8 +7321,8 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false - /@radix-ui/react-menu@2.1.3(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-wY5SY6yCiJYP+DMIy7RrjF4shoFpB9LJltliVwejBm8T2yepWDJgKBhIFYOGWYR/lFHOCtbstN9duZFu6gmveQ==} + /@radix-ui/react-menu@2.1.4(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-BnOgVoL6YYdHAG6DtXONaR29Eq4nvbi8rutrV/xlr3RQCMMb3yqP85Qiw/3NReozrSW+4dfLkK+rc1hb4wPU/A==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -7333,7 +7339,7 @@ packages: '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.2.57)(react@18.2.0) '@radix-ui/react-context': 1.1.1(@types/react@18.2.57)(react@18.2.0) '@radix-ui/react-direction': 1.1.0(@types/react@18.2.57)(react@18.2.0) - '@radix-ui/react-dismissable-layer': 1.1.2(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-dismissable-layer': 1.1.3(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-focus-guards': 1.1.1(@types/react@18.2.57)(react@18.2.0) '@radix-ui/react-focus-scope': 1.1.1(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-id': 1.1.0(@types/react@18.2.57)(react@18.2.0) @@ -7349,11 +7355,11 @@ packages: aria-hidden: 1.2.4 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - react-remove-scroll: 2.6.0(@types/react@18.2.57)(react@18.2.0) + react-remove-scroll: 2.6.2(@types/react@18.2.57)(react@18.2.0) dev: false - /@radix-ui/react-popover@1.1.3(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-MBDKFwRe6fi0LT8m/Jl4V8J3WbS/UfXJtsgg8Ym5w5AyPG3XfHH4zhBp1P8HmZK83T8J7UzVm6/JpDE3WMl1Dw==} + /@radix-ui/react-popover@1.1.4(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-aUACAkXx8LaFymDma+HQVji7WhvEhpFJ7+qPz17Nf4lLZqtreGOFRiNQWQmhzp7kEWg9cOyyQJpdIMUMPc/CPw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -7368,7 +7374,7 @@ packages: '@radix-ui/primitive': 1.1.1 '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.2.57)(react@18.2.0) '@radix-ui/react-context': 1.1.1(@types/react@18.2.57)(react@18.2.0) - '@radix-ui/react-dismissable-layer': 1.1.2(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-dismissable-layer': 1.1.3(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-focus-guards': 1.1.1(@types/react@18.2.57)(react@18.2.0) '@radix-ui/react-focus-scope': 1.1.1(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-id': 1.1.0(@types/react@18.2.57)(react@18.2.0) @@ -7383,7 +7389,7 @@ packages: aria-hidden: 1.2.4 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - react-remove-scroll: 2.6.0(@types/react@18.2.57)(react@18.2.0) + react-remove-scroll: 2.6.2(@types/react@18.2.57)(react@18.2.0) dev: false /@radix-ui/react-popper@1.1.3(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0): @@ -7656,8 +7662,8 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false - /@radix-ui/react-select@2.1.3(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-tlLwaewTfrKetiex8iW9wwME/qrYlzlH0qcgYmos7xS54MO00SiPHasLoAykg/yVrjf41GQptPPi4oXzrP+sgg==} + /@radix-ui/react-select@2.1.4(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-pOkb2u8KgO47j/h7AylCj7dJsm69BXcjkrvTqMptFqsE2i0p8lHkfgneXKjAgPzBMivnoMyt8o4KiV4wYzDdyQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -7675,7 +7681,7 @@ packages: '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.2.57)(react@18.2.0) '@radix-ui/react-context': 1.1.1(@types/react@18.2.57)(react@18.2.0) '@radix-ui/react-direction': 1.1.0(@types/react@18.2.57)(react@18.2.0) - '@radix-ui/react-dismissable-layer': 1.1.2(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-dismissable-layer': 1.1.3(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-focus-guards': 1.1.1(@types/react@18.2.57)(react@18.2.0) '@radix-ui/react-focus-scope': 1.1.1(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-id': 1.1.0(@types/react@18.2.57)(react@18.2.0) @@ -7693,7 +7699,7 @@ packages: aria-hidden: 1.2.4 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - react-remove-scroll: 2.6.0(@types/react@18.2.57)(react@18.2.0) + react-remove-scroll: 2.6.2(@types/react@18.2.57)(react@18.2.0) dev: false /@radix-ui/react-separator@1.1.1(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0): @@ -7860,8 +7866,8 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false - /@radix-ui/react-tooltip@1.1.5(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-IucoQPcK5nwUuztaxBQvudvYwH58wtRcJlv1qvaMSyIbL9dEBfFN0vRf/D8xDbu6HmAJLlNGty4z8Na+vIqe9Q==} + /@radix-ui/react-tooltip@1.1.6(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-TLB5D8QLExS1uDn7+wH/bjEmRurNMTzNrtq7IjaS4kjion9NtzsTGkvR5+i7yc9q01Pi2KMM2cN3f8UG4IvvXA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -7876,7 +7882,7 @@ packages: '@radix-ui/primitive': 1.1.1 '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.2.57)(react@18.2.0) '@radix-ui/react-context': 1.1.1(@types/react@18.2.57)(react@18.2.0) - '@radix-ui/react-dismissable-layer': 1.1.2(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-dismissable-layer': 1.1.3(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-id': 1.1.0(@types/react@18.2.57)(react@18.2.0) '@radix-ui/react-popper': 1.2.1(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-portal': 1.1.3(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) @@ -8141,27 +8147,27 @@ packages: '@radix-ui/colors': 2.0.1 '@radix-ui/primitive': 1.1.1 '@radix-ui/react-accessible-icon': 1.1.1(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-alert-dialog': 1.1.3(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-alert-dialog': 1.1.4(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-aspect-ratio': 1.1.1(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-avatar': 1.1.2(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-checkbox': 1.1.3(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-context-menu': 2.2.3(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-dialog': 1.1.3(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-context-menu': 2.2.4(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-dialog': 1.1.4(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-direction': 1.1.0(@types/react@18.2.57)(react@18.2.0) - '@radix-ui/react-dropdown-menu': 2.1.3(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-dropdown-menu': 2.1.4(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-form': 0.1.1(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-hover-card': 1.1.3(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-popover': 1.1.3(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-hover-card': 1.1.4(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-popover': 1.1.4(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-portal': 1.1.3(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-radio-group': 1.2.2(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-scroll-area': 1.2.2(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-select': 2.1.3(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-select': 2.1.4(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-separator': 1.1.1(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-slider': 1.2.2(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-slot': 1.1.1(@types/react@18.2.57)(react@18.2.0) '@radix-ui/react-switch': 1.1.2(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-tabs': 1.1.2(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-tooltip': 1.1.5(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-tooltip': 1.1.6(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-visually-hidden': 1.1.1(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.2.0)(react@18.2.0) '@types/react': 18.2.57 '@types/react-dom': 18.2.19 @@ -8698,7 +8704,7 @@ packages: resolve: 1.22.8 rollup: 3.29.5 stacktrace-parser: 0.1.10 - webpack: 5.96.1(esbuild@0.21.5) + webpack: 5.96.1 transitivePeerDependencies: - '@opentelemetry/api' - '@opentelemetry/core' @@ -8851,7 +8857,7 @@ packages: '@sentry/bundler-plugin-core': 2.22.3 unplugin: 1.0.1 uuid: 9.0.1 - webpack: 5.96.1(esbuild@0.21.5) + webpack: 5.96.1 transitivePeerDependencies: - encoding - supports-color @@ -9731,6 +9737,10 @@ packages: '@types/ms': 0.7.33 dev: true + /@types/dedent@0.7.2: + resolution: {integrity: sha512-kRiitIeUg1mPV9yH4VUJ/1uk2XjyANfeL8/7rH1tsjvHeO9PJLBHJIYsFWmAvmGj5u8rj+1TZx7PZzW2qLw3Lw==} + dev: true + /@types/diff-match-patch@1.0.36: resolution: {integrity: sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==} dev: false @@ -13159,7 +13169,6 @@ packages: peerDependenciesMeta: babel-plugin-macros: optional: true - dev: true /deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} @@ -16143,6 +16152,7 @@ packages: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} dependencies: loose-envify: 1.4.0 + dev: true /invert-kv@1.0.0: resolution: {integrity: sha512-xgs2NH9AE66ucSq4cNG1nhSFghr5l6tdL15Pk+jl46bmmBapgoaY/AacXyaDznAqmGL99TiLSQgO/XazFSKYeQ==} @@ -21179,38 +21189,38 @@ packages: engines: {node: '>=0.10.0'} dev: true - /react-remove-scroll-bar@2.3.6(@types/react@18.2.57)(react@18.2.0): - resolution: {integrity: sha512-DtSYaao4mBmX+HDo5YWYdBWQwYIQQshUV/dVxFxK+KM26Wjwp1gZ6rv6OC3oujI6Bfu6Xyg3TwK533AQutsn/g==} + /react-remove-scroll-bar@2.3.8(@types/react@18.2.57)(react@18.2.0): + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} engines: {node: '>=10'} peerDependencies: - '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 peerDependenciesMeta: '@types/react': optional: true dependencies: '@types/react': 18.2.57 react: 18.2.0 - react-style-singleton: 2.2.1(@types/react@18.2.57)(react@18.2.0) + react-style-singleton: 2.2.3(@types/react@18.2.57)(react@18.2.0) tslib: 2.8.1 dev: false - /react-remove-scroll@2.6.0(@types/react@18.2.57)(react@18.2.0): - resolution: {integrity: sha512-I2U4JVEsQenxDAKaVa3VZ/JeJZe0/2DxPWL8Tj8yLKctQJQiZM52pn/GWFpSp8dftjM3pSAHVJZscAnC/y+ySQ==} + /react-remove-scroll@2.6.2(@types/react@18.2.57)(react@18.2.0): + resolution: {integrity: sha512-KmONPx5fnlXYJQqC62Q+lwIeAk64ws/cUw6omIumRzMRPqgnYqhSSti99nbj0Ry13bv7dF+BKn7NB+OqkdZGTw==} engines: {node: '>=10'} peerDependencies: - '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc peerDependenciesMeta: '@types/react': optional: true dependencies: '@types/react': 18.2.57 react: 18.2.0 - react-remove-scroll-bar: 2.3.6(@types/react@18.2.57)(react@18.2.0) - react-style-singleton: 2.2.1(@types/react@18.2.57)(react@18.2.0) + react-remove-scroll-bar: 2.3.8(@types/react@18.2.57)(react@18.2.0) + react-style-singleton: 2.2.3(@types/react@18.2.57)(react@18.2.0) tslib: 2.8.1 - use-callback-ref: 1.3.2(@types/react@18.2.57)(react@18.2.0) + use-callback-ref: 1.3.3(@types/react@18.2.57)(react@18.2.0) use-sidecar: 1.1.2(@types/react@18.2.57)(react@18.2.0) dev: false @@ -21259,19 +21269,18 @@ packages: - supports-color dev: false - /react-style-singleton@2.2.1(@types/react@18.2.57)(react@18.2.0): - resolution: {integrity: sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==} + /react-style-singleton@2.2.3(@types/react@18.2.57)(react@18.2.0): + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} engines: {node: '>=10'} peerDependencies: - '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc peerDependenciesMeta: '@types/react': optional: true dependencies: '@types/react': 18.2.57 get-nonce: 1.0.1 - invariant: 2.2.4 react: 18.2.0 tslib: 2.8.1 dev: false @@ -23213,6 +23222,31 @@ packages: serialize-javascript: 6.0.2 terser: 5.31.3 webpack: 5.96.1(esbuild@0.21.5) + dev: true + + /terser-webpack-plugin@5.3.10(webpack@5.96.1): + resolution: {integrity: sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==} + engines: {node: '>= 10.13.0'} + peerDependencies: + '@swc/core': '*' + esbuild: '*' + uglify-js: '*' + webpack: ^5.1.0 + peerDependenciesMeta: + '@swc/core': + optional: true + esbuild: + optional: true + uglify-js: + optional: true + dependencies: + '@jridgewell/trace-mapping': 0.3.25 + jest-worker: 27.5.1 + schema-utils: 3.3.0 + serialize-javascript: 6.0.2 + terser: 5.31.3 + webpack: 5.96.1 + dev: false /terser@5.31.3: resolution: {integrity: sha512-pAfYn3NIZLyZpa83ZKigvj6Rn9c/vd5KfYGX7cN1mnzqgDcxWvrU5ZtAfIKhEXz9nRecw4z3LXkjaq96/qZqAA==} @@ -24158,12 +24192,12 @@ packages: resolution: {integrity: sha512-Qp95D4TPJl1kC9SKigDcqgyM2VDVO4RiJc2d4qe5GrYm+zbIQCWWKAFaJNQ4BhdFeDGwBmAxqJBwWSJDb9T3BQ==} dev: true - /use-callback-ref@1.3.2(@types/react@18.2.57)(react@18.2.0): - resolution: {integrity: sha512-elOQwe6Q8gqZgDA8mrh44qRTQqpIHDcZ3hXTLjBe1i4ph8XpNJnO+aQf3NaG+lriLopI4HMx9VjQLfPQ6vhnoA==} + /use-callback-ref@1.3.3(@types/react@18.2.57)(react@18.2.0): + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} engines: {node: '>=10'} peerDependencies: - '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc peerDependenciesMeta: '@types/react': optional: true @@ -24490,6 +24524,45 @@ packages: - uglify-js dev: true + /webpack@5.96.1: + resolution: {integrity: sha512-l2LlBSvVZGhL4ZrPwyr8+37AunkcYj5qh8o6u2/2rzoPc8gxFJkLj1WxNgooi9pnoc06jh0BjuXnamM4qlujZA==} + engines: {node: '>=10.13.0'} + hasBin: true + peerDependencies: + webpack-cli: '*' + peerDependenciesMeta: + webpack-cli: + optional: true + dependencies: + '@types/eslint-scope': 3.7.7 + '@types/estree': 1.0.6 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/wasm-edit': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + acorn: 8.14.0 + browserslist: 4.24.2 + chrome-trace-event: 1.0.4 + enhanced-resolve: 5.17.1 + es-module-lexer: 1.5.4 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + json-parse-even-better-errors: 2.3.1 + loader-runner: 4.3.0 + mime-types: 2.1.35 + neo-async: 2.6.2 + schema-utils: 3.3.0 + tapable: 2.2.1 + terser-webpack-plugin: 5.3.10(webpack@5.96.1) + watchpack: 2.4.2 + webpack-sources: 3.2.3 + transitivePeerDependencies: + - '@swc/core' + - esbuild + - uglify-js + dev: false + /webpack@5.96.1(esbuild@0.21.5): resolution: {integrity: sha512-l2LlBSvVZGhL4ZrPwyr8+37AunkcYj5qh8o6u2/2rzoPc8gxFJkLj1WxNgooi9pnoc06jh0BjuXnamM4qlujZA==} engines: {node: '>=10.13.0'} @@ -24527,6 +24600,7 @@ packages: - '@swc/core' - esbuild - uglify-js + dev: true /webvtt-parser@2.2.0: resolution: {integrity: sha512-FzmaED+jZyt8SCJPTKbSsimrrnQU8ELlViE1wuF3x1pgiQUM8Llj5XWj2j/s6Tlk71ucPfGSMFqZWBtKn/0uEA==}