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
+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 { 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,
};
}