Skip to content
Open
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
27 changes: 27 additions & 0 deletions apps/api/src/ai/ai-practice.queue.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Injectable } from "@nestjs/common";

import { QUEUE_NAMES, QueueService } from "src/queue";

import type { Job } from "bullmq";
import type { AiMentorPracticeJobData } from "src/ai/ai-practice.types";

export const AI_MENTOR_PRACTICE_JOB_NAME = "generate-ai-mentor-practice";

@Injectable()
export class AiPracticeQueueService {
constructor(private readonly queueService: QueueService) {}

enqueue(data: AiMentorPracticeJobData): Promise<Job<AiMentorPracticeJobData>> {
return this.queueService.enqueue(
QUEUE_NAMES.AI_MENTOR_PRACTICE,
AI_MENTOR_PRACTICE_JOB_NAME,
data,
{
attempts: 3,
backoff: { type: "exponential", delay: 1000 },
removeOnComplete: true,
removeOnFail: false,
},
);
}
}
36 changes: 36 additions & 0 deletions apps/api/src/ai/ai-practice.schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { SUPPORTED_LANGUAGES } from "@repo/shared";
import { Type, type Static } from "@sinclair/typebox";

import { AI_MENTOR_PRACTICE_STATUSES } from "src/ai/ai-practice.types";
import { responseAiJudgeJudgementSchema } from "src/ai/utils/ai.schema";
import { THREAD_STATUS } from "src/ai/utils/ai.type";
import { UUIDSchema } from "src/common";

const practiceScenarioSchema = Type.String({ minLength: 1, maxLength: 3000 });

export const createAiMentorPracticeSchema = Type.Object({
language: Type.Enum(SUPPORTED_LANGUAGES),
scenario: practiceScenarioSchema,
});

export const aiMentorPracticeSessionSchema = Type.Object({
id: UUIDSchema,
practiceDate: Type.String(),
language: Type.Enum(SUPPORTED_LANGUAGES),
title: Type.Union([Type.String(), Type.Null()]),
aiMentorName: Type.Union([Type.String(), Type.Null()]),
threadId: Type.Union([UUIDSchema, Type.Null()]),
threadStatus: Type.Union([Type.Enum(THREAD_STATUS), Type.Null()]),
taskGoal: Type.Union([Type.String(), Type.Null()]),
evaluation: Type.Union([responseAiJudgeJudgementSchema, Type.Null()]),
status: Type.Enum(AI_MENTOR_PRACTICE_STATUSES),
errorCode: Type.Union([Type.String(), Type.Null()]),
});

export const nullableAiMentorPracticeSessionSchema = Type.Union([
aiMentorPracticeSessionSchema,
Type.Null(),
]);

export type CreateAiMentorPracticeBody = Static<typeof createAiMentorPracticeSchema>;
export type AiMentorPracticeSessionResponse = Static<typeof aiMentorPracticeSessionSchema>;
50 changes: 50 additions & 0 deletions apps/api/src/ai/ai-practice.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type { LocalizedText, SupportedLanguages } from "@repo/shared";
import type { SQL } from "drizzle-orm";
import type { UUIDType } from "src/common";
import type {
aiJudgeBlockingErrors,
aiJudgeConfigurations,
aiJudgeCriteria,
aiJudgeScoreGuidance,
} from "src/storage/schema";

export { AI_MENTOR_PRACTICE_STATUSES } from "@repo/shared";
export type { AiMentorPracticeStatus } from "@repo/shared";

export type AiMentorPracticeJobData = {
tenantId: UUIDType;
sessionId: UUIDType;
};

export type AiMentorPracticeGenerationInput = {
scenario: string;
language: SupportedLanguages;
};

