feat: support per-provider agent settings and environment-aware analysis
This commit is contained in:
@@ -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' },
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user