feat: add global AI guide to LMS launcher

This commit is contained in:
2026-07-15 14:08:46 +09:00
parent 860334a86b
commit 5e7cffa901
4 changed files with 87 additions and 8 deletions
+5
View File
@@ -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,
+70 -7
View File
@@ -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<string>('FUTUREDRIVE_LLM_MODEL', 'futuredrive-llm')
: this.configService.get<string>('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<string>('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<string>('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<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(
thread: Prisma.ChatThreadGetPayload<{
include: {
+6
View File
@@ -9,4 +9,10 @@ export class CreateAiResponseDto {
@Min(1)
@Max(100)
week?: number;
@IsOptional()
@IsInt()
@Min(1)
@Max(100)
contextWeek?: number;
}
+6 -1
View File
@@ -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;
}