export type AiPracticeJudgeConfigurationGraph = {
configuration: Omit<
typeof aiJudgeConfigurations.$inferInsert,
"id" | "tenantId" | "practiceSessionId" | "taskGoal"
> & {
id: UUIDType;
practiceSessionId: UUIDType;
taskGoal: LocalizedText | SQL<unknown>;
};
criteria: Array<
Omit<typeof aiJudgeCriteria.$inferInsert, "title" | "expectedBehavior"> & {
title?: LocalizedText | SQL<unknown>;
expectedBehavior?: LocalizedText | SQL<unknown>;
}
>;
scoreGuidance: Array<
Omit<typeof aiJudgeScoreGuidance.$inferInsert, "description" | "example"> & {
description?: LocalizedText | SQL<unknown>;
example?: LocalizedText | SQL<unknown> | null;
}
>;
blockingErrors: Array<
Omit<typeof aiJudgeBlockingErrors.$inferInsert, "description"> & {
description?: LocalizedText | SQL<unknown>;
}
>;
};
52 changes: 52 additions & 0 deletions apps/api/src/ai/ai-practice.worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import {
Injectable,
InternalServerErrorException,
Logger,
type OnModuleDestroy,
} from "@nestjs/common";
import { Worker } from "bullmq";

import { AI_MENTOR_PRACTICE_JOB_NAME } from "src/ai/ai-practice.queue.service";
import { AiPracticeService } from "src/ai/services/ai-practice.service";
import { QUEUE_NAMES, QueueService } from "src/queue";
import { TenantDbRunnerService } from "src/storage/db/tenant-db-runner.service";

import type { Job } from "bullmq";
import type { AiMentorPracticeJobData } from "src/ai/ai-practice.types";

@Injectable()
export class AiPracticeWorker implements OnModuleDestroy {
private readonly logger = new Logger(AiPracticeWorker.name);
private readonly worker: Worker<AiMentorPracticeJobData>;

constructor(
private readonly queueService: QueueService,
private readonly practiceService: AiPracticeService,
private readonly tenantRunner: TenantDbRunnerService,
) {
this.worker = new Worker<AiMentorPracticeJobData>(
QUEUE_NAMES.AI_MENTOR_PRACTICE,
(job) => this.process(job),
{
connection: this.queueService.getConnection(),
concurrency: Number(process.env.AI_MENTOR_PRACTICE_WORKER_CONCURRENCY || 2),
},
);
this.worker.on("failed", (job, error) => {
this.logger.error(`AI Mentor practice job ${job?.id} failed: ${error.message}`);
});
}

private async process(job: Job<AiMentorPracticeJobData>) {
if (job.name !== AI_MENTOR_PRACTICE_JOB_NAME)
throw new InternalServerErrorException(`Unexpected AI practice job name: ${job.name}`);

await this.tenantRunner.runWithTenant(job.data.tenantId, () =>
this.practiceService.processGenerationJob(job.data),
);
}

async onModuleDestroy() {
await this.worker.close();
}
}
62 changes: 61 additions & 1 deletion apps/api/src/ai/ai.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ import { Type } from "@sinclair/typebox";
import { Response } from "express";
import { Validate } from "nestjs-typebox";

