feat: add managed A-Copilot provider using shared AI chat backend

This commit is contained in:
2026-10-19 14:15:23 +09:00
parent f58a20f262
commit 8c051c4bf4
10 changed files with 286 additions and 92 deletions
+13 -9
View File
@@ -9,7 +9,7 @@ import { AiProviderService, type CompletionInput } from './ai-provider.service';
import { AgentImagesService, supportsAgentImages, type AgentImageScope } from './agent-images.service'; import { AgentImagesService, supportsAgentImages, type AgentImageScope } from './agent-images.service';
import { AGENT_DOCUMENT_CATALOG, isAgentDocumentId, readAgentDocument } from './agent-documents'; 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 { 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 { AgentSessionQueryDto, SyncAgentSessionDto } from './dto/agent-session.dto';
import { CreateAgentPlanDto } from './dto/create-agent-plan.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 course = await this.getStudentCourse(student.id, courseId);
const setting = await this.prisma.courseAiSetting.findUnique({ where: { courseId: course.id } }); const setting = await this.prisma.courseAiSetting.findUnique({ where: { courseId: course.id } });
return { return {
enabled: Boolean(setting?.enabled && setting.encryptedApiKey), enabled: Boolean(setting?.enabled && this.hasProviderCredentials(setting)),
provider: setting?.provider ?? null, 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)), 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) { async uploadImage(accessToken: string | undefined, input: AgentSessionQueryDto, file: { originalname: string; buffer: Buffer } | undefined) {
const scope = await this.getAgentScope(accessToken, input.courseId, input.week); const scope = await this.getAgentScope(accessToken, input.courseId, input.week);
const setting = await this.prisma.courseAiSetting.findUnique({ where: { courseId: scope.course.id } }); 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 student = await this.getLmsUser(accessToken);
const course = await this.getStudentCourse(student.id, dto.courseId); const course = await this.getStudentCourse(student.id, dto.courseId);
const setting = await this.prisma.courseAiSetting.findUnique({ where: { courseId: course.id } }); 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 에이전트가 설정되어 있지 않습니다.'); 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 공급자입니다.'); throw new BadRequestException('지원하지 않는 AI 공급자입니다.');
} }
@@ -307,8 +311,8 @@ export class AgentService {
const completionInput: CompletionInput = { const completionInput: CompletionInput = {
provider: setting.provider as AiProvider, provider: setting.provider as AiProvider,
model: setting.model, model: setting.model,
apiKey: this.credentialService.decrypt(setting.encryptedApiKey), apiKey: setting.provider === 'A_COPILOT' ? '' : this.credentialService.decrypt(setting.encryptedApiKey!),
endpointUrl: setting.endpointUrl, endpointUrl: setting.provider === 'A_COPILOT' ? null : setting.endpointUrl,
json: true, json: true,
system: this.systemPrompt(), system: this.systemPrompt(),
messages: [{ messages: [{
@@ -362,7 +366,7 @@ export class AgentService {
} }
} }
if (!plan.requestedDocs.length) { 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))); const documents = await Promise.all(plan.requestedDocs.map((id) => readAgentDocument(id)));
@@ -384,7 +388,7 @@ export class AgentService {
if (this.isUnexecutablePlan(plan)) { if (this.isUnexecutablePlan(plan)) {
throw new BadGatewayException('AI가 문서를 읽은 뒤에도 실행할 수 없는 계획만 반환했습니다. 질문을 다시 보내주세요.'); 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[] { private parseToolResults(input: unknown): AgentToolResult[] {
+24
View File
@@ -2,6 +2,7 @@ import { BadGatewayException, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import type { AiCompletionResult, AiProvider } from './ai.types'; import type { AiCompletionResult, AiProvider } from './ai.types';
import { getCopilotConfiguration, requestFutureDriveChatCompletion } from './copilot-backend';
export type CompletionInput = { export type CompletionInput = {
provider: AiProvider; provider: AiProvider;
@@ -17,9 +18,14 @@ export type CompletionInput = {
export class AiProviderService { export class AiProviderService {
constructor(private readonly configService: ConfigService) {} constructor(private readonly configService: ConfigService) {}
copilotConfigured(): boolean {
return getCopilotConfiguration(this.configService).configured;
}
async complete(input: CompletionInput): Promise<AiCompletionResult> { async complete(input: CompletionInput): Promise<AiCompletionResult> {
if (input.provider !== 'QWEN' && input.messages.some(message => message.images?.length)) throw new BadGatewayException('이미지 첨부는 현재 Qwen에서만 지원합니다.'); if (input.provider !== 'QWEN' && input.messages.some(message => message.images?.length)) throw new BadGatewayException('이미지 첨부는 현재 Qwen에서만 지원합니다.');
switch (input.provider) { switch (input.provider) {
case 'A_COPILOT': return this.copilot(input);
case 'OPENAI': return this.openAi(input); case 'OPENAI': return this.openAi(input);
case 'ANTHROPIC': return this.anthropic(input); case 'ANTHROPIC': return this.anthropic(input);
case 'GEMINI': return this.gemini(input); case 'GEMINI': return this.gemini(input);
@@ -27,6 +33,24 @@ export class AiProviderService {
} }
} }
private async copilot(input: CompletionInput): Promise<AiCompletionResult> {
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<string>('OPENAI_API_KEY')!.trim(),
endpointUrl: null,
});
}
private async openAi(input: CompletionInput): Promise<AiCompletionResult> { private async openAi(input: CompletionInput): Promise<AiCompletionResult> {
const reasoningModel = /^gpt-(?:5|6)(?:[.-]|$)|^o[1-9](?:-|$)/.test(input.model); const reasoningModel = /^gpt-(?:5|6)(?:[.-]|$)|^o[1-9](?:-|$)/.test(input.model);
const response = await fetch('https://api.openai.com/v1/chat/completions', { const response = await fetch('https://api.openai.com/v1/chat/completions', {
+1 -1
View File
@@ -1,6 +1,6 @@
import type { AgentOperation, AgentToolRequest, AgentToolResult } from './agent-tools'; 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 AiProvider = typeof AI_PROVIDERS[number];
export type AiCompletionUsage = { export type AiCompletionUsage = {
+107
View File
@@ -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<string, string>) => ({ 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);
});
});
+32
View File
@@ -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<string>('AI_PROVIDER', 'openai') ?? 'openai').trim().toLowerCase() === 'futuredrive' ? 'futuredrive' : 'openai';
const model = backend === 'futuredrive'
? config.get<string>('FUTUREDRIVE_LLM_MODEL', 'futuredrive-llm') ?? 'futuredrive-llm'
: config.get<string>('OPENAI_CHAT_MODEL', 'gpt-4.1-nano') ?? 'gpt-4.1-nano';
const configured = Boolean(config.get<string>(backend === 'futuredrive' ? 'FUTUREDRIVE_LLM_API_KEY' : 'OPENAI_API_KEY')?.trim());
return { backend, model, configured };
}
export async function requestFutureDriveChatCompletion(config: ConfigService, messages: CopilotMessage[]): Promise<AiCompletionResult> {
const apiKey = config.get<string>('FUTUREDRIVE_LLM_API_KEY')?.trim();
if (!apiKey) throw new BadGatewayException('A-Copilot 서버의 FUTUREDRIVE_LLM_API_KEY가 설정되어 있지 않습니다.');
const baseUrl = (config.get<string>('FUTUREDRIVE_LLM_BASE_URL', 'https://famine-tissue-cursor.ngrok-free.dev') ?? 'https://famine-tissue-cursor.ngrok-free.dev').replace(/\/+$/, '');
const lang = config.get<string>('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 } };
}
+2
View File
@@ -268,6 +268,8 @@ EXPLORER 위쪽 아이콘:
- `에이전트`(Agent, 마술봉): 워크스페이스 정보 기반 분석, 필요한 소스/로그 요청, 코드 변경 제안, 승인 기반 명령 실행. 강좌의 에이전트 설정이 필요하다. - `에이전트`(Agent, 마술봉): 워크스페이스 정보 기반 분석, 필요한 소스/로그 요청, 코드 변경 제안, 승인 기반 명령 실행. 강좌의 에이전트 설정이 필요하다.
- `강사`(Instructor, 사람): 강사에게 질문하고 답변을 기다린다. 강사 메시지와 AI 생성 답변은 다르다. - `강사`(Instructor, 사람): 강사에게 질문하고 답변을 기다린다. 강사 메시지와 AI 생성 답변은 다르다.
강사가 `설정 > AI 에이전트`에서 공급자를 `A-Copilot`으로 선택하면 일반 AI 탭과 같은 서버 공용 API 연결을 사용한다. A-Copilot의 모델과 API 키는 서버에서 관리하며 강좌나 학생 앱에서 입력하지 않는다. A-Copilot은 현재 텍스트 파일·로그 분석을 지원하며 이미지 첨부는 지원하지 않는다. 파일 변경 적용과 명령 실행은 다른 공급자와 동일하게 학생의 승인을 거친다.
### 14.1 에이전트로 사용법 묻기 ### 14.1 에이전트로 사용법 묻기
“NAV2-Control 파라미터는 어느 버튼에서 바꾸고 저장해?”, “실제 차량에 로컬 모듈을 연결하는 순서를 버튼 이름으로 알려줘”, “연결종료와 반납이 어떻게 달라?”처럼 질문한다. 에이전트는 사용법 문서가 필요하다고 판단하면 서버의 이 문서를 요청해 읽고 설명한다. 사용자가 편집기에서 매뉴얼 파일을 열 필요가 없다. “NAV2-Control 파라미터는 어느 버튼에서 바꾸고 저장해?”, “실제 차량에 로컬 모듈을 연결하는 순서를 버튼 이름으로 알려줘”, “연결종료와 반납이 어떻게 달라?”처럼 질문한다. 에이전트는 사용법 문서가 필요하다고 판단하면 서버의 이 문서를 요청해 읽고 설명한다. 사용자가 편집기에서 매뉴얼 파일을 열 필요가 없다.
+3 -2
View File
@@ -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'; import { AI_PROVIDERS, type AiProvider } from '../ai.types';
@@ -9,10 +9,11 @@ export class UpdateCourseAiSettingDto {
@IsIn(AI_PROVIDERS) @IsIn(AI_PROVIDERS)
provider: AiProvider; provider: AiProvider;
@ValidateIf((input: UpdateCourseAiSettingDto) => input.provider !== 'A_COPILOT')
@IsString() @IsString()
@MinLength(1) @MinLength(1)
@MaxLength(120) @MaxLength(120)
model: string; model?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
+4 -69
View File
@@ -22,6 +22,7 @@ import { CreateAiResponseDto } from './dto/create-ai-response.dto';
import { CreateChatMessageDto } from './dto/create-chat-message.dto'; import { CreateChatMessageDto } from './dto/create-chat-message.dto';
import { CreateInstructorChatMessageDto } from './dto/create-instructor-chat-message.dto'; import { CreateInstructorChatMessageDto } from './dto/create-instructor-chat-message.dto';
import { ListChatThreadsDto } from './dto/list-chat-threads.dto'; import { ListChatThreadsDto } from './dto/list-chat-threads.dto';
import { getCopilotConfiguration, requestFutureDriveChatCompletion } from '../ai/copilot-backend';
type OpenAiChatCompletionResponse = { type OpenAiChatCompletionResponse = {
choices?: Array<{ 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 STUDENT_CHAT_VISIBLE_MESSAGE_LIMIT = 20;
const AI_CHAT_CONTEXT_MESSAGE_LIMIT = 6; const AI_CHAT_CONTEXT_MESSAGE_LIMIT = 6;
const PUBLIC_GUIDE_COURSE_SLUG = 'autonomy-studio-lms-guide'; const PUBLIC_GUIDE_COURSE_SLUG = 'autonomy-studio-lms-guide';
@@ -263,10 +248,7 @@ export class ChatService {
}); });
const startedAt = Date.now(); const startedAt = Date.now();
const aiProvider = this.configService.get<string>('AI_PROVIDER', 'openai').trim().toLowerCase(); const { model } = getCopilotConfiguration(this.configService);
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 const curriculumContext = dto.contextWeek
? await this.getCurriculumContext(course.id, dto.contextWeek) ? await this.getCurriculumContext(course.id, dto.contextWeek)
: null; : null;
@@ -818,9 +800,8 @@ export class ChatService {
messages: Array<{ role: 'user' | 'assistant'; content: string }>, messages: Array<{ role: 'user' | 'assistant'; content: string }>,
curriculumContext?: string | null, curriculumContext?: string | null,
) { ) {
const provider = this.configService.get<string>('AI_PROVIDER', 'openai').trim().toLowerCase(); if (getCopilotConfiguration(this.configService).backend === 'futuredrive') {
if (provider === 'futuredrive') { return requestFutureDriveChatCompletion(this.configService, curriculumContext
return this.requestFutureDriveLlmChatCompletion(curriculumContext
? [{ role: 'user', content: curriculumContext }, ...messages] ? [{ role: 'user', content: curriculumContext }, ...messages]
: messages); : messages);
} }
@@ -877,52 +858,6 @@ export class ChatService {
}; };
} }
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') { private toThreadType(mode: 'ai' | 'teacher') {
return mode === 'ai' ? ChatThreadType.AI : ChatThreadType.INSTRUCTOR; return mode === 'ai' ? ChatThreadType.AI : ChatThreadType.INSTRUCTOR;
} }
+80
View File
@@ -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');
});
});
+20 -11
View File
@@ -32,6 +32,7 @@ import { CreateInstructorCourseMapDto } from './dto/create-instructor-course-map
import { NAV2_SYSTEM_MAPS } from '../curriculum/course-maps'; import { NAV2_SYSTEM_MAPS } from '../curriculum/course-maps';
import { AiCredentialService } from '../ai/ai-credential.service'; import { AiCredentialService } from '../ai/ai-credential.service';
import { UpdateCourseAiSettingDto } from '../ai/dto/update-course-ai-setting.dto'; 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 INSTRUCTOR_ROLES = new Set(['INSTRUCTOR', 'LMS_ADMIN']);
const DEFAULT_SUBMISSION_BOT_EMAIL = 'lms-bot@futuredrive.net'; const DEFAULT_SUBMISSION_BOT_EMAIL = 'lms-bot@futuredrive.net';
@@ -116,10 +117,11 @@ export class InstructorService {
return { return {
courseId: course.id, courseId: course.id,
enabled: setting?.enabled ?? false, enabled: setting?.enabled ?? false,
provider: setting?.provider ?? 'OPENAI', provider: setting?.provider ?? 'A_COPILOT',
model: setting?.model ?? 'gpt-4.1-mini', model: !setting || setting.provider === 'A_COPILOT' ? COPILOT_MODEL : setting.model,
endpointUrl: setting?.provider === 'QWEN' ? setting.endpointUrl : null, 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, updatedAt: setting?.updatedAt ?? null,
}; };
} }
@@ -135,38 +137,45 @@ export class InstructorService {
const course = await this.findInstructorCourse(organizationName, instructor.id, courseId); const course = await this.findInstructorCourse(organizationName, instructor.id, courseId);
if (!course) throw new NotFoundException('Course not found'); if (!course) throw new NotFoundException('Course not found');
const existing = await this.prisma.courseAiSetting.findUnique({ where: { courseId: course.id } }); const existing = await this.prisma.courseAiSetting.findUnique({ where: { courseId: course.id } });
const apiKey = input.apiKey?.trim(); const managed = input.provider === 'A_COPILOT';
if (input.enabled && !apiKey && (!existing?.encryptedApiKey || existing.provider !== input.provider)) { 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 키를 입력하세요.'); throw new BadRequestException('AI 에이전트를 사용하거나 공급자를 변경하려면 해당 공급자의 API 키를 입력하세요.');
} }
const endpointUrl = input.provider === 'QWEN' const endpointUrl = input.provider === 'QWEN'
? this.normalizeQwenEndpoint(input.endpointUrl) ? this.normalizeQwenEndpoint(input.endpointUrl)
: null; : 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({ const setting = await this.prisma.courseAiSetting.upsert({
where: { courseId: course.id }, where: { courseId: course.id },
create: { create: {
courseId: course.id, courseId: course.id,
enabled: input.enabled, enabled: input.enabled,
provider: input.provider, provider: input.provider,
model: input.model.trim(), model,
endpointUrl, endpointUrl,
encryptedApiKey: apiKey ? this.aiCredentialService.encrypt(apiKey) : null, encryptedApiKey: encryptedApiKey ?? null,
}, },
update: { update: {
enabled: input.enabled, enabled: input.enabled,
provider: input.provider, provider: input.provider,
model: input.model.trim(), model,
...(input.provider !== 'QWEN' || input.endpointUrl !== undefined ? { endpointUrl } : {}), ...(input.provider !== 'QWEN' || input.endpointUrl !== undefined ? { endpointUrl } : {}),
...(apiKey ? { encryptedApiKey: this.aiCredentialService.encrypt(apiKey) } : {}), ...(encryptedApiKey !== undefined ? { encryptedApiKey } : {}),
}, },
}); });
return { return {
courseId: course.id, courseId: course.id,
enabled: setting.enabled, enabled: setting.enabled,
provider: setting.provider, provider: setting.provider,
model: setting.model, model: setting.provider === 'A_COPILOT' ? COPILOT_MODEL : setting.model,
endpointUrl: setting.provider === 'QWEN' ? setting.endpointUrl : null, 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, updatedAt: setting.updatedAt,
}; };
} }