feat: add course AI agent backend
This commit is contained in:
@@ -12,6 +12,7 @@ import { SaveInstructorGroupDto } from './dto/save-instructor-group.dto';
|
||||
import { SaveSubmissionScoreDto } from './dto/save-submission-score.dto';
|
||||
import { UpdateInstructorCurriculumDto } from './dto/update-instructor-curriculum.dto';
|
||||
import { UpdateInstructorPlatformDto } from './dto/update-instructor-platform.dto';
|
||||
import { UpdateCourseAiSettingDto } from '../ai/dto/update-course-ai-setting.dto';
|
||||
|
||||
@Controller('instructor')
|
||||
export class InstructorController {
|
||||
@@ -32,6 +33,20 @@ export class InstructorController {
|
||||
return this.instructorService.createInitialCourse(this.getBearerToken(request), body.title);
|
||||
}
|
||||
|
||||
@Get('courses/:courseId/ai-setting')
|
||||
getCourseAiSetting(@Req() request: Request, @Param('courseId') courseId: string) {
|
||||
return this.instructorService.getCourseAiSetting(this.getBearerToken(request), courseId);
|
||||
}
|
||||
|
||||
@Put('courses/:courseId/ai-setting')
|
||||
updateCourseAiSetting(
|
||||
@Req() request: Request,
|
||||
@Param('courseId') courseId: string,
|
||||
@Body() body: UpdateCourseAiSettingDto,
|
||||
) {
|
||||
return this.instructorService.updateCourseAiSetting(this.getBearerToken(request), courseId, body);
|
||||
}
|
||||
|
||||
@Get('submissions')
|
||||
listSubmissions(@Req() request: Request) {
|
||||
return this.instructorService.listSubmissions(this.getBearerToken(request));
|
||||
|
||||
@@ -5,11 +5,12 @@ import { ChatModule } from '../chat/chat.module';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { PlatformGatewayClient } from '../platform-control/platform-gateway.client';
|
||||
import { R2PublicFileService } from '../storage/r2-public-file.service';
|
||||
import { AiModule } from '../ai/ai.module';
|
||||
import { InstructorController } from './instructor.controller';
|
||||
import { InstructorService } from './instructor.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, ChatModule, PrismaModule],
|
||||
imports: [AiModule, AuthModule, ChatModule, PrismaModule],
|
||||
controllers: [InstructorController],
|
||||
providers: [InstructorService, PlatformGatewayClient, R2PublicFileService],
|
||||
})
|
||||
|
||||
@@ -30,6 +30,8 @@ import { UpdateInstructorCurriculumDto } from './dto/update-instructor-curriculu
|
||||
import { UpdateInstructorPlatformDto } from './dto/update-instructor-platform.dto';
|
||||
import { CreateInstructorCourseMapDto } from './dto/create-instructor-course-map.dto';
|
||||
import { NAV2_SYSTEM_MAPS } from '../curriculum/course-maps';
|
||||
import { AiCredentialService } from '../ai/ai-credential.service';
|
||||
import { UpdateCourseAiSettingDto } from '../ai/dto/update-course-ai-setting.dto';
|
||||
|
||||
const INSTRUCTOR_ROLES = new Set(['INSTRUCTOR', 'LMS_ADMIN']);
|
||||
const DEFAULT_SUBMISSION_BOT_EMAIL = 'lms-bot@futuredrive.net';
|
||||
@@ -95,6 +97,7 @@ export class InstructorService {
|
||||
private readonly logger = new Logger(InstructorService.name);
|
||||
|
||||
constructor(
|
||||
private readonly aiCredentialService: AiCredentialService,
|
||||
private readonly authService: AuthService,
|
||||
private readonly chatGateway: ChatGateway,
|
||||
private readonly prisma: PrismaService,
|
||||
@@ -103,6 +106,64 @@ export class InstructorService {
|
||||
private readonly r2PublicFileService: R2PublicFileService,
|
||||
) {}
|
||||
|
||||
async getCourseAiSetting(accessToken: string | undefined, courseId: string) {
|
||||
const { authUser, instructor } = await this.getInstructor(accessToken);
|
||||
const organizationName = authUser.primaryAffiliation?.organizationName?.trim();
|
||||
if (!organizationName) throw new BadRequestException('Instructor organization is required');
|
||||
const course = await this.findInstructorCourse(organizationName, instructor.id, courseId);
|
||||
if (!course) throw new NotFoundException('Course not found');
|
||||
const setting = await this.prisma.courseAiSetting.findUnique({ where: { courseId: course.id } });
|
||||
return {
|
||||
courseId: course.id,
|
||||
enabled: setting?.enabled ?? false,
|
||||
provider: setting?.provider ?? 'OPENAI',
|
||||
model: setting?.model ?? 'gpt-4.1-mini',
|
||||
hasApiKey: Boolean(setting?.encryptedApiKey),
|
||||
updatedAt: setting?.updatedAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async updateCourseAiSetting(
|
||||
accessToken: string | undefined,
|
||||
courseId: string,
|
||||
input: UpdateCourseAiSettingDto,
|
||||
) {
|
||||
const { authUser, instructor } = await this.getInstructor(accessToken);
|
||||
const organizationName = authUser.primaryAffiliation?.organizationName?.trim();
|
||||
if (!organizationName) throw new BadRequestException('Instructor organization is required');
|
||||
const course = await this.findInstructorCourse(organizationName, instructor.id, courseId);
|
||||
if (!course) throw new NotFoundException('Course not found');
|
||||
const existing = await this.prisma.courseAiSetting.findUnique({ where: { courseId: course.id } });
|
||||
const apiKey = input.apiKey?.trim();
|
||||
if (input.enabled && !apiKey && (!existing?.encryptedApiKey || existing.provider !== input.provider)) {
|
||||
throw new BadRequestException('AI 에이전트를 사용하거나 공급자를 변경하려면 해당 공급자의 API 키를 입력하세요.');
|
||||
}
|
||||
const setting = await this.prisma.courseAiSetting.upsert({
|
||||
where: { courseId: course.id },
|
||||
create: {
|
||||
courseId: course.id,
|
||||
enabled: input.enabled,
|
||||
provider: input.provider,
|
||||
model: input.model.trim(),
|
||||
encryptedApiKey: apiKey ? this.aiCredentialService.encrypt(apiKey) : null,
|
||||
},
|
||||
update: {
|
||||
enabled: input.enabled,
|
||||
provider: input.provider,
|
||||
model: input.model.trim(),
|
||||
...(apiKey ? { encryptedApiKey: this.aiCredentialService.encrypt(apiKey) } : {}),
|
||||
},
|
||||
});
|
||||
return {
|
||||
courseId: course.id,
|
||||
enabled: setting.enabled,
|
||||
provider: setting.provider,
|
||||
model: setting.model,
|
||||
hasApiKey: Boolean(setting.encryptedApiKey),
|
||||
updatedAt: setting.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
async listCourses(accessToken: string | undefined) {
|
||||
const { authUser, instructor } = await this.getInstructor(accessToken);
|
||||
const organizationName = authUser.primaryAffiliation?.organizationName?.trim();
|
||||
|
||||
@@ -17,7 +17,7 @@ describe('platform registration', () => {
|
||||
create: jest.fn().mockResolvedValue({}),
|
||||
},
|
||||
};
|
||||
const service = new InstructorService({} as never, {} as never, prisma as never, {} as never, {} as never, {} as never);
|
||||
const service = new InstructorService({} as never, {} as never, {} as never, prisma as never, {} as never, {} as never, {} as never);
|
||||
jest.spyOn(service as any, 'getInstructor').mockResolvedValue({
|
||||
authUser: { primaryAffiliation: { organizationId: 'org-id', organizationName: 'org' } },
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@ describe('week cover images', () => {
|
||||
uploadPublicFile: jest.fn().mockResolvedValue('https://files.example/new-cover'),
|
||||
deletePublicFile: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const service = new InstructorService({} as never, {} as never, prisma as never, {} as never, {} as never, storage as never);
|
||||
const service = new InstructorService({} as never, {} as never, {} as never, prisma as never, {} as never, {} as never, storage as never);
|
||||
const internals = service as any;
|
||||
internals.getInstructor = jest.fn().mockResolvedValue({ authUser: { primaryAffiliation: { organizationName: 'org' } }, instructor: { id: 'teacher' } });
|
||||
internals.findInstructorCourse = jest.fn().mockResolvedValue({ id: 'course', weeks: [{ id: 'week', week: 2 }] });
|
||||
|
||||
Reference in New Issue
Block a user