feat: add course AI agent backend

This commit is contained in:
2026-10-14 14:25:42 +09:00
parent 5ccdc1e843
commit 56d7f2060c
20 changed files with 788 additions and 3 deletions
+15
View File
@@ -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));
+2 -1
View File
@@ -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],
})
+61
View File
@@ -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();
+1 -1
View File
@@ -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' } },
});
+1 -1
View File
@@ -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 }] });