import { BadGatewayException, ForbiddenException, Injectable, NotFoundException, UnauthorizedException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { AiChatRunStatus, ChatMessageStatus, ChatSenderType, ChatThreadStatus, ChatThreadType, Prisma, } from '@prisma/client'; import { AuthService, type AuthResponseUser } from '../auth/auth.service'; import { PrismaService } from '../prisma/prisma.service'; import { ChatGateway, type ChatNotificationPayload } from './chat.gateway'; import { CreateAiResponseDto } from './dto/create-ai-response.dto'; import { CreateChatMessageDto } from './dto/create-chat-message.dto'; import { CreateInstructorChatMessageDto } from './dto/create-instructor-chat-message.dto'; import { ListChatThreadsDto } from './dto/list-chat-threads.dto'; type OpenAiChatCompletionResponse = { choices?: Array<{ message?: { content?: string; }; }>; usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number; }; error?: { message?: string; }; }; type FutureDriveLlmChatResponse = { answer?: string; sources?: Array<{ title?: string; snippet?: string; score?: number; }>; topic?: string; follow_ups?: string[]; request_id?: string; error?: { message?: string; }; message?: string; }; const STUDENT_CHAT_VISIBLE_MESSAGE_LIMIT = 20; const AI_CHAT_CONTEXT_MESSAGE_LIMIT = 6; @Injectable() export class ChatService { private readonly authServerUrl: string; constructor( private readonly authService: AuthService, private readonly chatGateway: ChatGateway, private readonly configService: ConfigService, private readonly prisma: PrismaService, ) { this.authServerUrl = this.configService .get('AUTH_SERVER_URL', 'http://localhost:4100') .replace(/\/$/, ''); } async listThreads(accessToken: string | undefined, query: ListChatThreadsDto) { const student = await this.getLmsUser(accessToken); const course = await this.getActiveCourse(); const week = query.week ? await this.getCourseWeek(course.id, query.week) : null; const threads = await this.prisma.chatThread.findMany({ where: { courseId: course.id, studentId: student.id, weekId: week?.id, type: query.mode ? this.toThreadType(query.mode) : undefined, status: { not: ChatThreadStatus.ARCHIVED }, }, orderBy: { lastMessageAt: 'desc' }, include: { messages: { where: { deletedAt: null }, orderBy: { createdAt: 'desc' }, take: STUDENT_CHAT_VISIBLE_MESSAGE_LIMIT, include: { aiRun: true, senderUser: { select: { authUserId: true, email: true, name: true, }, }, }, }, week: { select: { week: true, title: true }, }, }, }); return { threads: threads.map((thread) => this.serializeThread(thread)), }; } async clearStudentThreads(accessToken: string | undefined, query: ListChatThreadsDto) { const student = await this.getLmsUser(accessToken); const course = await this.getActiveCourse(); const week = query.week ? await this.getCourseWeek(course.id, query.week) : null; const result = await this.prisma.chatThread.updateMany({ where: { courseId: course.id, studentId: student.id, weekId: week?.id, type: query.mode ? this.toThreadType(query.mode) : undefined, status: { not: ChatThreadStatus.ARCHIVED }, }, data: { status: ChatThreadStatus.ARCHIVED, }, }); return { deletedCount: result.count }; } async createStudentMessage( accessToken: string | undefined, dto: CreateChatMessageDto, ) { const student = await this.getLmsUser(accessToken); const course = await this.getActiveCourse(); const week = dto.week ? await this.getCourseWeek(course.id, dto.week) : null; const thread = await this.getOrCreateThread({ courseId: course.id, weekId: week?.id ?? null, studentId: student.id, type: this.toThreadType(dto.mode), firstMessage: dto.content, status: dto.mode === 'teacher' ? ChatThreadStatus.WAITING_INSTRUCTOR : ChatThreadStatus.OPEN, }); const message = await this.prisma.$transaction(async (tx) => { const createdMessage = await tx.chatMessage.create({ data: { threadId: thread.id, senderType: ChatSenderType.STUDENT, senderUserId: student.id, content: dto.content, }, }); await tx.chatThread.update({ where: { id: thread.id }, data: { status: dto.mode === 'teacher' ? ChatThreadStatus.WAITING_INSTRUCTOR : thread.status, lastMessageAt: createdMessage.createdAt, }, }); return createdMessage; }); const payload = this.toNotificationPayload(thread.id, message); this.chatGateway.notifyThread(thread.id, payload); this.chatGateway.notifyUser(student.id, payload); if (dto.mode === 'teacher') { this.chatGateway.notifyCourseInstructors(course.id, payload); } return { threadId: thread.id, message: this.serializeMessage(message), }; } async createAiResponse( accessToken: string | undefined, dto: CreateAiResponseDto, ) { const student = await this.getLmsUser(accessToken); const course = await this.getActiveCourse(); const week = dto.week ? await this.getCourseWeek(course.id, dto.week) : null; const thread = await this.getOrCreateThread({ courseId: course.id, weekId: week?.id ?? null, studentId: student.id, type: ChatThreadType.AI, firstMessage: dto.content, status: ChatThreadStatus.OPEN, }); const studentMessage = await this.prisma.$transaction(async (tx) => { const createdMessage = await tx.chatMessage.create({ data: { threadId: thread.id, senderType: ChatSenderType.STUDENT, senderUserId: student.id, content: dto.content, }, }); await tx.chatThread.update({ where: { id: thread.id }, data: { lastMessageAt: createdMessage.createdAt }, }); return createdMessage; }); const studentPayload = this.toNotificationPayload(thread.id, studentMessage); this.chatGateway.notifyThread(thread.id, studentPayload); this.chatGateway.notifyUser(student.id, studentPayload); const recentMessages = await this.prisma.chatMessage.findMany({ where: { threadId: thread.id, deletedAt: null, senderType: { in: [ChatSenderType.STUDENT, ChatSenderType.AI] }, status: ChatMessageStatus.ACTIVE, }, orderBy: { createdAt: 'desc' }, take: AI_CHAT_CONTEXT_MESSAGE_LIMIT, }); const startedAt = Date.now(); const aiProvider = this.configService.get('AI_PROVIDER', 'openai').trim().toLowerCase(); const model = aiProvider === 'futuredrive' ? this.configService.get('FUTUREDRIVE_LLM_MODEL', 'futuredrive-llm') : this.configService.get('OPENAI_CHAT_MODEL', 'gpt-4.1-nano'); try { const aiResponse = await this.requestAiChatCompletion( model, recentMessages.reverse().map((message) => ({ role: message.senderType === ChatSenderType.STUDENT ? ('user' as const) : ('assistant' as const), content: message.content, })), ); const currentStudentMessage = await this.prisma.chatMessage.findUnique({ where: { id: studentMessage.id }, select: { status: true }, }); if (currentStudentMessage?.status === ChatMessageStatus.CANCELED) { return { threadId: thread.id, message: this.serializeMessage({ ...studentMessage, status: ChatMessageStatus.CANCELED, canceledAt: new Date(), aiRun: null, senderUser: null, }), content: '', canceled: true, }; } const assistantMessage = await this.prisma.$transaction(async (tx) => { const message = await tx.chatMessage.create({ data: { threadId: thread.id, senderType: ChatSenderType.AI, content: aiResponse.content, aiRun: { create: { model, promptTokens: aiResponse.usage.promptTokens, completionTokens: aiResponse.usage.completionTokens, totalTokens: aiResponse.usage.totalTokens, latencyMs: Date.now() - startedAt, status: AiChatRunStatus.SUCCEEDED, }, }, }, include: { aiRun: true }, }); await tx.chatThread.update({ where: { id: thread.id }, data: { lastMessageAt: message.createdAt }, }); return message; }); const payload = this.toNotificationPayload(thread.id, assistantMessage); this.chatGateway.notifyThread(thread.id, payload); this.chatGateway.notifyUser(student.id, payload); return { threadId: thread.id, message: this.serializeMessage(assistantMessage), content: assistantMessage.content, }; } catch (error) { const message = error instanceof Error ? error.message : 'AI request failed'; const failedMessage = await this.prisma.chatMessage.create({ data: { threadId: thread.id, senderType: ChatSenderType.SYSTEM, content: message, aiRun: { create: { model, latencyMs: Date.now() - startedAt, status: AiChatRunStatus.FAILED, error: message, }, }, }, }); await this.prisma.chatThread.update({ where: { id: thread.id }, data: { lastMessageAt: failedMessage.createdAt }, }); const payload = this.toNotificationPayload(thread.id, failedMessage); this.chatGateway.notifyThread(thread.id, payload); this.chatGateway.notifyUser(student.id, payload); throw new BadGatewayException(message); } } async cancelAiMessage( accessToken: string | undefined, messageId: string, content?: string, ) { const student = await this.getLmsUser(accessToken); const message = await this.prisma.chatMessage.findFirst({ where: { id: messageId, senderType: ChatSenderType.AI, thread: { studentId: student.id, type: ChatThreadType.AI, status: { not: ChatThreadStatus.ARCHIVED }, }, }, include: { aiRun: true, senderUser: { select: { authUserId: true, email: true, name: true, }, }, }, }); if (!message) { throw new NotFoundException('AI 메시지를 찾을 수 없습니다.'); } const nextContent = typeof content === 'string' && content.trim() ? content.trim() : message.content; const canceledMessage = await this.prisma.chatMessage.update({ where: { id: message.id }, data: { content: nextContent, status: ChatMessageStatus.CANCELED, canceledAt: new Date(), }, include: { aiRun: true, senderUser: { select: { authUserId: true, email: true, name: true, }, }, }, }); return { message: this.serializeMessage(canceledMessage), }; } async cancelActiveAiResponse( accessToken: string | undefined, weekNumber?: number, ) { const student = await this.getLmsUser(accessToken); const course = await this.getActiveCourse(); const week = weekNumber ? await this.getCourseWeek(course.id, weekNumber) : null; const message = await this.prisma.chatMessage.findFirst({ where: { senderType: ChatSenderType.STUDENT, status: ChatMessageStatus.ACTIVE, thread: { courseId: course.id, studentId: student.id, weekId: week?.id ?? null, type: ChatThreadType.AI, status: { not: ChatThreadStatus.ARCHIVED }, }, }, orderBy: { createdAt: 'desc' }, include: { aiRun: true, senderUser: { select: { authUserId: true, email: true, name: true, }, }, }, }); if (!message) { return { message: null }; } const canceledMessage = await this.prisma.chatMessage.update({ where: { id: message.id }, data: { status: ChatMessageStatus.CANCELED, canceledAt: new Date(), }, include: { aiRun: true, senderUser: { select: { authUserId: true, email: true, name: true, }, }, }, }); return { message: this.serializeMessage(canceledMessage), }; } async listInstructorThreads( accessToken: string | undefined, query: ListChatThreadsDto, ) { const instructor = await this.getInstructorLmsUser(accessToken); const course = await this.getActiveCourse(); const week = query.week ? await this.getCourseWeek(course.id, query.week) : null; const threads = await this.prisma.chatThread.findMany({ where: { courseId: course.id, weekId: week?.id, type: ChatThreadType.INSTRUCTOR, status: { not: ChatThreadStatus.ARCHIVED }, ...this.instructorThreadVisibilityWhere(instructor), }, orderBy: { lastMessageAt: 'desc' }, include: { student: { select: { id: true, authUserId: true, email: true, name: true, }, }, assignedInstructor: { select: { id: true, email: true, name: true, }, }, messages: { where: { deletedAt: null }, orderBy: { createdAt: 'asc' }, include: { aiRun: true, senderUser: { select: { authUserId: true, email: true, name: true, }, }, }, }, week: { select: { week: true, title: true }, }, }, }); return { threads: threads.map((thread) => this.serializeInstructorThread(thread)), }; } async getInstructorThread(accessToken: string | undefined, threadId: string) { const instructor = await this.getInstructorLmsUser(accessToken); const thread = await this.getInstructorThreadById(threadId, instructor); return { thread: this.serializeInstructorThread(thread), }; } async createInstructorMessage( accessToken: string | undefined, threadId: string, dto: CreateInstructorChatMessageDto, ) { const instructor = await this.getInstructorLmsUser(accessToken); const thread = await this.getInstructorThreadById(threadId, instructor); const message = await this.prisma.$transaction(async (tx) => { const createdMessage = await tx.chatMessage.create({ data: { threadId, senderType: ChatSenderType.INSTRUCTOR, senderUserId: instructor.id, content: dto.content, }, }); await tx.chatThread.update({ where: { id: threadId }, data: { assignedInstructorId: instructor.id, status: ChatThreadStatus.OPEN, lastMessageAt: createdMessage.createdAt, }, }); return createdMessage; }); const updatedThread = await this.getInstructorThreadById(threadId, instructor); const payload = this.toNotificationPayload(threadId, message); this.chatGateway.notifyThread(threadId, payload); this.chatGateway.notifyUser(thread.student.id, payload); this.chatGateway.notifyCourseInstructors(thread.courseId, payload); return { thread: this.serializeInstructorThread(updatedThread), message: this.serializeMessage(message), }; } private async getLmsUser(accessToken: string | undefined) { if (!accessToken) throw new UnauthorizedException('Bearer token required'); const { user } = await this.authService.me(accessToken); return this.prisma.user.upsert({ where: { email: user.email }, update: this.toUserSyncData(user), create: { email: user.email, ...this.toUserSyncData(user), }, }); } private async getInstructorLmsUser(accessToken: string | undefined) { if (!accessToken) throw new UnauthorizedException('Bearer token required'); const { user } = await this.authService.me(accessToken); const roles = new Set( [user.role, ...(user.roles ?? [])].map((role) => role.toUpperCase()), ); if (!['INSTRUCTOR', 'LMS_ADMIN'].some((role) => roles.has(role))) { throw new ForbiddenException('Instructor role required'); } return this.prisma.user.upsert({ where: { email: user.email }, update: this.toUserSyncData(user), create: { email: user.email, ...this.toUserSyncData(user), }, }); } private toUserSyncData(user: AuthResponseUser) { return { authUserId: user.id, name: user.name, authRole: user.role, primaryOrganizationId: user.primaryAffiliation?.organizationId ?? null, primaryOrganizationName: user.primaryAffiliation?.organizationName ?? null, primaryOrganizationType: user.primaryAffiliation?.organizationType ?? null, department: user.primaryAffiliation?.department ?? null, position: user.primaryAffiliation?.position ?? null, }; } private instructorThreadVisibilityWhere( instructor: Prisma.UserGetPayload>, ): Prisma.ChatThreadWhereInput { const studentOrganizationFilters = [ instructor.primaryOrganizationId ? { primaryOrganizationId: instructor.primaryOrganizationId, } : undefined, instructor.primaryOrganizationName ? { progress: { some: { course: { organizationName: instructor.primaryOrganizationName, }, }, }, } : undefined, ].filter(Boolean) as Prisma.UserWhereInput[]; return { OR: [ { assignedInstructorId: null }, { assignedInstructorId: instructor.id }, ], student: studentOrganizationFilters.length ? { OR: studentOrganizationFilters, } : { id: '__NO_ORGANIZATION_SCOPE__', }, }; } private async getActiveCourse() { const course = await this.prisma.course.findFirst({ where: { slug: 'futuredrive-autonomy-basic', isActive: true }, }); if (!course) throw new NotFoundException('Curriculum course not found'); return course; } private async getCourseWeek(courseId: string, weekNumber: number) { const week = await this.prisma.courseWeek.findUnique({ where: { courseId_week: { courseId, week: weekNumber } }, }); if (!week) throw new NotFoundException('Course week not found'); return week; } private async getOrCreateThread(input: { courseId: string; weekId: string | null; studentId: string; type: ChatThreadType; firstMessage: string; status: ChatThreadStatus; }) { const thread = await this.prisma.chatThread.findFirst({ where: { courseId: input.courseId, weekId: input.weekId, studentId: input.studentId, type: input.type, status: { not: ChatThreadStatus.ARCHIVED }, }, orderBy: { lastMessageAt: 'desc' }, }); if (thread) return thread; return this.prisma.chatThread.create({ data: { courseId: input.courseId, weekId: input.weekId, studentId: input.studentId, type: input.type, status: input.status, title: input.firstMessage.slice(0, 80), }, }); } private async getInstructorThreadById( threadId: string, instructor: Prisma.UserGetPayload>, ) { const thread = await this.prisma.chatThread.findFirst({ where: { id: threadId, type: ChatThreadType.INSTRUCTOR, status: { not: ChatThreadStatus.ARCHIVED }, ...this.instructorThreadVisibilityWhere(instructor), }, include: { student: { select: { id: true, authUserId: true, email: true, name: true, }, }, assignedInstructor: { select: { id: true, email: true, name: true, }, }, messages: { where: { deletedAt: null }, orderBy: { createdAt: 'asc' }, include: { aiRun: true, senderUser: { select: { authUserId: true, email: true, name: true, }, }, }, }, week: { select: { week: true, title: true }, }, }, }); if (!thread) throw new NotFoundException('Instructor chat thread not found'); return thread; } private async requestAiChatCompletion( model: string, messages: Array<{ role: 'user' | 'assistant'; content: string }>, ) { const provider = this.configService.get('AI_PROVIDER', 'openai').trim().toLowerCase(); if (provider === 'futuredrive') { return this.requestFutureDriveLlmChatCompletion(messages); } return this.requestOpenAiChatCompletion(model, messages); } private async requestOpenAiChatCompletion( model: string, messages: Array<{ role: 'user' | 'assistant'; content: string }>, ) { const apiKey = this.configService.get('OPENAI_API_KEY'); if (!apiKey) { throw new Error( 'OPENAI_API_KEY가 설정되어 있지 않습니다. lms-be .env에 OPENAI_API_KEY=... 형식으로 추가하세요.', ); } const response = await fetch('https://api.openai.com/v1/chat/completions', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ model, temperature: 0.3, messages: [ { role: 'system', content: '당신은 자율주행 교육용 ROS2, SLAM 실습을 돕는 한국어 AI 챗봇입니다. 답변은 짧고 실습자가 바로 따라 할 수 있게 명확하게 작성하세요.', }, ...messages, ], }), }); const data = (await response.json()) as OpenAiChatCompletionResponse; if (!response.ok) { throw new Error(data.error?.message ?? `OpenAI request failed: ${response.status}`); } const content = data.choices?.[0]?.message?.content?.trim(); if (!content) throw new Error('OpenAI 응답이 비어 있습니다.'); return { content, usage: { promptTokens: data.usage?.prompt_tokens, completionTokens: data.usage?.completion_tokens, totalTokens: data.usage?.total_tokens, }, }; } private async requestFutureDriveLlmChatCompletion( messages: Array<{ role: 'user' | 'assistant'; content: string }>, ) { const apiKey = this.configService.get('FUTUREDRIVE_LLM_API_KEY'); if (!apiKey) { throw new Error( 'FUTUREDRIVE_LLM_API_KEY가 설정되어 있지 않습니다. lms-be .env에 FUTUREDRIVE_LLM_API_KEY=... 형식으로 추가하세요.', ); } const baseUrl = this.configService .get('FUTUREDRIVE_LLM_BASE_URL', 'https://famine-tissue-cursor.ngrok-free.dev') .replace(/\/+$/, ''); const lang = this.configService.get('FUTUREDRIVE_LLM_LANG', 'ko'); const response = await fetch(`${baseUrl}/api/chat/complete`, { method: 'POST', headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json', 'ngrok-skip-browser-warning': '1', }, body: JSON.stringify({ messages, lang, }), }); const data = (await response.json().catch(() => ({}))) as FutureDriveLlmChatResponse; if (!response.ok) { throw new Error(data.error?.message ?? data.message ?? `FutureDrive LLM request failed: ${response.status}`); } const content = data.answer?.trim(); if (!content) throw new Error('FutureDrive LLM 응답이 비어 있습니다.'); return { content, usage: { promptTokens: undefined, completionTokens: undefined, totalTokens: undefined, }, }; } private toThreadType(mode: 'ai' | 'teacher') { return mode === 'ai' ? ChatThreadType.AI : ChatThreadType.INSTRUCTOR; } private serializeThread( thread: Prisma.ChatThreadGetPayload<{ include: { messages: { include: { aiRun: true; senderUser: { select: { authUserId: true; email: true; name: true } } } }; week: { select: { week: true; title: true } }; }; }>, ) { return { id: thread.id, type: thread.type, status: thread.status, title: thread.title, week: thread.week, lastMessageAt: thread.lastMessageAt, messages: [...thread.messages] .sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime()) .map((message) => this.serializeMessage(message)), }; } private serializeInstructorThread( thread: Prisma.ChatThreadGetPayload<{ include: { student: { select: { id: true; authUserId: true; email: true; name: true } }; assignedInstructor: { select: { id: true; email: true; name: true } }; messages: { include: { aiRun: true; senderUser: { select: { authUserId: true; email: true; name: true } } } }; week: { select: { week: true; title: true } }; }; }>, ) { return { id: thread.id, type: thread.type, status: thread.status, title: thread.title, student: thread.student, studentName: thread.student.name ?? thread.student.email, studentAvatarUrl: this.getStudentAvatarUrl(thread.student), assignedInstructor: thread.assignedInstructor, week: thread.week, lastMessageAt: thread.lastMessageAt, messages: thread.messages.map((message) => this.serializeMessage(message)), }; } private serializeMessage( message: Prisma.ChatMessageGetPayload<{ include: { aiRun: true; senderUser: { select: { authUserId: true; email: true; name: true } }; }; }> | { id: string; senderType: ChatSenderType; senderUserId: string | null; senderUser?: { email: string; name: string | null } | null; content: string; contentFormat: string; status?: ChatMessageStatus; metadata: Prisma.JsonValue | null; createdAt: Date; canceledAt?: Date | null; deletedAt: Date | null; }, ) { return { id: message.id, senderType: message.senderType, senderUserId: message.senderUserId, senderName: message.senderUser?.name ?? message.senderUser?.email ?? null, senderAvatarUrl: this.getUserAvatarUrl(message.senderUser ?? null), content: message.content, contentFormat: message.contentFormat, status: 'status' in message ? message.status : ChatMessageStatus.ACTIVE, metadata: message.metadata, createdAt: message.createdAt, canceledAt: 'canceledAt' in message ? message.canceledAt : null, }; } private toNotificationPayload( threadId: string, message: { id: string; senderType: ChatSenderType; content: string; createdAt: Date; }, ): ChatNotificationPayload { return { type: 'chat.message.created', threadId, messageId: message.id, senderType: message.senderType, preview: message.content.slice(0, 120), createdAt: message.createdAt.toISOString(), }; } private getStudentAvatarUrl(student: { authUserId: string | null; email: string; }) { return this.getUserAvatarUrl(student); } private getUserAvatarUrl(user: { authUserId?: string | null; email?: string | null; } | null) { if (!user) return null; if (user.authUserId) { return `${this.authServerUrl}/auth/users/${user.authUserId}/avatar`; } if (user.email) { return `${this.authServerUrl}/auth/users/avatar?email=${encodeURIComponent(user.email)}`; } return null; } }