feat: add managed A-Copilot provider using shared AI chat backend
This commit is contained in:
+13
-9
@@ -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[] {
|
||||
|
||||
@@ -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<AiCompletionResult> {
|
||||
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<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> {
|
||||
const reasoningModel = /^gpt-(?:5|6)(?:[.-]|$)|^o[1-9](?:-|$)/.test(input.model);
|
||||
const response = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||
|
||||
+1
-1
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 } };
|
||||
}
|
||||
@@ -268,6 +268,8 @@ EXPLORER 위쪽 아이콘:
|
||||
- `에이전트`(Agent, 마술봉): 워크스페이스 정보 기반 분석, 필요한 소스/로그 요청, 코드 변경 제안, 승인 기반 명령 실행. 강좌의 에이전트 설정이 필요하다.
|
||||
- `강사`(Instructor, 사람): 강사에게 질문하고 답변을 기다린다. 강사 메시지와 AI 생성 답변은 다르다.
|
||||
|
||||
강사가 `설정 > AI 에이전트`에서 공급자를 `A-Copilot`으로 선택하면 일반 AI 탭과 같은 서버 공용 API 연결을 사용한다. A-Copilot의 모델과 API 키는 서버에서 관리하며 강좌나 학생 앱에서 입력하지 않는다. A-Copilot은 현재 텍스트 파일·로그 분석을 지원하며 이미지 첨부는 지원하지 않는다. 파일 변경 적용과 명령 실행은 다른 공급자와 동일하게 학생의 승인을 거친다.
|
||||
|
||||
### 14.1 에이전트로 사용법 묻기
|
||||
|
||||
“NAV2-Control 파라미터는 어느 버튼에서 바꾸고 저장해?”, “실제 차량에 로컬 모듈을 연결하는 순서를 버튼 이름으로 알려줘”, “연결종료와 반납이 어떻게 달라?”처럼 질문한다. 에이전트는 사용법 문서가 필요하다고 판단하면 서버의 이 문서를 요청해 읽고 설명한다. 사용자가 편집기에서 매뉴얼 파일을 열 필요가 없다.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<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');
|
||||
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<string>('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<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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user