import {
aiMentorPracticeSessionSchema,
createAiMentorPracticeSchema,
nullableAiMentorPracticeSessionSchema,
type CreateAiMentorPracticeBody,
} from "src/ai/ai-practice.schema";
import { AiPracticeService } from "src/ai/services/ai-practice.service";
import { AiService } from "src/ai/services/ai.service";
import { ThreadService } from "src/ai/services/thread.service";
import { loadAiSdk } from "src/ai/utils/ai-esm";
Expand All @@ -18,7 +25,7 @@ import {
streamChatSchema,
} from "src/ai/utils/ai.schema";
import { OPENAI_MODELS } from "src/ai/utils/ai.type";
import { type BaseResponse, baseResponse, UUIDSchema, UUIDType } from "src/common";
import { BaseResponse, baseResponse, UUIDSchema, UUIDType } from "src/common";
import { RequirePermission } from "src/common/decorators/require-permission.decorator";
import { CurrentUser } from "src/common/decorators/user.decorator";
import { CurrentUserType } from "src/common/types/current-user.type";
Expand All @@ -28,8 +35,61 @@ export class AiController {
constructor(
private readonly threadService: ThreadService,
private readonly aiService: AiService,
private readonly aiPracticeService: AiPracticeService,
) {}

@Get("practice/today")
@RequirePermission(PERMISSIONS.AI_USE)
@Validate({
response: baseResponse(nullableAiMentorPracticeSessionSchema),
})
async getTodayPractice(@CurrentUser() currentUser: CurrentUserType) {
return new BaseResponse(await this.aiPracticeService.getToday(currentUser));
}

@Post("practice")
@RequirePermission(PERMISSIONS.AI_USE)
@Validate({
request: [{ type: "body", schema: createAiMentorPracticeSchema }],
response: baseResponse(aiMentorPracticeSessionSchema),
})
async createPractice(
@Body() body: CreateAiMentorPracticeBody,
@CurrentUser() currentUser: CurrentUserType,
) {
return new BaseResponse(await this.aiPracticeService.create(body, currentUser));
}

@Get("practice/:id")
@RequirePermission(PERMISSIONS.AI_USE)
@Validate({
request: [{ type: "param", name: "id", schema: UUIDSchema }],
response: baseResponse(aiMentorPracticeSessionSchema),
})
async getPractice(@Param("id") id: UUIDType, @CurrentUser() currentUser: CurrentUserType) {
return new BaseResponse(await this.aiPracticeService.getById(id, currentUser));
}

@Post("practice/:id/retry")
@RequirePermission(PERMISSIONS.AI_USE)
@Validate({
request: [{ type: "param", name: "id", schema: UUIDSchema }],
response: baseResponse(aiMentorPracticeSessionSchema),
})
async retryPractice(@Param("id") id: UUIDType, @CurrentUser() currentUser: CurrentUserType) {
return new BaseResponse(await this.aiPracticeService.retry(id, currentUser));
}

@Post("practice/:id/replay")
@RequirePermission(PERMISSIONS.AI_USE)
@Validate({
request: [{ type: "param", name: "id", schema: UUIDSchema }],
response: baseResponse(aiMentorPracticeSessionSchema),
})
async replayPractice(@Param("id") id: UUIDType, @CurrentUser() currentUser: CurrentUserType) {
return new BaseResponse(await this.aiPracticeService.replay(id, currentUser));
}

@Get("thread")
@RequirePermission(PERMISSIONS.AI_USE)
@Validate({
Expand Down
10 changes: 10 additions & 0 deletions apps/api/src/ai/ai.module.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { Module } from "@nestjs/common";

import { AiPracticeQueueService } from "src/ai/ai-practice.queue.service";
import { AiPracticeWorker } from "src/ai/ai-practice.worker";
import { AiController } from "src/ai/ai.controller";
import { AiJudgeConfigurationGenerationWorkflowService } from "src/ai/judge-configuration-generation/services/ai-judge-configuration-generation-workflow.service";
import { AiJudgeConfigurationGeneratorService } from "src/ai/judge-configuration-generation/services/ai-judge-configuration-generator.service";
import { AiJudgeConfigurationValidatorService } from "src/ai/judge-configuration-generation/services/ai-judge-configuration-validator.service";
import { AiRepository } from "src/ai/repositories/ai.repository";
import { RagRepository } from "src/ai/repositories/rag.repository";
import { AiPracticeContentGeneratorService } from "src/ai/services/ai-practice-content-generator.service";
import { AiPracticeJudgeConfigurationService } from "src/ai/services/ai-practice-judge-configuration.service";
import { AiPracticeService } from "src/ai/services/ai-practice.service";
import { AiRuntimeService } from "src/ai/services/ai-runtime.service";
import { AiService } from "src/ai/services/ai.service";
import { ChatService } from "src/ai/services/chat.service";
Expand All @@ -27,6 +32,8 @@ import { StudentLessonProgressModule } from "src/studentLessonProgress/studentLe
AiJudgeConfigurationGeneratorService,
AiJudgeConfigurationGenerationWorkflowService,
AiJudgeConfigurationValidatorService,
AiPracticeJudgeConfigurationService,
AiPracticeContentGeneratorService,
ChatService,
AiRuntimeService,
AiService,
Expand All @@ -39,6 +46,9 @@ import { StudentLessonProgressModule } from "src/studentLessonProgress/studentLe
SummaryService,
RagService,
RagRepository,
AiPracticeService,
AiPracticeQueueService,
AiPracticeWorker,
],
exports: [
AiJudgeConfigurationGenerationWorkflowService,
Expand Down
Loading
Loading