diff --git a/src/ai/agent.service.ts b/src/ai/agent.service.ts index 7af53dd..a04a6d4 100644 --- a/src/ai/agent.service.ts +++ b/src/ai/agent.service.ts @@ -9,7 +9,7 @@ import { AiProviderService, type CompletionInput } from './ai-provider.service'; import { AgentImagesService, supportsAgentImages, type AgentImageScope } from './agent-images.service'; import { AGENT_DOCUMENT_CATALOG, isAgentDocumentId, readAgentDocument } from './agent-documents'; import { AGENT_READ_TOOLS, AGENT_RESULT_NAMES, safeAgentPath, validateAgentOperation, validateAgentTool, type AgentToolResult } from './agent-tools'; -import type { AgentCommand, AgentFileChange, AgentModulePlan, AgentPlan, AiProvider } from './ai.types'; +import { AI_PROVIDERS, type AgentCommand, type AgentFileChange, type AgentModulePlan, type AgentPlan, type AiProvider } from './ai.types'; import { AgentSessionQueryDto, SyncAgentSessionDto } from './dto/agent-session.dto'; import { CreateAgentPlanDto } from './dto/create-agent-plan.dto'; @@ -42,13 +42,17 @@ export class AgentService { const course = await this.getStudentCourse(student.id, courseId); const setting = await this.prisma.courseAiSetting.findUnique({ where: { courseId: course.id } }); return { - enabled: Boolean(setting?.enabled && setting.encryptedApiKey), + enabled: Boolean(setting?.enabled && this.hasProviderCredentials(setting)), provider: setting?.provider ?? null, - model: setting?.model ?? null, + model: setting?.provider === 'A_COPILOT' ? 'A-Copilot' : setting?.model ?? null, imagesEnabled: Boolean(setting?.enabled && supportsAgentImages(setting.provider, setting.model)), }; } + private hasProviderCredentials(setting: { provider: string; encryptedApiKey: string | null }): boolean { + return setting.provider === 'A_COPILOT' ? this.providerService.copilotConfigured() : Boolean(setting.encryptedApiKey); + } + async uploadImage(accessToken: string | undefined, input: AgentSessionQueryDto, file: { originalname: string; buffer: Buffer } | undefined) { const scope = await this.getAgentScope(accessToken, input.courseId, input.week); const setting = await this.prisma.courseAiSetting.findUnique({ where: { courseId: scope.course.id } }); @@ -255,10 +259,10 @@ export class AgentService { const student = await this.getLmsUser(accessToken); const course = await this.getStudentCourse(student.id, dto.courseId); const setting = await this.prisma.courseAiSetting.findUnique({ where: { courseId: course.id } }); - if (!setting?.enabled || !setting.encryptedApiKey) { + if (!setting?.enabled || !this.hasProviderCredentials(setting)) { throw new BadRequestException('이 강좌에는 AI 에이전트가 설정되어 있지 않습니다.'); } - if (!['OPENAI', 'ANTHROPIC', 'GEMINI', 'QWEN'].includes(setting.provider)) { + if (!(AI_PROVIDERS as readonly string[]).includes(setting.provider)) { throw new BadRequestException('지원하지 않는 AI 공급자입니다.'); } @@ -307,8 +311,8 @@ export class AgentService { const completionInput: CompletionInput = { provider: setting.provider as AiProvider, model: setting.model, - apiKey: this.credentialService.decrypt(setting.encryptedApiKey), - endpointUrl: setting.endpointUrl, + apiKey: setting.provider === 'A_COPILOT' ? '' : this.credentialService.decrypt(setting.encryptedApiKey!), + endpointUrl: setting.provider === 'A_COPILOT' ? null : setting.endpointUrl, json: true, system: this.systemPrompt(), messages: [{ @@ -362,7 +366,7 @@ export class AgentService { } } if (!plan.requestedDocs.length) { - return { provider: setting.provider, model: setting.model, usage: this.sumUsage(responses), plan: this.groundToolPlan(plan, dto, toolResults) }; + return { provider: setting.provider, model: setting.provider === 'A_COPILOT' ? 'A-Copilot' : setting.model, usage: this.sumUsage(responses), plan: this.groundToolPlan(plan, dto, toolResults) }; } const documents = await Promise.all(plan.requestedDocs.map((id) => readAgentDocument(id))); @@ -384,7 +388,7 @@ export class AgentService { if (this.isUnexecutablePlan(plan)) { throw new BadGatewayException('AI가 문서를 읽은 뒤에도 실행할 수 없는 계획만 반환했습니다. 질문을 다시 보내주세요.'); } - return { provider: setting.provider, model: setting.model, usage: this.sumUsage([...responses, finalResponse]), plan: this.groundToolPlan(plan, dto, toolResults) }; + return { provider: setting.provider, model: setting.provider === 'A_COPILOT' ? 'A-Copilot' : setting.model, usage: this.sumUsage([...responses, finalResponse]), plan: this.groundToolPlan(plan, dto, toolResults) }; } private parseToolResults(input: unknown): AgentToolResult[] { diff --git a/src/ai/ai-provider.service.ts b/src/ai/ai-provider.service.ts index d0945db..8cc02b0 100644 --- a/src/ai/ai-provider.service.ts +++ b/src/ai/ai-provider.service.ts @@ -2,6 +2,7 @@ import { BadGatewayException, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import type { AiCompletionResult, AiProvider } from './ai.types'; +import { getCopilotConfiguration, requestFutureDriveChatCompletion } from './copilot-backend'; export type CompletionInput = { provider: AiProvider; @@ -17,9 +18,14 @@ export type CompletionInput = { export class AiProviderService { constructor(private readonly configService: ConfigService) {} + copilotConfigured(): boolean { + return getCopilotConfiguration(this.configService).configured; + } + async complete(input: CompletionInput): Promise { if (input.provider !== 'QWEN' && input.messages.some(message => message.images?.length)) throw new BadGatewayException('이미지 첨부는 현재 Qwen에서만 지원합니다.'); switch (input.provider) { + case 'A_COPILOT': return this.copilot(input); case 'OPENAI': return this.openAi(input); case 'ANTHROPIC': return this.anthropic(input); case 'GEMINI': return this.gemini(input); @@ -27,6 +33,24 @@ export class AiProviderService { } } + private async copilot(input: CompletionInput): Promise { + const configuration = getCopilotConfiguration(this.configService); + if (!configuration.configured) throw new BadGatewayException('A-Copilot 서버 연결이 설정되어 있지 않습니다.'); + if (configuration.backend === 'futuredrive') { + return requestFutureDriveChatCompletion(this.configService, [ + { role: 'user', content: input.system }, + ...input.messages.map(({ role, content }) => ({ role, content })), + ]); + } + return this.openAi({ + ...input, + provider: 'OPENAI', + model: configuration.model, + apiKey: this.configService.get('OPENAI_API_KEY')!.trim(), + endpointUrl: null, + }); + } + private async openAi(input: CompletionInput): Promise { const reasoningModel = /^gpt-(?:5|6)(?:[.-]|$)|^o[1-9](?:-|$)/.test(input.model); const response = await fetch('https://api.openai.com/v1/chat/completions', { diff --git a/src/ai/ai.types.ts b/src/ai/ai.types.ts index a28309d..1cdfd56 100644 --- a/src/ai/ai.types.ts +++ b/src/ai/ai.types.ts @@ -1,6 +1,6 @@ import type { AgentOperation, AgentToolRequest, AgentToolResult } from './agent-tools'; -export const AI_PROVIDERS = ['OPENAI', 'ANTHROPIC', 'GEMINI', 'QWEN'] as const; +export const AI_PROVIDERS = ['A_COPILOT', 'OPENAI', 'ANTHROPIC', 'GEMINI', 'QWEN'] as const; export type AiProvider = typeof AI_PROVIDERS[number]; export type AiCompletionUsage = { diff --git a/src/ai/copilot-backend.spec.ts b/src/ai/copilot-backend.spec.ts new file mode 100644 index 0000000..e0ead31 --- /dev/null +++ b/src/ai/copilot-backend.spec.ts @@ -0,0 +1,107 @@ +import type { ConfigService } from '@nestjs/config'; +import { ChatService } from '../chat/chat.service'; +import { AgentService } from './agent.service'; +import { AiProviderService } from './ai-provider.service'; +import { getCopilotConfiguration } from './copilot-backend'; + +const config = (values: Record) => ({ get: (key: string, fallback?: string) => values[key] ?? fallback }) as ConfigService; +const configuration = { + AI_PROVIDER: 'futuredrive', FUTUREDRIVE_LLM_BASE_URL: 'https://shared-ai.test/', + FUTUREDRIVE_LLM_API_KEY: 'server-only-secret', FUTUREDRIVE_LLM_LANG: 'ko', +}; +const plan = { summary: '분석 완료', explanation: '확인한 파일을 기준으로 답변합니다.', changes: [], commands: [], createModule: null }; + +describe('managed A-Copilot backend', () => { + const originalFetch = global.fetch; + afterEach(() => { global.fetch = originalFetch; jest.restoreAllMocks(); }); + + it('uses exactly the same FutureDrive transport and server key as the AI tab', async () => { + const fetchMock = jest.fn().mockResolvedValue({ ok: true, json: async () => ({ answer: JSON.stringify(plan) }) }); + global.fetch = fetchMock as typeof fetch; + const settings = config(configuration); + const chat = Object.assign(Object.create(ChatService.prototype), { configService: settings }); + await chat.requestAiChatCompletion('ignored-model', [{ role: 'user', content: '학습 질문' }], '공식 교육 내용'); + const provider = new AiProviderService(settings); + await provider.complete({ + provider: 'A_COPILOT', model: 'untrusted-model', apiKey: 'untrusted-key', endpointUrl: 'https://untrusted.test', + system: 'Return an agent JSON plan', json: true, messages: [{ role: 'user', content: '워크스페이스 문맥' }], + }); + for (const [url, request] of fetchMock.mock.calls) { + expect(url).toBe('https://shared-ai.test/api/chat/complete'); + expect(request.headers).toEqual({ 'X-API-Key': 'server-only-secret', 'Content-Type': 'application/json', 'ngrok-skip-browser-warning': '1' }); + const body = JSON.parse(request.body); + expect(Object.keys(body).sort()).toEqual(['lang', 'messages']); + expect(body.lang).toBe('ko'); + expect(request.body).not.toMatch(/server-only-secret|untrusted/); + } + expect(JSON.parse(fetchMock.mock.calls[0][1].body).messages[0].content).toBe('공식 교육 내용'); + expect(JSON.parse(fetchMock.mock.calls[1][1].body).messages).toEqual([ + { role: 'user', content: 'Return an agent JSON plan' }, { role: 'user', content: '워크스페이스 문맥' }, + ]); + }); + + it('follows the AI tab server selection when OpenAI is configured, without accepting course overrides', async () => { + const fetchMock = jest.fn().mockResolvedValue({ ok: true, json: async () => ({ choices: [{ message: { content: JSON.stringify(plan) } }] }) }); + global.fetch = fetchMock as typeof fetch; + const provider = new AiProviderService(config({ AI_PROVIDER: 'openai', OPENAI_API_KEY: 'shared-openai-key', OPENAI_CHAT_MODEL: 'gpt-6-astra' })); + await provider.complete({ provider: 'A_COPILOT', model: 'ignored', apiKey: 'ignored', endpointUrl: 'https://untrusted.test', system: 'agent instructions', json: true, messages: [{ role: 'user', content: 'test' }] }); + expect(fetchMock.mock.calls[0][0]).toBe('https://api.openai.com/v1/chat/completions'); + expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe('Bearer shared-openai-key'); + expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toMatchObject({ model: 'gpt-6-astra', response_format: { type: 'json_object' }, messages: [{ role: 'system', content: 'agent instructions' }, { role: 'user', content: 'test' }] }); + }); + + it('never falls back to a different server provider when its configured key is missing', async () => { + const settings = config({ AI_PROVIDER: 'futuredrive', OPENAI_API_KEY: 'other-provider-key' }); + expect(getCopilotConfiguration(settings)).toMatchObject({ backend: 'futuredrive', configured: false }); + const fetchMock = jest.fn(); global.fetch = fetchMock as typeof fetch; + const provider = new AiProviderService(settings); + await expect(provider.complete({ provider: 'A_COPILOT', model: 'a-copilot', apiKey: 'course-key', system: 'test', messages: [] })).rejects.toThrow(/서버 연결/); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it.each([ + { ok: false, status: 503, json: async () => ({ message: 'upstream unavailable' }) }, + { ok: true, status: 200, json: async () => ({ answer: '' }) }, + { ok: true, status: 200, json: async () => { throw Error('invalid JSON'); } }, + ])('rejects upstream failures or empty answers instead of inventing a successful plan', async response => { + global.fetch = jest.fn().mockResolvedValue(response) as typeof fetch; + const provider = new AiProviderService(config(configuration)); + await expect(provider.complete({ provider: 'A_COPILOT', model: 'a-copilot', apiKey: '', system: 'test', messages: [] })).rejects.toThrow(); + }); + + it('does not silently send images to the text-only API', async () => { + const fetchMock = jest.fn(); global.fetch = fetchMock as typeof fetch; + const provider = new AiProviderService(config(configuration)); + await expect(provider.complete({ provider: 'A_COPILOT', model: 'a-copilot', apiKey: '', system: 'test', messages: [{ role: 'user', content: 'test', images: [{ mimeType: 'image/png', data: 'test' }] }] })).rejects.toThrow(/Qwen/); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('creates and repairs an agent JSON plan using server credentials, without a course credential', async () => { + const fetchMock = jest.fn() + .mockResolvedValueOnce({ ok: true, json: async () => ({ answer: 'invalid plan JSON' }) }) + .mockResolvedValueOnce({ ok: true, json: async () => ({ answer: JSON.stringify(plan) }) }); + global.fetch = fetchMock as typeof fetch; + const decrypt = jest.fn(); + const setting = { provider: 'A_COPILOT', model: 'a-copilot', enabled: true, encryptedApiKey: null, endpointUrl: null }; + const provider = new AiProviderService(config(configuration)); + const service = Object.assign(Object.create(AgentService.prototype), { + getLmsUser: jest.fn().mockResolvedValue({ id: 'student' }), + getStudentCourse: jest.fn().mockResolvedValue({ id: 'course', title: '실습' }), + prisma: { courseAiSetting: { findUnique: jest.fn().mockResolvedValue(setting) } }, + credentialService: { decrypt }, providerService: provider, + }) as AgentService; + await expect(service.status('token', 'course')).resolves.toEqual({ enabled: true, provider: 'A_COPILOT', model: 'A-Copilot', imagesEnabled: false }); + const result = await service.createPlan('token', { courseId: 'course', request: '파일 분석', modules: [{ name: 'NAV2-Control', files: [{ path: 'config/control.yaml', content: 'velocity: 0.5' }] }], toolProtocol: 1 }); + expect(result).toMatchObject({ provider: 'A_COPILOT', model: 'A-Copilot', plan }); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(decrypt).not.toHaveBeenCalled(); + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.messages[0].content).toContain('JSON'); + expect(body.messages[1].content).toContain('velocity: 0.5'); + expect(JSON.stringify(result)).not.toContain('server-only-secret'); + jest.spyOn(provider, 'copilotConfigured').mockReturnValue(false); + await expect(service.status('token', 'course')).resolves.toMatchObject({ enabled: false, imagesEnabled: false }); + await expect(service.createPlan('token', { courseId: 'course', request: 'test', modules: [] })).rejects.toThrow(/설정되어 있지/); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/ai/copilot-backend.ts b/src/ai/copilot-backend.ts new file mode 100644 index 0000000..bcdb7ba --- /dev/null +++ b/src/ai/copilot-backend.ts @@ -0,0 +1,32 @@ +import { BadGatewayException } from '@nestjs/common'; +import type { ConfigService } from '@nestjs/config'; +import type { AiCompletionResult } from './ai.types'; + +export const COPILOT_MODEL = 'a-copilot'; +export type CopilotMessage = { role: 'user' | 'assistant'; content: string }; + +export function getCopilotConfiguration(config: ConfigService) { + const backend = (config.get('AI_PROVIDER', 'openai') ?? 'openai').trim().toLowerCase() === 'futuredrive' ? 'futuredrive' : 'openai'; + const model = backend === 'futuredrive' + ? config.get('FUTUREDRIVE_LLM_MODEL', 'futuredrive-llm') ?? 'futuredrive-llm' + : config.get('OPENAI_CHAT_MODEL', 'gpt-4.1-nano') ?? 'gpt-4.1-nano'; + const configured = Boolean(config.get(backend === 'futuredrive' ? 'FUTUREDRIVE_LLM_API_KEY' : 'OPENAI_API_KEY')?.trim()); + return { backend, model, configured }; +} + +export async function requestFutureDriveChatCompletion(config: ConfigService, messages: CopilotMessage[]): Promise { + const apiKey = config.get('FUTUREDRIVE_LLM_API_KEY')?.trim(); + if (!apiKey) throw new BadGatewayException('A-Copilot 서버의 FUTUREDRIVE_LLM_API_KEY가 설정되어 있지 않습니다.'); + const baseUrl = (config.get('FUTUREDRIVE_LLM_BASE_URL', 'https://famine-tissue-cursor.ngrok-free.dev') ?? 'https://famine-tissue-cursor.ngrok-free.dev').replace(/\/+$/, ''); + const lang = config.get('FUTUREDRIVE_LLM_LANG', 'ko') ?? '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 { answer?: string; error?: { message?: string }; message?: string }; + if (!response.ok) throw new BadGatewayException(data.error?.message ?? data.message ?? `A-Copilot request failed: ${response.status}`); + const content = typeof data.answer === 'string' ? data.answer.trim() : ''; + if (!content) throw new BadGatewayException('A-Copilot 응답이 비어 있습니다.'); + return { content, usage: { promptTokens: undefined, completionTokens: undefined, totalTokens: undefined } }; +} diff --git a/src/ai/docs/lms-user-guide.ko.md b/src/ai/docs/lms-user-guide.ko.md index cb7905f..e281ac1 100644 --- a/src/ai/docs/lms-user-guide.ko.md +++ b/src/ai/docs/lms-user-guide.ko.md @@ -268,6 +268,8 @@ EXPLORER 위쪽 아이콘: - `에이전트`(Agent, 마술봉): 워크스페이스 정보 기반 분석, 필요한 소스/로그 요청, 코드 변경 제안, 승인 기반 명령 실행. 강좌의 에이전트 설정이 필요하다. - `강사`(Instructor, 사람): 강사에게 질문하고 답변을 기다린다. 강사 메시지와 AI 생성 답변은 다르다. +강사가 `설정 > AI 에이전트`에서 공급자를 `A-Copilot`으로 선택하면 일반 AI 탭과 같은 서버 공용 API 연결을 사용한다. A-Copilot의 모델과 API 키는 서버에서 관리하며 강좌나 학생 앱에서 입력하지 않는다. A-Copilot은 현재 텍스트 파일·로그 분석을 지원하며 이미지 첨부는 지원하지 않는다. 파일 변경 적용과 명령 실행은 다른 공급자와 동일하게 학생의 승인을 거친다. + ### 14.1 에이전트로 사용법 묻기 “NAV2-Control 파라미터는 어느 버튼에서 바꾸고 저장해?”, “실제 차량에 로컬 모듈을 연결하는 순서를 버튼 이름으로 알려줘”, “연결종료와 반납이 어떻게 달라?”처럼 질문한다. 에이전트는 사용법 문서가 필요하다고 판단하면 서버의 이 문서를 요청해 읽고 설명한다. 사용자가 편집기에서 매뉴얼 파일을 열 필요가 없다. diff --git a/src/ai/dto/update-course-ai-setting.dto.ts b/src/ai/dto/update-course-ai-setting.dto.ts index 66d151d..22e6570 100644 --- a/src/ai/dto/update-course-ai-setting.dto.ts +++ b/src/ai/dto/update-course-ai-setting.dto.ts @@ -1,4 +1,4 @@ -import { IsBoolean, IsIn, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; +import { IsBoolean, IsIn, IsOptional, IsString, MaxLength, MinLength, ValidateIf } from 'class-validator'; import { AI_PROVIDERS, type AiProvider } from '../ai.types'; @@ -9,10 +9,11 @@ export class UpdateCourseAiSettingDto { @IsIn(AI_PROVIDERS) provider: AiProvider; + @ValidateIf((input: UpdateCourseAiSettingDto) => input.provider !== 'A_COPILOT') @IsString() @MinLength(1) @MaxLength(120) - model: string; + model?: string; @IsOptional() @IsString() diff --git a/src/chat/chat.service.ts b/src/chat/chat.service.ts index 9eafe0f..f4aa1c6 100644 --- a/src/chat/chat.service.ts +++ b/src/chat/chat.service.ts @@ -22,6 +22,7 @@ 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'; +import { getCopilotConfiguration, requestFutureDriveChatCompletion } from '../ai/copilot-backend'; type OpenAiChatCompletionResponse = { choices?: Array<{ @@ -39,22 +40,6 @@ type OpenAiChatCompletionResponse = { }; }; -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; const PUBLIC_GUIDE_COURSE_SLUG = 'autonomy-studio-lms-guide'; @@ -263,10 +248,7 @@ export class ChatService { }); 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'); + const { model } = getCopilotConfiguration(this.configService); const curriculumContext = dto.contextWeek ? await this.getCurriculumContext(course.id, dto.contextWeek) : null; @@ -818,9 +800,8 @@ export class ChatService { 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(curriculumContext + if (getCopilotConfiguration(this.configService).backend === 'futuredrive') { + return requestFutureDriveChatCompletion(this.configService, curriculumContext ? [{ role: 'user', content: curriculumContext }, ...messages] : messages); } @@ -877,52 +858,6 @@ export class ChatService { }; } - 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; } diff --git a/src/instructor/course-ai-setting.spec.ts b/src/instructor/course-ai-setting.spec.ts new file mode 100644 index 0000000..cc89326 --- /dev/null +++ b/src/instructor/course-ai-setting.spec.ts @@ -0,0 +1,80 @@ +import { InstructorService } from './instructor.service'; +import { UpdateCourseAiSettingDto } from '../ai/dto/update-course-ai-setting.dto'; +import { validate } from 'class-validator'; + +describe('course AI setting defaults', () => { + const setup = (setting: unknown, configured = true) => { + const findUnique = jest.fn().mockResolvedValue(setting); + const upsert = jest.fn(async ({ create }) => ({ ...create, updatedAt: new Date('2026-10-19') })); + const encrypt = jest.fn().mockReturnValue('new-encrypted-key'); + const service = Object.assign(Object.create(InstructorService.prototype), { + prisma: { courseAiSetting: { findUnique, upsert } }, + aiCredentialService: { encrypt }, + configService: { get: (key: string, fallback?: string) => ({ AI_PROVIDER: 'futuredrive', FUTUREDRIVE_LLM_API_KEY: configured ? 'server-only-secret' : '' })[key] ?? fallback }, + getInstructor: jest.fn().mockResolvedValue({ + authUser: { primaryAffiliation: { organizationName: 'Org' } }, + instructor: { id: 'teacher' }, + }), + findInstructorCourse: jest.fn().mockResolvedValue({ id: 'course' }), + }) as InstructorService; + return { service, findUnique, upsert, encrypt }; + }; + + it('offers managed A-Copilot for a course without saved settings and keeps AI disabled', async () => { + const { service, findUnique } = setup(null); + await expect(service.getCourseAiSetting('token', 'course')).resolves.toEqual({ + courseId: 'course', enabled: false, provider: 'A_COPILOT', model: 'a-copilot', + endpointUrl: null, hasApiKey: false, copilotConfigured: true, updatedAt: null, + }); + expect(findUnique).toHaveBeenCalledWith({ where: { courseId: 'course' } }); + }); + + it('uses canonical managed values, removes a previous course key and never persists or returns the server key', async () => { + const { service, upsert, encrypt } = setup({ provider: 'QWEN', encryptedApiKey: 'old-course-key' }); + const result = await service.updateCourseAiSetting('token', 'course', { + enabled: true, provider: 'A_COPILOT', model: 'forged-model', apiKey: 'forged-key', endpointUrl: 'https://untrusted.test', + }); + expect(result).toMatchObject({ provider: 'A_COPILOT', model: 'a-copilot', hasApiKey: false, copilotConfigured: true, endpointUrl: null }); + for (const data of [upsert.mock.calls[0][0].create, upsert.mock.calls[0][0].update]) { + expect(data).toMatchObject({ provider: 'A_COPILOT', model: 'a-copilot', encryptedApiKey: null, endpointUrl: null }); + } + expect(encrypt).not.toHaveBeenCalled(); + expect(JSON.stringify({ result, calls: upsert.mock.calls })).not.toMatch(/server-only-secret|forged-key|untrusted/); + }); + + it('allows managed settings without a model or course key but refuses enabling an unconfigured server', async () => { + const dto = Object.assign(new UpdateCourseAiSettingDto(), { enabled: true, provider: 'A_COPILOT' }); + expect(await validate(dto)).toHaveLength(0); + const ready = setup(null); + await expect(ready.service.updateCourseAiSetting('token', 'course', dto)).resolves.toMatchObject({ enabled: true }); + const missing = setup(null, false); + await expect(missing.service.updateCourseAiSetting('token', 'course', dto)).rejects.toThrow(/서버 연결/); + expect(missing.upsert).not.toHaveBeenCalled(); + await expect(missing.service.updateCourseAiSetting('token', 'course', { ...dto, enabled: false })).resolves.toMatchObject({ enabled: false }); + }); + + it('still requires a model and a new credential when switching from managed AI to an external provider', async () => { + const dto = Object.assign(new UpdateCourseAiSettingDto(), { enabled: true, provider: 'OPENAI' }); + expect((await validate(dto)).some(error => error.property === 'model')).toBe(true); + const { service, upsert } = setup({ provider: 'A_COPILOT', encryptedApiKey: null }); + await expect(service.updateCourseAiSetting('token', 'course', { enabled: true, provider: 'OPENAI', model: 'gpt-6-astra' })).rejects.toThrow(/API 키/); + expect(upsert).not.toHaveBeenCalled(); + }); + + it('does not carry an old provider credential into a different disabled provider', async () => { + const { service, upsert } = setup({ provider: 'QWEN', encryptedApiKey: 'qwen-only-key' }); + await service.updateCourseAiSetting('token', 'course', { enabled: false, provider: 'OPENAI', model: 'gpt-6-astra' }); + expect(upsert.mock.calls[0][0].update.encryptedApiKey).toBeNull(); + }); + + it.each([ + { provider: 'OPENAI', model: 'gpt-4.1-mini', endpointUrl: null }, + { provider: 'QWEN', model: 'qwen3.7-plus', endpointUrl: 'https://example.test/v1' }, + { provider: 'ANTHROPIC', model: 'custom-private-model', endpointUrl: null }, + ])('preserves the saved $provider model rather than upgrading it automatically', async setting => { + const { service } = setup({ ...setting, enabled: true, encryptedApiKey: 'encrypted', updatedAt: new Date('2026-10-01') }); + const result = await service.getCourseAiSetting('token', 'course'); + expect(result).toMatchObject({ ...setting, enabled: true, hasApiKey: true }); + expect(result).not.toHaveProperty('encryptedApiKey'); + }); +}); diff --git a/src/instructor/instructor.service.ts b/src/instructor/instructor.service.ts index a3d774b..1d04ea6 100644 --- a/src/instructor/instructor.service.ts +++ b/src/instructor/instructor.service.ts @@ -32,6 +32,7 @@ import { CreateInstructorCourseMapDto } from './dto/create-instructor-course-map import { NAV2_SYSTEM_MAPS } from '../curriculum/course-maps'; import { AiCredentialService } from '../ai/ai-credential.service'; import { UpdateCourseAiSettingDto } from '../ai/dto/update-course-ai-setting.dto'; +import { COPILOT_MODEL, getCopilotConfiguration } from '../ai/copilot-backend'; const INSTRUCTOR_ROLES = new Set(['INSTRUCTOR', 'LMS_ADMIN']); const DEFAULT_SUBMISSION_BOT_EMAIL = 'lms-bot@futuredrive.net'; @@ -116,10 +117,11 @@ export class InstructorService { return { courseId: course.id, enabled: setting?.enabled ?? false, - provider: setting?.provider ?? 'OPENAI', - model: setting?.model ?? 'gpt-4.1-mini', + provider: setting?.provider ?? 'A_COPILOT', + model: !setting || setting.provider === 'A_COPILOT' ? COPILOT_MODEL : setting.model, endpointUrl: setting?.provider === 'QWEN' ? setting.endpointUrl : null, - hasApiKey: Boolean(setting?.encryptedApiKey), + hasApiKey: Boolean(setting?.provider !== 'A_COPILOT' && setting?.encryptedApiKey), + copilotConfigured: getCopilotConfiguration(this.configService).configured, updatedAt: setting?.updatedAt ?? null, }; } @@ -135,38 +137,45 @@ export class InstructorService { const course = await this.findInstructorCourse(organizationName, instructor.id, courseId); if (!course) throw new NotFoundException('Course not found'); const existing = await this.prisma.courseAiSetting.findUnique({ where: { courseId: course.id } }); - const apiKey = input.apiKey?.trim(); - if (input.enabled && !apiKey && (!existing?.encryptedApiKey || existing.provider !== input.provider)) { + const managed = input.provider === 'A_COPILOT'; + const apiKey = managed ? undefined : input.apiKey?.trim(); + if (managed && input.enabled && !getCopilotConfiguration(this.configService).configured) { + throw new BadRequestException('A-Copilot 서버 연결이 설정되어 있지 않습니다.'); + } + if (!managed && input.enabled && !apiKey && (!existing?.encryptedApiKey || existing.provider !== input.provider)) { throw new BadRequestException('AI 에이전트를 사용하거나 공급자를 변경하려면 해당 공급자의 API 키를 입력하세요.'); } const endpointUrl = input.provider === 'QWEN' ? this.normalizeQwenEndpoint(input.endpointUrl) : null; + const model = managed ? COPILOT_MODEL : input.model!.trim(); + const encryptedApiKey = managed ? null : apiKey ? this.aiCredentialService.encrypt(apiKey) : existing?.provider === input.provider ? undefined : null; const setting = await this.prisma.courseAiSetting.upsert({ where: { courseId: course.id }, create: { courseId: course.id, enabled: input.enabled, provider: input.provider, - model: input.model.trim(), + model, endpointUrl, - encryptedApiKey: apiKey ? this.aiCredentialService.encrypt(apiKey) : null, + encryptedApiKey: encryptedApiKey ?? null, }, update: { enabled: input.enabled, provider: input.provider, - model: input.model.trim(), + model, ...(input.provider !== 'QWEN' || input.endpointUrl !== undefined ? { endpointUrl } : {}), - ...(apiKey ? { encryptedApiKey: this.aiCredentialService.encrypt(apiKey) } : {}), + ...(encryptedApiKey !== undefined ? { encryptedApiKey } : {}), }, }); return { courseId: course.id, enabled: setting.enabled, provider: setting.provider, - model: setting.model, + model: setting.provider === 'A_COPILOT' ? COPILOT_MODEL : setting.model, endpointUrl: setting.provider === 'QWEN' ? setting.endpointUrl : null, - hasApiKey: Boolean(setting.encryptedApiKey), + hasApiKey: Boolean(setting.provider !== 'A_COPILOT' && setting.encryptedApiKey), + copilotConfigured: getCopilotConfiguration(this.configService).configured, updatedAt: setting.updatedAt, }; }