diff --git a/src/chat/chat.controller.ts b/src/chat/chat.controller.ts index 6413a7e..eddc7bf 100644 --- a/src/chat/chat.controller.ts +++ b/src/chat/chat.controller.ts @@ -21,6 +21,11 @@ export class ChatController { return this.chatService.clearStudentThreads(this.getBearerToken(request), query); } + @Delete('threads/:threadId') + deleteThread(@Req() request: Request, @Param('threadId') threadId: string) { + return this.chatService.deleteStudentThread(this.getBearerToken(request), threadId); + } + @Post('messages') createMessage( @Req() request: Request, diff --git a/src/chat/chat.service.ts b/src/chat/chat.service.ts index ae8501d..eaf4322 100644 --- a/src/chat/chat.service.ts +++ b/src/chat/chat.service.ts @@ -88,9 +88,9 @@ export class ChatService { where: { courseId: course.id, studentId: student.id, - weekId: week?.id, + weekId: week?.id ?? null, type: query.mode ? this.toThreadType(query.mode) : undefined, - status: { not: ChatThreadStatus.ARCHIVED }, + status: query.includeArchived ? undefined : { not: ChatThreadStatus.ARCHIVED }, }, orderBy: { lastMessageAt: 'desc' }, include: { @@ -131,7 +131,7 @@ export class ChatService { where: { courseId: course.id, studentId: student.id, - weekId: week?.id, + weekId: week?.id ?? null, type: query.mode ? this.toThreadType(query.mode) : undefined, status: { not: ChatThreadStatus.ARCHIVED }, }, @@ -143,6 +143,25 @@ export class ChatService { return { deletedCount: result.count }; } + async deleteStudentThread(accessToken: string | undefined, threadId: string) { + const student = await this.getLmsUser(accessToken); + const thread = await this.prisma.chatThread.findFirst({ + where: { + id: threadId, + studentId: student.id, + type: ChatThreadType.AI, + }, + select: { id: true }, + }); + + if (!thread) { + throw new NotFoundException('대화 내역을 찾을 수 없습니다.'); + } + + await this.prisma.chatThread.delete({ where: { id: thread.id } }); + return { id: thread.id }; + } + async createStudentMessage( accessToken: string | undefined, dto: CreateChatMessageDto, @@ -247,6 +266,9 @@ export class ChatService { const model = aiProvider === 'futuredrive' ? this.configService.get('FUTUREDRIVE_LLM_MODEL', 'futuredrive-llm') : this.configService.get('OPENAI_CHAT_MODEL', 'gpt-4.1-nano'); + const curriculumContext = dto.contextWeek + ? await this.getCurriculumContext(course.id, dto.contextWeek) + : null; try { const aiResponse = await this.requestAiChatCompletion( @@ -258,6 +280,7 @@ export class ChatService { : ('assistant' as const), content: message.content, })), + curriculumContext, ); const currentStudentMessage = await this.prisma.chatMessage.findUnique({ where: { id: studentMessage.id }, @@ -309,6 +332,7 @@ export class ChatService { return { threadId: thread.id, + threadTitle: thread.title, message: this.serializeMessage(assistantMessage), content: assistantMessage.content, }; @@ -700,7 +724,7 @@ export class ChatService { studentId: input.studentId, type: input.type, status: input.status, - title: input.firstMessage.slice(0, 80), + title: this.makeThreadTitle(input.firstMessage), }, }); } @@ -760,17 +784,21 @@ export class ChatService { private async requestAiChatCompletion( model: string, messages: Array<{ role: 'user' | 'assistant'; content: string }>, + curriculumContext?: string | null, ) { const provider = this.configService.get('AI_PROVIDER', 'openai').trim().toLowerCase(); if (provider === 'futuredrive') { - return this.requestFutureDriveLlmChatCompletion(messages); + return this.requestFutureDriveLlmChatCompletion(curriculumContext + ? [{ role: 'user', content: curriculumContext }, ...messages] + : messages); } - return this.requestOpenAiChatCompletion(model, messages); + return this.requestOpenAiChatCompletion(model, messages, curriculumContext); } private async requestOpenAiChatCompletion( model: string, messages: Array<{ role: 'user' | 'assistant'; content: string }>, + curriculumContext?: string | null, ) { const apiKey = this.configService.get('OPENAI_API_KEY'); if (!apiKey) { @@ -792,7 +820,7 @@ export class ChatService { { role: 'system', content: - '당신은 자율주행 교육용 ROS2, SLAM 실습을 돕는 한국어 AI 챗봇입니다. 답변은 짧고 실습자가 바로 따라 할 수 있게 명확하게 작성하세요.', + `당신은 자율주행 교육용 ROS2, SLAM 실습을 돕는 한국어 AI 챗봇입니다. 답변은 짧고 실습자가 바로 따라 할 수 있게 명확하게 작성하세요.${curriculumContext ? `\n\n${curriculumContext}` : ''}`, }, ...messages, ], @@ -867,6 +895,41 @@ export class ChatService { return mode === 'ai' ? ChatThreadType.AI : ChatThreadType.INSTRUCTOR; } + private async getCurriculumContext(courseId: string, weekNumber: number): Promise { + const week = await this.prisma.courseWeek.findUnique({ + where: { courseId_week: { courseId, week: weekNumber } }, + include: { + topics: { orderBy: { sortOrder: 'asc' }, select: { title: true, detail: true } }, + practices: { orderBy: { sortOrder: 'asc' }, select: { title: true, description: true } }, + references: { orderBy: { sortOrder: 'asc' }, select: { kind: true, title: true } }, + }, + }); + if (!week) return null; + + const lines = [ + `[현재 선택된 커리큘럼 정보]`, + `${week.week}주차: ${week.title}`, + `개요: ${week.overview}`, + week.topics.length ? `학습 단계:\n${week.topics.map((topic, index) => `${index + 1}. ${topic.title}${topic.detail ? ` - ${topic.detail}` : ''}`).join('\n')}` : null, + week.practices.length ? `실습 내용:\n${week.practices.map((practice, index) => `${index + 1}. ${practice.title} - ${practice.description}`).join('\n')}` : null, + `제출 기준: ${week.deliverable}`, + week.references.length ? `참고 자료: ${week.references.map((reference) => `${reference.title} (${reference.kind})`).join(', ')}` : null, + `위 정보는 사용자가 빠른 질문으로 요청한 현재 주차의 공식 커리큘럼입니다. 정보에 근거해 답변하고, 없는 내용은 추측하지 마세요.`, + ].filter(Boolean).join('\n\n'); + + return lines.slice(0, 12000); + } + + private makeThreadTitle(content: string): string { + const normalized = content.replace(/\s+/g, ' ').trim(); + const concise = normalized + .replace(/^(현재|이번|이)\s*(주차|학습|실습)\s*(에서|의)?\s*/i, '') + .replace(/(알려\s*줘|설명해\s*줘|정리해\s*줘|도와\s*줘|궁금해요|인가요|인가요\?)$/i, '') + .trim(); + const source = concise || normalized || '새 대화'; + return source.length > 42 ? `${source.slice(0, 42).trimEnd()}...` : source; + } + private serializeThread( thread: Prisma.ChatThreadGetPayload<{ include: { diff --git a/src/chat/dto/create-ai-response.dto.ts b/src/chat/dto/create-ai-response.dto.ts index c305dca..3b57ec6 100644 --- a/src/chat/dto/create-ai-response.dto.ts +++ b/src/chat/dto/create-ai-response.dto.ts @@ -9,4 +9,10 @@ export class CreateAiResponseDto { @Min(1) @Max(100) week?: number; + + @IsOptional() + @IsInt() + @Min(1) + @Max(100) + contextWeek?: number; } diff --git a/src/chat/dto/list-chat-threads.dto.ts b/src/chat/dto/list-chat-threads.dto.ts index d08b2d2..4b61dae 100644 --- a/src/chat/dto/list-chat-threads.dto.ts +++ b/src/chat/dto/list-chat-threads.dto.ts @@ -1,5 +1,5 @@ import { Transform } from 'class-transformer'; -import { IsIn, IsInt, IsOptional, Max, Min } from 'class-validator'; +import { IsBoolean, IsIn, IsInt, IsOptional, Max, Min } from 'class-validator'; export class ListChatThreadsDto { @IsOptional() @@ -12,4 +12,9 @@ export class ListChatThreadsDto { @Min(1) @Max(100) week?: number; + + @IsOptional() + @Transform(({ value }) => value === true || value === 'true') + @IsBoolean() + includeArchived?: boolean; }