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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions apps/api/src/activity-logs/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,8 @@ export type LessonActivityLogSnapshot = {
lessonResources?: LessonActivityLogResource[];
questions?: LessonActivityLogQuestion[];
aiMentor?: {
aiMentorInstructions?: string | null;
name?: string | null;
avatarReference?: string | null;
type?: string | null;
voiceMode?: string | null;
ttsPreset?: string | null;
customTtsReference?: string | null;
Expand Down
18 changes: 12 additions & 6 deletions apps/api/src/ai/__tests__/ai.controller.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { DB, DB_ADMIN } from "src/storage/db/db.providers";
import {
aiJudgeConfigurations,
aiJudgeCriteria,
aiMentorConfigurations,
aiMentorLessons,
chapters,
courses,
Expand Down Expand Up @@ -79,13 +80,18 @@ describe("AiController (e2e)", () => {

const addPolishAiMentorScenario = async (aiMentorLessonId: UUIDType) => {
await db
.update(aiMentorLessons)
.update(aiMentorConfigurations)
.set({
aiMentorInstructions: setJsonbField(
aiMentorLessons.aiMentorInstructions,
additionalInstructions: setJsonbField(
aiMentorConfigurations.additionalInstructions,
SUPPORTED_LANGUAGES.PL,
"Polish mentor instructions",
),
})
.where(eq(aiMentorConfigurations.aiMentorLessonId, aiMentorLessonId));
await db
.update(aiMentorLessons)
.set({
name: setJsonbField(aiMentorLessons.name, SUPPORTED_LANGUAGES.PL, "Polish mentor"),
})
.where(eq(aiMentorLessons.id, aiMentorLessonId));
Expand All @@ -97,7 +103,7 @@ describe("AiController (e2e)", () => {
.withUserSettings(db)
.create({ role: SYSTEM_ROLE_SLUGS.STUDENT });
const aiMentorLesson = await aiMentorLessonFactory.create({
aiMentorInstructions: "English mentor instructions",
additionalInstructions: "English mentor instructions",
taskGoal: "English task goal",
});
const courseId = await getCourseIdForAiMentorLesson(aiMentorLesson.lessonId);
Expand Down Expand Up @@ -131,9 +137,9 @@ describe("AiController (e2e)", () => {
SUPPORTED_LANGUAGES.DE,
);

expect(polishLesson.instructions).toBe("Polish mentor instructions");
expect(polishLesson.additionalInstructions).toBe("Polish mentor instructions");
expect(polishLesson.name).toBe("Polish mentor");
expect(fallbackLesson.instructions).toBe("English mentor instructions");
expect(fallbackLesson.additionalInstructions).toBe("English mentor instructions");
expect(polishLesson.learnerFirstName).toBe(threadOwner.firstName);
});
});
Expand Down
50 changes: 38 additions & 12 deletions apps/api/src/ai/__tests__/createAiMentorLesson.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,22 @@
import { faker } from "@faker-js/faker";
import { AI_MENTOR_TTS_PRESET, AI_MENTOR_VOICE_MODE, DEFAULT_AI_MENTOR_TYPE } from "@repo/shared";
import {
AI_MENTOR_ROLEPLAY_DIFFICULTY,
AI_MENTOR_TTS_PRESET,
AI_MENTOR_TYPE,
AI_MENTOR_VOICE_MODE,
SUPPORTED_LANGUAGES,
} from "@repo/shared";
import { Factory } from "fishery";

import { buildJsonbField } from "src/common/helpers/sqlHelpers";
import { LESSON_TYPES } from "src/lesson/lesson.type";
import { aiJudgeConfigurations, aiMentorLessons, lessons } from "src/storage/schema";
import {
aiJudgeConfigurations,
aiMentorConfigurations,
aiMentorLessons,
aiMentorRoleplayConfigurations,
lessons,
} from "src/storage/schema";

import { createChapterFactory } from "../../../test/factory/chapter.factory";

Expand All @@ -13,9 +25,9 @@ import type { DatabasePg, UUIDType } from "src/common";

export type AiMentorLessonTest = Omit<
InferSelectModel<typeof aiMentorLessons>,
"tenantId" | "aiMentorInstructions" | "name"
"tenantId" | "name"
> & {
aiMentorInstructions: string;
additionalInstructions: string;
taskGoal: string;
name: string;
};
Expand All @@ -39,7 +51,7 @@ export const createAiMentorLessonFactory = (db: DatabasePg) => {
.values({
chapterId,
type: LESSON_TYPES.AI_MENTOR,
title: buildJsonbField("en", faker.commerce.productName()),
title: buildJsonbField(SUPPORTED_LANGUAGES.EN, faker.commerce.productName()),
isExternal: true,
})
.returning();
Expand All @@ -48,24 +60,39 @@ export const createAiMentorLessonFactory = (db: DatabasePg) => {
.insert(aiMentorLessons)
.values({
lessonId: lesson.id,
aiMentorInstructions: buildJsonbField("en", aiMentorLesson.aiMentorInstructions),
name: buildJsonbField("en", aiMentorLesson.name),
type: aiMentorLesson.type,
name: buildJsonbField(SUPPORTED_LANGUAGES.EN, aiMentorLesson.name),
voiceMode: aiMentorLesson.voiceMode,
ttsPreset: aiMentorLesson.ttsPreset,
customTtsReference: aiMentorLesson.customTtsReference,
})
.returning();

const [mentorConfiguration] = await db
.insert(aiMentorConfigurations)
.values({
aiMentorLessonId: createdAiMentorLesson.id,
type: AI_MENTOR_TYPE.ROLEPLAY,
additionalInstructions: buildJsonbField(
SUPPORTED_LANGUAGES.EN,
aiMentorLesson.additionalInstructions,
),
})
.returning();

await db.insert(aiMentorRoleplayConfigurations).values({
configurationId: mentorConfiguration.id,
difficulty: AI_MENTOR_ROLEPLAY_DIFFICULTY.REALISTIC,
});

await db.insert(aiJudgeConfigurations).values({
aiMentorLessonId: createdAiMentorLesson.id,
taskGoal: buildJsonbField("en", aiMentorLesson.taskGoal),
taskGoal: buildJsonbField(SUPPORTED_LANGUAGES.EN, aiMentorLesson.taskGoal),
passingThresholdPercent: 0,
});

return {
...createdAiMentorLesson,
aiMentorInstructions: aiMentorLesson.aiMentorInstructions,
additionalInstructions: aiMentorLesson.additionalInstructions,
taskGoal: aiMentorLesson.taskGoal,
name: aiMentorLesson.name,
};
Expand All @@ -76,9 +103,8 @@ export const createAiMentorLessonFactory = (db: DatabasePg) => {
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
lessonId: faker.string.uuid(),
aiMentorInstructions: faker.commerce.productDescription(),
additionalInstructions: faker.commerce.productDescription(),
taskGoal: faker.commerce.productDescription(),
type: DEFAULT_AI_MENTOR_TYPE,
name: "AI Mentor",
avatarReference: null,
voiceMode: AI_MENTOR_VOICE_MODE.PRESET,
Expand Down
9 changes: 0 additions & 9 deletions apps/api/src/ai/ai-prompt.types.ts

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
import {
AI_MENTOR_ROLEPLAY_DIFFICULTY,
AI_MENTOR_TEACHING_STYLE,
AI_MENTOR_TYPE,
SUPPORTED_LANGUAGES,
} from "@repo/shared";
import { Value } from "@sinclair/typebox/value";

import {
Expand All @@ -19,8 +25,15 @@ const courseId = "baeb297a-b7d0-498a-bd4d-d70afcc428f1";
const lessonContext = {
title: "Handle a difficult sales objection",
taskDescription: "Reach an agreed next step.",
aiMentorInstructions: "Act as a skeptical buyer.",
aiMentorType: "roleplay",
aiMentorConfiguration: {
type: AI_MENTOR_TYPE.ROLEPLAY,
scenario: "A buyer challenges the price of the proposed solution.",
aiRole: "Skeptical buyer",
learnerRole: "Sales representative",
characterGoal: "Understand whether the proposal justifies its price.",
difficulty: AI_MENTOR_ROLEPLAY_DIFFICULTY.REALISTIC,
additionalInstructions: "Raise a credible budget objection.",
},
} as const;

const configuration = {
Expand Down Expand Up @@ -85,11 +98,58 @@ describe("AI Judge configuration generation schemas", () => {
lessonContext,
mode: "create",
brief: "Assess objection handling.",
language: "pl",
language: SUPPORTED_LANGUAGES.PL,
}),
).toBe(false);
});

it("accepts both structured variants and rejects the legacy flat mentor context", () => {
expect(
Value.Check(generateAiJudgeConfigurationInputSchema, {
courseId,
lessonContext: {
title: "Explain safe password storage",
aiMentorConfiguration: {
type: AI_MENTOR_TYPE.TEACHER,
taskGoal: "Teach the learner to distinguish hashing from encryption.",
expertise: "Application security instructor",
contentScope: "Password storage, salts, and adaptive password hashing.",
teachingStyle: AI_MENTOR_TEACHING_STYLE.GUIDED_DISCOVERY,
},
},
mode: "create",
brief: "Assess whether the learner can select a safe password-storage approach.",
}),
).toBe(true);

expect(
Value.Check(generateAiJudgeConfigurationInputSchema, {
courseId,
lessonContext: {
title: "Handle a difficult sales objection",
aiMentorInstructions: "Act as a skeptical buyer.",
aiMentorType: AI_MENTOR_TYPE.ROLEPLAY,
},
mode: "create",
brief: "Assess objection handling.",
}),
).toBe(false);
});

it("accepts lesson context before Mentor behavior is configured", () => {
expect(
Value.Check(generateAiJudgeConfigurationInputSchema, {
courseId,
lessonContext: {
title: "Handle a difficult sales objection",
taskDescription: "Respond to the customer's concern and agree on a next step.",
},
mode: "create",
brief: "Assess objection handling.",
}),
).toBe(true);
});

it("requires complete configuration context for improve requests", () => {
expect(
Value.Check(generateAiJudgeConfigurationInputSchema, {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { AI_MENTOR_TYPE } from "@repo/shared";
import { Type } from "@sinclair/typebox";

import { UUIDSchema } from "src/common";
Expand All @@ -9,6 +8,7 @@ import {
aiJudgeCriterionContentSchema,
aiJudgeScoreGuidanceContentSchema,
} from "src/lesson/ai-judge-configuration/ai-judge-configuration.schema";
import { aiMentorConfigurationContentSchema } from "src/lesson/ai-mentor-configuration/schemas/ai-mentor-configuration.schema";

import {
AI_JUDGE_BLOCKING_ERROR_REF_PATTERN,
Expand All @@ -26,6 +26,7 @@ import {
} from "../ai-judge-configuration-generation.types";

import type { Static } from "@sinclair/typebox";
import type { AiMentorConfigurationContent } from "src/lesson/ai-mentor-configuration/schemas/ai-mentor-configuration.schema";

const nonEmptyTextSchema = Type.String({ minLength: 1 });
const generatedTaskGoalSchema = Type.String({ minLength: 1 });
Expand Down Expand Up @@ -115,8 +116,7 @@ export const aiJudgeGenerationLessonContextSchema = Type.Object(
{
title: Type.Optional(Type.String()),
taskDescription: Type.Optional(Type.String()),
aiMentorInstructions: Type.Optional(Type.String()),
aiMentorType: Type.Enum(AI_MENTOR_TYPE),
aiMentorConfiguration: Type.Optional(aiMentorConfigurationContentSchema),
},
{ additionalProperties: false },
);
Expand Down Expand Up @@ -545,7 +545,11 @@ export const cancelAiJudgeGenerationResponseSchema = Type.Object(

export type ReferencedAiJudgeConfiguration = Static<typeof referencedAiJudgeConfigurationSchema>;
export type GeneratedAiJudgeConfiguration = Static<typeof generatedAiJudgeConfigurationSchema>;
export type AiJudgeGenerationLessonContext = Static<typeof aiJudgeGenerationLessonContextSchema>;
export type AiJudgeGenerationLessonContext = {
title?: string;
taskDescription?: string;
aiMentorConfiguration?: AiMentorConfigurationContent;
};
export type AiJudgeValidationIssue = Static<typeof aiJudgeValidationIssueSchema>;
export type AiJudgeConfigurationValidatorModelResult = Static<
typeof aiJudgeConfigurationValidatorModelResultSchema
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { SUPPORTED_LANGUAGES } from "@repo/shared";
import { AI_MENTOR_ROLEPLAY_DIFFICULTY, AI_MENTOR_TYPE, SUPPORTED_LANGUAGES } from "@repo/shared";

import { AI_JUDGE_GENERATION_FAILURE_MESSAGE } from "../ai-judge-configuration-generation.constants";

Expand All @@ -19,7 +19,14 @@ jest.mock("@langfuse/tracing", () => ({

const lessonContext = {
title: "Handle a price objection",
aiMentorType: "roleplay",
aiMentorConfiguration: {
type: AI_MENTOR_TYPE.ROLEPLAY,
scenario: "A buyer challenges the price of the proposed solution.",
aiRole: "Skeptical buyer",
learnerRole: "Sales representative",
characterGoal: "Understand whether the proposal justifies its price.",
difficulty: AI_MENTOR_ROLEPLAY_DIFFICULTY.REALISTIC,
},
} as const;

const draft: ReferencedAiJudgeConfiguration = {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { SUPPORTED_LANGUAGES } from "@repo/shared";
import { AI_MENTOR_ROLEPLAY_DIFFICULTY, AI_MENTOR_TYPE, SUPPORTED_LANGUAGES } from "@repo/shared";

import { loadAiSdk } from "src/ai/utils/ai-esm";

Expand All @@ -17,8 +17,15 @@ jest.mock("src/ai/utils/ai-esm", () => ({ loadAiSdk: jest.fn() }));
const lessonContext = {
title: "Handle a price objection",
taskDescription: "Reach an agreed next step.",
aiMentorInstructions: "Act as a skeptical buyer.",
aiMentorType: "roleplay",
aiMentorConfiguration: {
type: AI_MENTOR_TYPE.ROLEPLAY,
scenario: "A buyer challenges the price of the proposed solution.",
aiRole: "Skeptical buyer",
learnerRole: "Sales representative",
characterGoal: "Understand whether the proposal justifies its price.",
difficulty: AI_MENTOR_ROLEPLAY_DIFFICULTY.REALISTIC,
additionalInstructions: "Raise a credible budget objection.",
},
} as const;

const configuration: ReferencedAiJudgeConfiguration = {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { SUPPORTED_LANGUAGES } from "@repo/shared";
import { AI_MENTOR_ROLEPLAY_DIFFICULTY, AI_MENTOR_TYPE, SUPPORTED_LANGUAGES } from "@repo/shared";

import { loadAiSdk } from "src/ai/utils/ai-esm";

Expand All @@ -19,7 +19,14 @@ jest.mock("src/ai/utils/ai-esm", () => ({ loadAiSdk: jest.fn() }));

const lessonContext = {
title: "Handle a price objection",
aiMentorType: "roleplay",
aiMentorConfiguration: {
type: AI_MENTOR_TYPE.ROLEPLAY,
scenario: "A buyer challenges the price of the proposed solution.",
aiRole: "Skeptical buyer",
learnerRole: "Sales representative",
characterGoal: "Understand whether the proposal justifies its price.",
difficulty: AI_MENTOR_ROLEPLAY_DIFFICULTY.REALISTIC,
},
} as const;

const configuration: ReferencedAiJudgeConfiguration = {
Expand Down
Loading