|
|
@@ -88,9 +88,9 @@ export class ChatService {
|
|
|
|
where: {
|
|
|
|
where: {
|
|
|
|
courseId: course.id,
|
|
|
|
courseId: course.id,
|
|
|
|
studentId: student.id,
|
|
|
|
studentId: student.id,
|
|
|
|
weekId: week?.id,
|
|
|
|
weekId: week?.id ?? null,
|
|
|
|
type: query.mode ? this.toThreadType(query.mode) : undefined,
|
|
|
|
type: query.mode ? this.toThreadType(query.mode) : undefined,
|
|
|
|
status: { not: ChatThreadStatus.ARCHIVED },
|
|
|
|
status: query.includeArchived ? undefined : { not: ChatThreadStatus.ARCHIVED },
|
|
|
|
},
|
|
|
|
},
|
|
|
|
orderBy: { lastMessageAt: 'desc' },
|
|
|
|
orderBy: { lastMessageAt: 'desc' },
|
|
|
|
include: {
|
|
|
|
include: {
|
|
|
@@ -131,7 +131,7 @@ export class ChatService {
|
|
|
|
where: {
|
|
|
|
where: {
|
|
|
|
courseId: course.id,
|
|
|
|
courseId: course.id,
|
|
|
|
studentId: student.id,
|
|
|
|
studentId: student.id,
|
|
|
|
weekId: week?.id,
|
|
|
|
weekId: week?.id ?? null,
|
|
|
|
type: query.mode ? this.toThreadType(query.mode) : undefined,
|
|
|
|
type: query.mode ? this.toThreadType(query.mode) : undefined,
|
|
|
|
status: { not: ChatThreadStatus.ARCHIVED },
|
|
|
|
status: { not: ChatThreadStatus.ARCHIVED },
|
|
|
|
},
|
|
|
|
},
|
|
|
@@ -143,6 +143,25 @@ export class ChatService {
|
|
|
|
return { deletedCount: result.count };
|
|
|
|
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(
|
|
|
|
async createStudentMessage(
|
|
|
|
accessToken: string | undefined,
|
|
|
|
accessToken: string | undefined,
|
|
|
|
dto: CreateChatMessageDto,
|
|
|
|
dto: CreateChatMessageDto,
|
|
|
@@ -247,6 +266,9 @@ export class ChatService {
|
|
|
|
const model = aiProvider === 'futuredrive'
|
|
|
|
const model = aiProvider === 'futuredrive'
|
|
|
|
? this.configService.get<string>('FUTUREDRIVE_LLM_MODEL', 'futuredrive-llm')
|
|
|
|
? this.configService.get<string>('FUTUREDRIVE_LLM_MODEL', 'futuredrive-llm')
|
|
|
|
: this.configService.get<string>('OPENAI_CHAT_MODEL', 'gpt-4.1-nano');
|
|
|
|
: this.configService.get<string>('OPENAI_CHAT_MODEL', 'gpt-4.1-nano');
|
|
|
|
|
|
|
|
const curriculumContext = dto.contextWeek
|
|
|
|
|
|
|
|
? await this.getCurriculumContext(course.id, dto.contextWeek)
|
|
|
|
|
|
|
|
: null;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
try {
|
|
|
|
const aiResponse = await this.requestAiChatCompletion(
|
|
|
|
const aiResponse = await this.requestAiChatCompletion(
|
|
|
@@ -258,6 +280,7 @@ export class ChatService {
|
|
|
|
: ('assistant' as const),
|
|
|
|
: ('assistant' as const),
|
|
|
|
content: message.content,
|
|
|
|
content: message.content,
|
|
|
|
})),
|
|
|
|
})),
|
|
|
|
|
|
|
|
curriculumContext,
|
|
|
|
);
|
|
|
|
);
|
|
|
|
const currentStudentMessage = await this.prisma.chatMessage.findUnique({
|
|
|
|
const currentStudentMessage = await this.prisma.chatMessage.findUnique({
|
|
|
|
where: { id: studentMessage.id },
|
|
|
|
where: { id: studentMessage.id },
|
|
|
@@ -309,6 +332,7 @@ export class ChatService {
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
return {
|
|
|
|
threadId: thread.id,
|
|
|
|
threadId: thread.id,
|
|
|
|
|
|
|
|
threadTitle: thread.title,
|
|
|
|
message: this.serializeMessage(assistantMessage),
|
|
|
|
message: this.serializeMessage(assistantMessage),
|
|
|
|
content: assistantMessage.content,
|
|
|
|
content: assistantMessage.content,
|
|
|
|
};
|
|
|
|
};
|
|
|
@@ -700,7 +724,7 @@ export class ChatService {
|
|
|
|
studentId: input.studentId,
|
|
|
|
studentId: input.studentId,
|
|
|
|
type: input.type,
|
|
|
|
type: input.type,
|
|
|
|
status: input.status,
|
|
|
|
status: input.status,
|
|
|
|
title: input.firstMessage.slice(0, 80),
|
|
|
|
title: this.makeThreadTitle(input.firstMessage),
|
|
|
|
},
|
|
|
|
},
|
|
|
|
});
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
@@ -760,17 +784,21 @@ export class ChatService {
|
|
|
|
private async requestAiChatCompletion(
|
|
|
|
private async requestAiChatCompletion(
|
|
|
|
model: string,
|
|
|
|
model: string,
|
|
|
|
messages: Array<{ role: 'user' | 'assistant'; content: string }>,
|
|
|
|
messages: Array<{ role: 'user' | 'assistant'; content: string }>,
|
|
|
|
|
|
|
|
curriculumContext?: string | null,
|
|
|
|
) {
|
|
|
|
) {
|
|
|
|
const provider = this.configService.get<string>('AI_PROVIDER', 'openai').trim().toLowerCase();
|
|
|
|
const provider = this.configService.get<string>('AI_PROVIDER', 'openai').trim().toLowerCase();
|
|
|
|
if (provider === 'futuredrive') {
|
|
|
|
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(
|
|
|
|
private async requestOpenAiChatCompletion(
|
|
|
|
model: string,
|
|
|
|
model: string,
|
|
|
|
messages: Array<{ role: 'user' | 'assistant'; content: string }>,
|
|
|
|
messages: Array<{ role: 'user' | 'assistant'; content: string }>,
|
|
|
|
|
|
|
|
curriculumContext?: string | null,
|
|
|
|
) {
|
|
|
|
) {
|
|
|
|
const apiKey = this.configService.get<string>('OPENAI_API_KEY');
|
|
|
|
const apiKey = this.configService.get<string>('OPENAI_API_KEY');
|
|
|
|
if (!apiKey) {
|
|
|
|
if (!apiKey) {
|
|
|
@@ -792,7 +820,7 @@ export class ChatService {
|
|
|
|
{
|
|
|
|
{
|
|
|
|
role: 'system',
|
|
|
|
role: 'system',
|
|
|
|
content:
|
|
|
|
content:
|
|
|
|
'당신은 자율주행 교육용 ROS2, SLAM 실습을 돕는 한국어 AI 챗봇입니다. 답변은 짧고 실습자가 바로 따라 할 수 있게 명확하게 작성하세요.',
|
|
|
|
`당신은 자율주행 교육용 ROS2, SLAM 실습을 돕는 한국어 AI 챗봇입니다. 답변은 짧고 실습자가 바로 따라 할 수 있게 명확하게 작성하세요.${curriculumContext ? `\n\n${curriculumContext}` : ''}`,
|
|
|
|
},
|
|
|
|
},
|
|
|
|
...messages,
|
|
|
|
...messages,
|
|
|
|
],
|
|
|
|
],
|
|
|
@@ -867,6 +895,41 @@ export class ChatService {
|
|
|
|
return mode === 'ai' ? ChatThreadType.AI : ChatThreadType.INSTRUCTOR;
|
|
|
|
return mode === 'ai' ? ChatThreadType.AI : ChatThreadType.INSTRUCTOR;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private async getCurriculumContext(courseId: string, weekNumber: number): Promise<string | null> {
|
|
|
|
|
|
|
|
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(
|
|
|
|
private serializeThread(
|
|
|
|
thread: Prisma.ChatThreadGetPayload<{
|
|
|
|
thread: Prisma.ChatThreadGetPayload<{
|
|
|
|
include: {
|
|
|
|
include: {
|
|
|
|