feat: add managed A-Copilot provider using shared AI chat backend
This commit is contained in:
@@ -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