990 lines
28 KiB
TypeScript
990 lines
28 KiB
TypeScript
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<string>('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<string>('AI_PROVIDER', 'openai').trim().toLowerCase();
|
|
const model = aiProvider === 'futuredrive'
|
|
? this.configService.get<string>('FUTUREDRIVE_LLM_MODEL', 'futuredrive-llm')
|
|
: this.configService.get<string>('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<Record<string, never>>,
|
|
): 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<Record<string, never>>,
|
|
) {
|
|
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<string>('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<string>('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<string>('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<string>('FUTUREDRIVE_LLM_BASE_URL', 'https://famine-tissue-cursor.ngrok-free.dev')
|
|
.replace(/\/+$/, '');
|
|
const lang = this.configService.get<string>('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;
|
|
}
|
|
}
|