feat: support per-provider agent settings and environment-aware analysis

This commit is contained in:
2026-10-20 11:36:28 +09:00
parent ea6ff6997f
commit 94c17ac50d
17 changed files with 452 additions and 56 deletions
+34 -9
View File
@@ -4,11 +4,18 @@ 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 rows: any[] = Array.isArray(setting) ? setting : setting ? [setting] : [];
const findUnique = jest.fn(async ({ where }) => rows.find(row => row.provider === where.courseId_provider.provider) ?? null);
const findMany = jest.fn(async () => rows);
const upsert = jest.fn(async ({ create, update }) => {
const existing = rows.find(row => row.provider === create.provider);
if (existing) Object.assign(existing, update, { updatedAt: new Date('2026-10-19') });
else rows.push({ ...create, updatedAt: new Date('2026-10-19') });
return rows.find(row => row.provider === create.provider);
});
const encrypt = jest.fn().mockReturnValue('new-encrypted-key');
const service = Object.assign(Object.create(InstructorService.prototype), {
prisma: { courseAiSetting: { findUnique, upsert } },
prisma: { courseAiSetting: { findUnique, findMany, 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({
@@ -17,19 +24,22 @@ describe('course AI setting defaults', () => {
}),
findInstructorCourse: jest.fn().mockResolvedValue({ id: 'course' }),
}) as InstructorService;
return { service, findUnique, upsert, encrypt };
return { service, findUnique, findMany, upsert, encrypt, rows };
};
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({
const { service, findMany } = setup(null);
const response = await service.getCourseAiSetting('token', 'course');
expect(response).toMatchObject({
courseId: 'course', enabled: false, provider: 'A_COPILOT', model: 'a-copilot',
endpointUrl: null, hasApiKey: false, copilotConfigured: true, updatedAt: null,
});
expect(findUnique).toHaveBeenCalledWith({ where: { courseId: 'course' } });
expect(response.settings).toHaveLength(5);
expect(response.settings.every(setting => !setting.enabled && !setting.hasApiKey)).toBe(true);
expect(findMany).toHaveBeenCalledWith({ where: { courseId: 'course' } });
});
it('uses canonical managed values, removes a previous course key and never persists or returns the server key', async () => {
it('uses canonical managed values without copying another provider key or exposing 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',
@@ -53,7 +63,7 @@ describe('course AI setting defaults', () => {
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 () => {
it('requires a model and an initial credential for a newly enabled 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 });
@@ -67,6 +77,21 @@ describe('course AI setting defaults', () => {
expect(upsert.mock.calls[0][0].update.encryptedApiKey).toBeNull();
});
it('stores providers independently and keeps the original key when enabling a saved provider again', async () => {
const original = { courseId: 'course', provider: 'QWEN', enabled: true, model: 'qwen3.8-flash', encryptedApiKey: 'qwen-only-key', endpointUrl: 'https://qwen.test/v1' };
const { service, rows, upsert } = setup({ ...original });
await service.updateCourseAiSetting('token', 'course', { enabled: true, provider: 'OPENAI', model: 'gpt-6.1-sol', apiKey: 'openai-only-key' });
expect(rows.find(row => row.provider === 'QWEN')).toEqual(original);
expect(upsert.mock.calls[0][0].where).toEqual({ courseId_provider: { courseId: 'course', provider: 'OPENAI' } });
await service.updateCourseAiSetting('token', 'course', { enabled: false, provider: 'QWEN', model: 'qwen3.8-flash' });
await service.updateCourseAiSetting('token', 'course', { enabled: true, provider: 'QWEN', model: 'qwen3.8-max' });
const qwen = rows.find(row => row.provider === 'QWEN');
expect(qwen).toMatchObject({ enabled: true, encryptedApiKey: 'qwen-only-key', endpointUrl: original.endpointUrl });
const result = await service.getCourseAiSetting('token', 'course');
expect(result.settings.filter(setting => setting.enabled).map(setting => setting.provider)).toEqual(['OPENAI', 'QWEN']);
expect(JSON.stringify(result)).not.toMatch(/qwen-only-key|openai-only-key|new-encrypted-key|server-only-secret/);
});
it.each([
{ provider: 'OPENAI', model: 'gpt-4.1-mini', endpointUrl: null },
{ provider: 'QWEN', model: 'qwen3.7-plus', endpointUrl: 'https://example.test/v1' },
+22 -20
View File
@@ -33,6 +33,7 @@ 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';
import { AI_DEFAULT_MODELS, AI_PROVIDERS, type AiProvider } from '../ai/ai.types';
const INSTRUCTOR_ROLES = new Set(['INSTRUCTOR', 'LMS_ADMIN']);
const DEFAULT_SUBMISSION_BOT_EMAIL = 'lms-bot@futuredrive.net';
@@ -113,17 +114,10 @@ export class InstructorService {
if (!organizationName) throw new BadRequestException('Instructor organization is required');
const course = await this.findInstructorCourse(organizationName, instructor.id, courseId);
if (!course) throw new NotFoundException('Course not found');
const setting = await this.prisma.courseAiSetting.findUnique({ where: { courseId: course.id } });
return {
courseId: course.id,
enabled: setting?.enabled ?? false,
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?.provider !== 'A_COPILOT' && setting?.encryptedApiKey),
copilotConfigured: getCopilotConfiguration(this.configService).configured,
updatedAt: setting?.updatedAt ?? null,
};
const saved = await this.prisma.courseAiSetting.findMany({ where: { courseId: course.id } });
const settings = AI_PROVIDERS.map(provider => this.courseAiSettingResponse(course.id, provider, saved.find(row => row.provider === provider)));
const legacy = settings.find(setting => setting.enabled) ?? settings.find(setting => setting.updatedAt) ?? settings[0];
return { ...legacy, settings };
}
async updateCourseAiSetting(
@@ -136,7 +130,8 @@ export class InstructorService {
if (!organizationName) throw new BadRequestException('Instructor organization is required');
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 where = { courseId_provider: { courseId: course.id, provider: input.provider } };
const existing = await this.prisma.courseAiSetting.findUnique({ where });
const managed = input.provider === 'A_COPILOT';
const apiKey = managed ? undefined : input.apiKey?.trim();
if (managed && input.enabled && !getCopilotConfiguration(this.configService).configured) {
@@ -149,9 +144,10 @@ export class InstructorService {
? this.normalizeQwenEndpoint(input.endpointUrl)
: null;
const model = managed ? COPILOT_MODEL : input.model!.trim();
if (!model) throw new BadRequestException('모델 ID를 입력하세요.');
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 },
where,
create: {
courseId: course.id,
enabled: input.enabled,
@@ -168,15 +164,21 @@ export class InstructorService {
...(encryptedApiKey !== undefined ? { encryptedApiKey } : {}),
},
});
return this.courseAiSettingResponse(course.id, input.provider, setting);
}
private courseAiSettingResponse(courseId: string, provider: AiProvider, setting?: {
enabled: boolean; model: string; endpointUrl: string | null; encryptedApiKey: string | null; updatedAt: Date;
}) {
return {
courseId: course.id,
enabled: setting.enabled,
provider: setting.provider,
model: setting.provider === 'A_COPILOT' ? COPILOT_MODEL : setting.model,
endpointUrl: setting.provider === 'QWEN' ? setting.endpointUrl : null,
hasApiKey: Boolean(setting.provider !== 'A_COPILOT' && setting.encryptedApiKey),
courseId,
enabled: setting?.enabled ?? false,
provider,
model: provider === 'A_COPILOT' ? COPILOT_MODEL : setting?.model ?? AI_DEFAULT_MODELS[provider],
endpointUrl: provider === 'QWEN' ? setting?.endpointUrl ?? null : null,
hasApiKey: Boolean(provider !== 'A_COPILOT' && setting?.encryptedApiKey),
copilotConfigured: getCopilotConfiguration(this.configService).configured,
updatedAt: setting.updatedAt,
updatedAt: setting?.updatedAt ?? null,
};
}