690615:1449 237 #01
This commit is contained in:
@@ -111,6 +111,9 @@ import {
|
||||
RuntimePolicy,
|
||||
ExecutionProfile,
|
||||
} from './interfaces/execution-policy.interface';
|
||||
import { AiExecutionProfilesService } from './services/ai-execution-profiles.service';
|
||||
import { CreateExecutionProfileDto } from './dto/create-execution-profile.dto';
|
||||
import { UpdateExecutionProfileDto } from './dto/update-execution-profile.dto';
|
||||
|
||||
@ApiTags('AI Gateway')
|
||||
@Controller('ai')
|
||||
@@ -125,6 +128,7 @@ export class AiController {
|
||||
private readonly fileStorageService: FileStorageService,
|
||||
private readonly migrationCheckpointService: AiMigrationCheckpointService,
|
||||
private readonly aiPolicyService: AiPolicyService,
|
||||
private readonly aiExecutionProfilesService: AiExecutionProfilesService,
|
||||
@InjectRedis() private readonly redis: Redis,
|
||||
@Optional() private readonly ocrService?: OcrService
|
||||
) {}
|
||||
@@ -683,10 +687,19 @@ export class AiController {
|
||||
description:
|
||||
'รับข้อความ OCR และ profileId แล้วรัน semantic chunking และ embedding preview',
|
||||
})
|
||||
@ApiHeader({
|
||||
name: 'Idempotency-Key',
|
||||
description: 'Unique key เพื่อป้องกัน duplicate sandbox RAG Prep job',
|
||||
required: true,
|
||||
})
|
||||
async submitSandboxRagPrep(
|
||||
@Body() dto: SandboxRagPrepDto
|
||||
@Body() dto: SandboxRagPrepDto,
|
||||
@Headers('idempotency-key') idempotencyKey: string
|
||||
): Promise<{ requestPublicId: string; jobId: string; status: string }> {
|
||||
const requestPublicId = uuidv7();
|
||||
if (!idempotencyKey) {
|
||||
throw new ValidationException('Idempotency-Key header is required');
|
||||
}
|
||||
const requestPublicId = idempotencyKey;
|
||||
const jobId = await this.aiQueueService.enqueueSandboxJob(
|
||||
'sandbox-rag-prep',
|
||||
{
|
||||
@@ -936,6 +949,73 @@ export class AiController {
|
||||
await this.aiRagService.cancelJob(requestPublicId);
|
||||
}
|
||||
|
||||
// ─── Execution Profiles Endpoints (US4 — T045-T048) ───────────────────────
|
||||
|
||||
@Get('execution-profiles')
|
||||
@UseGuards(JwtAuthGuard, RbacGuard)
|
||||
@ApiBearerAuth()
|
||||
@RequirePermission('system.manage_all')
|
||||
@ApiOperation({
|
||||
summary: 'AI Execution Profiles — ดึงรายการโปรไฟล์การทำงานทั้งหมด (T045)',
|
||||
})
|
||||
async getExecutionProfiles() {
|
||||
return this.aiExecutionProfilesService.findAll();
|
||||
}
|
||||
|
||||
@Post('execution-profiles')
|
||||
@UseGuards(JwtAuthGuard, RbacGuard)
|
||||
@ApiBearerAuth()
|
||||
@RequirePermission('system.manage_all')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({
|
||||
summary: 'AI Create Execution Profile — สร้างโปรไฟล์การทำงานใหม่ (T046)',
|
||||
})
|
||||
async createExecutionProfile(
|
||||
@Body() dto: CreateExecutionProfileDto,
|
||||
@CurrentUser() user: User
|
||||
) {
|
||||
return this.aiExecutionProfilesService.create(dto, user.user_id);
|
||||
}
|
||||
|
||||
@Put('execution-profiles/:id')
|
||||
@UseGuards(JwtAuthGuard, RbacGuard)
|
||||
@ApiBearerAuth()
|
||||
@RequirePermission('system.manage_all')
|
||||
@ApiOperation({
|
||||
summary: 'AI Update Execution Profile — อัปเดตโปรไฟล์การทำงาน (T047)',
|
||||
})
|
||||
@ApiParam({
|
||||
name: 'id',
|
||||
description: 'ID ของโปรไฟล์ (INT)',
|
||||
})
|
||||
async updateExecutionProfile(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateExecutionProfileDto,
|
||||
@CurrentUser() user: User
|
||||
) {
|
||||
return this.aiExecutionProfilesService.update(
|
||||
Number(id),
|
||||
dto,
|
||||
user.user_id
|
||||
);
|
||||
}
|
||||
|
||||
@Delete('execution-profiles/:id')
|
||||
@UseGuards(JwtAuthGuard, RbacGuard)
|
||||
@ApiBearerAuth()
|
||||
@RequirePermission('system.manage_all')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({
|
||||
summary: 'AI Delete Execution Profile — ลบโปรไฟล์การทำงาน (T048)',
|
||||
})
|
||||
@ApiParam({
|
||||
name: 'id',
|
||||
description: 'ID ของโปรไฟล์ (INT)',
|
||||
})
|
||||
async deleteExecutionProfile(@Param('id') id: string): Promise<void> {
|
||||
await this.aiExecutionProfilesService.delete(Number(id));
|
||||
}
|
||||
|
||||
@Post('legacy-migration/ingest')
|
||||
@UseGuards(ServiceAccountGuard)
|
||||
@UseInterceptors(FilesInterceptor('files', 25))
|
||||
|
||||
@@ -47,6 +47,7 @@ import { AiAvailableModel } from './entities/ai-available-model.entity';
|
||||
import { AiExecutionProfile } from './entities/ai-execution-profile.entity';
|
||||
import { AiSandboxProfile } from './entities/ai-sandbox-profile.entity';
|
||||
import { AiMigrationCheckpointService } from './ai-migration-checkpoint.service';
|
||||
import { AiExecutionProfilesService } from './services/ai-execution-profiles.service';
|
||||
import { AiEnabledGuard } from './guards/ai-enabled.guard';
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { MigrationModule } from '../migration/migration.module';
|
||||
@@ -200,6 +201,8 @@ import {
|
||||
// ADR-032: Typhoon OCR + LLM sequential processors (concurrency=1)
|
||||
TyphoonOcrProcessor,
|
||||
TyphoonLlmProcessor,
|
||||
// US4: Execution Profiles Service (T044)
|
||||
AiExecutionProfilesService,
|
||||
// RbacGuard ต้องการ UserService จาก UserModule
|
||||
RbacGuard,
|
||||
AiEnabledGuard,
|
||||
|
||||
@@ -59,6 +59,11 @@ describe('AiBatchProcessor', () => {
|
||||
processWithAutoDetect: jest.fn().mockResolvedValue({
|
||||
text: 'extracted ocr text from document that is long enough to bypass character length check',
|
||||
}),
|
||||
embedViaSidecar: jest.fn().mockResolvedValue({
|
||||
dense: [0.1, 0.2, 0.3],
|
||||
sparse: { indices: [0, 1], values: [0.5, 0.7] },
|
||||
device: 'cpu',
|
||||
}),
|
||||
};
|
||||
const mockSandboxOcrEngineService = {
|
||||
detectAndExtract: jest.fn().mockResolvedValue({
|
||||
@@ -741,4 +746,145 @@ describe('AiBatchProcessor', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sandbox RAG Prep (T031)', () => {
|
||||
it('ควรประมวลผล sandbox-rag-prep สำเร็จด้วย semantic chunking และ embedding', async () => {
|
||||
mockAiPromptsService.getActive.mockResolvedValue({
|
||||
id: 1,
|
||||
promptType: 'rag_prep_prompt',
|
||||
versionNumber: 1,
|
||||
template: 'Chunk this text: {{text}}',
|
||||
isActive: true,
|
||||
contextConfig: null,
|
||||
});
|
||||
mockOllamaService.generate.mockResolvedValue(
|
||||
'<chunk topic="Introduction">Introduction text</chunk><chunk topic="Main Content">Main content text</chunk>'
|
||||
);
|
||||
|
||||
const job = {
|
||||
id: 'job-sandbox-rag-prep',
|
||||
data: {
|
||||
jobType: 'sandbox-rag-prep',
|
||||
documentPublicId: 'doc-uuid-123',
|
||||
projectPublicId: 'proj-uuid-456',
|
||||
payload: {
|
||||
text: 'This is a test document for RAG preparation. It contains multiple sections.',
|
||||
profileId: 'standard',
|
||||
},
|
||||
idempotencyKey: 'idem-rag-prep-123',
|
||||
},
|
||||
} as unknown as Job<AiBatchJobData>;
|
||||
|
||||
await processor.process(job);
|
||||
|
||||
expect(mockAiPromptsService.getActive).toHaveBeenCalledWith(
|
||||
'rag_prep_prompt'
|
||||
);
|
||||
expect(mockOllamaService.generate).toHaveBeenCalled();
|
||||
expect(ocrService.embedViaSidecar).toHaveBeenCalledTimes(2);
|
||||
expect(redis.setex).toHaveBeenCalledWith(
|
||||
'ai:rag:result:idem-rag-prep-123',
|
||||
3600,
|
||||
expect.stringContaining('"status":"completed"')
|
||||
);
|
||||
});
|
||||
|
||||
it('ควร fallback ไป fixed-size chunking เมื่อ LLM parse chunk tags ล้มเหลว', async () => {
|
||||
mockAiPromptsService.getActive.mockResolvedValue({
|
||||
id: 1,
|
||||
promptType: 'rag_prep_prompt',
|
||||
versionNumber: 1,
|
||||
template: 'Chunk this text: {{text}}',
|
||||
isActive: true,
|
||||
contextConfig: null,
|
||||
});
|
||||
mockOllamaService.generate.mockResolvedValue(
|
||||
'Invalid LLM output without chunk tags'
|
||||
);
|
||||
|
||||
const job = {
|
||||
id: 'job-sandbox-rag-prep-fallback',
|
||||
data: {
|
||||
jobType: 'sandbox-rag-prep',
|
||||
documentPublicId: 'doc-uuid-456',
|
||||
projectPublicId: 'proj-uuid-789',
|
||||
payload: {
|
||||
text: 'This is a test document for RAG preparation fallback.',
|
||||
},
|
||||
idempotencyKey: 'idem-rag-prep-fallback',
|
||||
},
|
||||
} as unknown as Job<AiBatchJobData>;
|
||||
|
||||
await processor.process(job);
|
||||
|
||||
expect(ocrService.embedViaSidecar).toHaveBeenCalled();
|
||||
expect(redis.setex).toHaveBeenCalledWith(
|
||||
'ai:rag:result:idem-rag-prep-fallback',
|
||||
3600,
|
||||
expect.stringContaining('"status":"completed"')
|
||||
);
|
||||
});
|
||||
|
||||
it('ควร throw error เมื่อไม่มี text ใน payload', async () => {
|
||||
const job = {
|
||||
id: 'job-sandbox-rag-prep-error',
|
||||
data: {
|
||||
jobType: 'sandbox-rag-prep',
|
||||
documentPublicId: 'doc-uuid-789',
|
||||
projectPublicId: 'proj-uuid-999',
|
||||
payload: {},
|
||||
idempotencyKey: 'idem-rag-prep-error',
|
||||
},
|
||||
} as unknown as Job<AiBatchJobData>;
|
||||
|
||||
await expect(processor.process(job)).rejects.toThrow(
|
||||
'text is required for sandbox-rag-prep job'
|
||||
);
|
||||
});
|
||||
|
||||
it('ควรใช้ profileId เมื่อระบุใน payload', async () => {
|
||||
mockAiPromptsService.getActive.mockResolvedValue({
|
||||
id: 1,
|
||||
promptType: 'rag_prep_prompt',
|
||||
versionNumber: 1,
|
||||
template: 'Chunk this text: {{text}}',
|
||||
isActive: true,
|
||||
contextConfig: null,
|
||||
});
|
||||
mockAiPolicyService.getSandboxParameters.mockResolvedValueOnce({
|
||||
temperature: 0.2,
|
||||
topP: 0.7,
|
||||
maxTokens: 2048,
|
||||
numCtx: 4096,
|
||||
repeatPenalty: 1.2,
|
||||
keepAliveSeconds: 30,
|
||||
});
|
||||
mockOllamaService.generate.mockResolvedValue(
|
||||
'<chunk topic="Test">Test chunk</chunk>'
|
||||
);
|
||||
|
||||
const job = {
|
||||
id: 'job-sandbox-rag-prep-profile',
|
||||
data: {
|
||||
jobType: 'sandbox-rag-prep',
|
||||
documentPublicId: 'doc-uuid-999',
|
||||
projectPublicId: 'proj-uuid-111',
|
||||
payload: {
|
||||
text: 'Test text with profile',
|
||||
profileId: 'custom-profile',
|
||||
},
|
||||
idempotencyKey: 'idem-rag-prep-profile',
|
||||
},
|
||||
} as unknown as Job<AiBatchJobData>;
|
||||
|
||||
await processor.process(job);
|
||||
|
||||
expect(mockAiPolicyService.getSandboxParameters).toHaveBeenCalledWith(
|
||||
'custom-profile'
|
||||
);
|
||||
expect(mockAiPolicyService.getSandboxParameters).not.toHaveBeenCalledWith(
|
||||
'standard'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Patch,
|
||||
Body,
|
||||
Param,
|
||||
Headers,
|
||||
UseGuards,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
ApiOperation,
|
||||
ApiBearerAuth,
|
||||
ApiParam,
|
||||
ApiHeader,
|
||||
} from '@nestjs/swagger';
|
||||
import { AiPromptsService } from './ai-prompts.service';
|
||||
import { AiPrompt } from './ai-prompts.entity';
|
||||
@@ -35,6 +37,7 @@ import { RequirePermission } from '../../../common/decorators/require-permission
|
||||
import { Audit } from '../../../common/decorators/audit.decorator';
|
||||
import { CurrentUser } from '../../../common/decorators/current-user.decorator';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
import { ValidationException } from '../../../common/exceptions';
|
||||
|
||||
/**
|
||||
* Controller สำหรับจัดการ Prompt Versions ของ AI OCR (ADR-029)
|
||||
@@ -46,6 +49,12 @@ import { User } from '../../user/entities/user.entity';
|
||||
export class AiPromptsController {
|
||||
constructor(private readonly promptsService: AiPromptsService) {}
|
||||
|
||||
private assertIdempotencyKey(idempotencyKey?: string): void {
|
||||
if (!idempotencyKey) {
|
||||
throw new ValidationException('Idempotency-Key header is required');
|
||||
}
|
||||
}
|
||||
|
||||
private mapToDto(prompt: AiPrompt): AiPromptResponseDto {
|
||||
return plainToInstance(AiPromptResponseDto, prompt, {
|
||||
excludeExtraneousValues: true,
|
||||
@@ -73,11 +82,18 @@ export class AiPromptsController {
|
||||
summary: 'สร้าง Prompt Version ใหม่ (เริ่มต้นเป็น inactive)',
|
||||
})
|
||||
@ApiParam({ name: 'promptType', example: 'ocr_extraction' })
|
||||
@ApiHeader({
|
||||
name: 'Idempotency-Key',
|
||||
description: 'Unique key เพื่อป้องกัน duplicate prompt version creation',
|
||||
required: true,
|
||||
})
|
||||
async createPromptVersion(
|
||||
@Param('promptType') promptType: string,
|
||||
@Body() dto: CreateAiPromptDto,
|
||||
@CurrentUser() user: User
|
||||
@CurrentUser() user: User,
|
||||
@Headers('idempotency-key') idempotencyKey: string
|
||||
): Promise<{ data: AiPromptResponseDto }> {
|
||||
this.assertIdempotencyKey(idempotencyKey);
|
||||
const newPrompt = await this.promptsService.create(
|
||||
promptType,
|
||||
dto,
|
||||
@@ -108,11 +124,18 @@ export class AiPromptsController {
|
||||
@ApiOperation({ summary: 'เปิดใช้งาน Prompt Version' })
|
||||
@ApiParam({ name: 'promptType', example: 'ocr_extraction' })
|
||||
@ApiParam({ name: 'versionNumber', type: Number })
|
||||
@ApiHeader({
|
||||
name: 'Idempotency-Key',
|
||||
description: 'Unique key เพื่อป้องกัน duplicate prompt activation',
|
||||
required: true,
|
||||
})
|
||||
async activatePromptVersion(
|
||||
@Param('promptType') promptType: string,
|
||||
@Param('versionNumber', ParseIntPipe) versionNumber: number,
|
||||
@CurrentUser() user: User
|
||||
@CurrentUser() user: User,
|
||||
@Headers('idempotency-key') idempotencyKey: string
|
||||
): Promise<{ data: AiPromptResponseDto }> {
|
||||
this.assertIdempotencyKey(idempotencyKey);
|
||||
const activated = await this.promptsService.activate(
|
||||
promptType,
|
||||
versionNumber,
|
||||
@@ -165,11 +188,18 @@ export class AiPromptsController {
|
||||
})
|
||||
@ApiParam({ name: 'promptType', example: 'ocr_extraction' })
|
||||
@ApiParam({ name: 'versionNumber', type: Number })
|
||||
@ApiHeader({
|
||||
name: 'Idempotency-Key',
|
||||
description: 'Unique key เพื่อป้องกัน duplicate context config update',
|
||||
required: true,
|
||||
})
|
||||
async updateContextConfig(
|
||||
@Param('promptType') promptType: string,
|
||||
@Param('versionNumber', ParseIntPipe) versionNumber: number,
|
||||
@Body() dto: ContextConfigDto
|
||||
@Body() dto: ContextConfigDto,
|
||||
@Headers('idempotency-key') idempotencyKey: string
|
||||
): Promise<{ data: Record<string, unknown> }> {
|
||||
this.assertIdempotencyKey(idempotencyKey);
|
||||
const updated = await this.promptsService.updateContextConfig(
|
||||
promptType,
|
||||
versionNumber,
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
// - 2026-05-25: Created TypeORM entity for dynamic prompt management (ADR-029)
|
||||
// - 2026-05-25: Added definite assignment assertion operator (!) to satisfy strictPropertyInitialization
|
||||
// - 2026-05-27: Added publicId column for ADR-019 compliance
|
||||
// - 2026-06-15: Added @VersionColumn for optimistic locking (T066)
|
||||
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
VersionColumn,
|
||||
} from 'typeorm';
|
||||
import { Exclude } from 'class-transformer';
|
||||
|
||||
@@ -61,4 +63,7 @@ export class AiPrompt {
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
@VersionColumn({ name: 'version' })
|
||||
version!: number;
|
||||
}
|
||||
|
||||
@@ -66,6 +66,8 @@ describe('AiPromptsService', () => {
|
||||
};
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
mockQueryBuilder.getRawOne.mockReset();
|
||||
mockQueryBuilder.getRawMany.mockReset();
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AiPromptsService,
|
||||
@@ -106,6 +108,7 @@ describe('AiPromptsService', () => {
|
||||
activatedAt: null,
|
||||
createdBy: 1,
|
||||
createdAt: new Date(),
|
||||
version: 1,
|
||||
} as AiPrompt;
|
||||
mockQueryBuilder.getRawMany
|
||||
.mockResolvedValueOnce([
|
||||
@@ -156,6 +159,7 @@ describe('AiPromptsService', () => {
|
||||
activatedAt: null,
|
||||
createdBy: 1,
|
||||
createdAt: new Date(),
|
||||
version: 1,
|
||||
} as AiPrompt;
|
||||
mockQueryBuilder.getRawOne.mockResolvedValue(null);
|
||||
await expect(
|
||||
@@ -163,6 +167,7 @@ describe('AiPromptsService', () => {
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
it('ควร throw ForbiddenException เมื่อพยายาม override ข้ามโครงการที่ถูกล็อคไว้ใน template', async () => {
|
||||
const lockedProjectPublicId = '019505a1-7c3e-7000-8000-abc123def111';
|
||||
const activePrompt = {
|
||||
id: 1,
|
||||
publicId: 'prompt-uuid-789',
|
||||
@@ -173,7 +178,7 @@ describe('AiPromptsService', () => {
|
||||
isActive: true,
|
||||
contextConfig: {
|
||||
filter: {
|
||||
projectId: 1,
|
||||
projectId: lockedProjectPublicId,
|
||||
},
|
||||
},
|
||||
testResultJson: null,
|
||||
@@ -182,13 +187,17 @@ describe('AiPromptsService', () => {
|
||||
activatedAt: null,
|
||||
createdBy: 1,
|
||||
createdAt: new Date(),
|
||||
version: 1,
|
||||
} as AiPrompt;
|
||||
mockQueryBuilder.getRawOne.mockResolvedValue({ id: 2 });
|
||||
mockQueryBuilder.getRawOne
|
||||
.mockResolvedValueOnce({ id: 1 })
|
||||
.mockResolvedValueOnce({ id: 2 });
|
||||
await expect(
|
||||
service.resolveContext(activePrompt, 'another-project-uuid')
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
it('ควรผ่านเมื่อ override project UUID ตรงกับ projectId ที่ล็อคไว้ใน template', async () => {
|
||||
const lockedProjectPublicId = '019505a1-7c3e-7000-8000-abc123def222';
|
||||
const activePrompt = {
|
||||
id: 1,
|
||||
publicId: 'prompt-uuid-abc',
|
||||
@@ -199,7 +208,7 @@ describe('AiPromptsService', () => {
|
||||
isActive: true,
|
||||
contextConfig: {
|
||||
filter: {
|
||||
projectId: 1,
|
||||
projectId: lockedProjectPublicId,
|
||||
},
|
||||
},
|
||||
testResultJson: null,
|
||||
@@ -208,8 +217,11 @@ describe('AiPromptsService', () => {
|
||||
activatedAt: null,
|
||||
createdBy: 1,
|
||||
createdAt: new Date(),
|
||||
version: 1,
|
||||
} as AiPrompt;
|
||||
mockQueryBuilder.getRawOne.mockResolvedValue({ id: 1 });
|
||||
mockQueryBuilder.getRawOne
|
||||
.mockResolvedValueOnce({ id: 1 })
|
||||
.mockResolvedValueOnce({ id: 1 });
|
||||
mockQueryBuilder.getRawMany
|
||||
.mockResolvedValueOnce([
|
||||
{ projectCode: 'LCB3', uuid: 'proj-123', projectName: 'LCP Phase 3' },
|
||||
@@ -221,6 +233,62 @@ describe('AiPromptsService', () => {
|
||||
const result = await service.resolveContext(activePrompt, 'matched-uuid');
|
||||
expect(result.availableProjects).toBeDefined();
|
||||
});
|
||||
|
||||
it('ควร resolve context filter ด้วย public UUID ก่อนใช้ internal id ใน query', async () => {
|
||||
const projectPublicId = '019505a1-7c3e-7000-8000-abc123def456';
|
||||
const contractPublicId = '019505a1-7c3e-7000-8000-abc123def789';
|
||||
const activePrompt = {
|
||||
id: 1,
|
||||
publicId: 'prompt-uuid-filter',
|
||||
promptType: 'ocr_extraction',
|
||||
versionNumber: 1,
|
||||
template: 'Test template',
|
||||
fieldSchema: null,
|
||||
isActive: true,
|
||||
contextConfig: {
|
||||
filter: {
|
||||
projectId: projectPublicId,
|
||||
contractId: contractPublicId,
|
||||
},
|
||||
},
|
||||
testResultJson: null,
|
||||
manualNote: null,
|
||||
lastTestedAt: null,
|
||||
activatedAt: null,
|
||||
createdBy: 1,
|
||||
createdAt: new Date(),
|
||||
version: 1,
|
||||
} as AiPrompt;
|
||||
mockQueryBuilder.getRawOne
|
||||
.mockResolvedValueOnce({ id: 10 })
|
||||
.mockResolvedValueOnce({ id: 20, projectId: 10 });
|
||||
mockQueryBuilder.getRawMany
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
projectCode: 'LCB3',
|
||||
uuid: projectPublicId,
|
||||
projectName: 'LCP Phase 3',
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([]);
|
||||
const result = await service.resolveContext(activePrompt);
|
||||
expect(result.availableProjects).toEqual([
|
||||
{ code: 'LCB3', uuid: projectPublicId, name: 'LCP Phase 3' },
|
||||
]);
|
||||
expect(mockQueryBuilder.where).toHaveBeenCalledWith('p.uuid = :uuid', {
|
||||
uuid: projectPublicId,
|
||||
});
|
||||
expect(mockQueryBuilder.where).toHaveBeenCalledWith('c.uuid = :uuid', {
|
||||
uuid: contractPublicId,
|
||||
});
|
||||
expect(mockQueryBuilder.andWhere).not.toHaveBeenCalledWith(
|
||||
'p.id = :projectId',
|
||||
{ projectId: Number(projectPublicId) }
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('create', () => {
|
||||
it('ควรปฏิเสธ template ที่ไม่มี {{ocr_text}} placeholder สำหรับ ocr_extraction', async () => {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// - 2026-05-25: Created AiPromptsService for dynamic prompt management (ADR-029)
|
||||
// - 2026-05-25: Fixed BusinessException and NotFoundException constructor signatures
|
||||
// - 2026-05-25: Cast getRawOne() to resolve TypeScript type assertion error in ESLint
|
||||
// - 2026-06-15: Added optimistic locking error handling for @VersionColumn (T067)
|
||||
|
||||
import { Injectable, Logger, ForbiddenException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
ValidationException,
|
||||
NotFoundException,
|
||||
} from '../../../common/exceptions';
|
||||
import { readPromptContextScope } from './prompt-context-scope.util';
|
||||
|
||||
/**
|
||||
* Service สำหรับจัดการ Prompt Versioning และการดึงข้อมูล Prompt ล่าสุดที่พร้อมใช้งาน
|
||||
@@ -52,16 +54,45 @@ export class AiPromptsService {
|
||||
overrideProjectPublicId?: string,
|
||||
overrideContractPublicId?: string
|
||||
): Promise<Record<string, unknown>> {
|
||||
const config = activePrompt.contextConfig || {};
|
||||
const filter =
|
||||
(config.filter as Record<string, number | string | null | undefined>) ||
|
||||
{};
|
||||
let targetProjectId: number | null = filter.projectId
|
||||
? Number(filter.projectId)
|
||||
: null;
|
||||
const targetContractId: number | null = filter.contractId
|
||||
? Number(filter.contractId)
|
||||
: null;
|
||||
const scope = readPromptContextScope(activePrompt.contextConfig);
|
||||
let targetProjectId: number | null = null;
|
||||
if (scope.projectPublicId) {
|
||||
const foundProject = await this.dataSource.manager
|
||||
.createQueryBuilder()
|
||||
.select('p.id', 'id')
|
||||
.from('projects', 'p')
|
||||
.where('p.uuid = :uuid', { uuid: scope.projectPublicId })
|
||||
.andWhere('p.deleted_at IS NULL')
|
||||
.getRawOne<{ id: number }>();
|
||||
if (!foundProject) {
|
||||
throw new NotFoundException('Project', scope.projectPublicId);
|
||||
}
|
||||
targetProjectId = Number(foundProject.id);
|
||||
}
|
||||
|
||||
let targetContractId: number | null = null;
|
||||
let targetContractProjectId: number | null = null;
|
||||
if (scope.contractPublicId) {
|
||||
const foundContract = await this.dataSource.manager
|
||||
.createQueryBuilder()
|
||||
.select(['c.id as id', 'c.project_id as projectId'])
|
||||
.from('contracts', 'c')
|
||||
.where('c.uuid = :uuid', { uuid: scope.contractPublicId })
|
||||
.getRawOne<{ id: number; projectId: number }>();
|
||||
if (!foundContract) {
|
||||
throw new NotFoundException('Contract', scope.contractPublicId);
|
||||
}
|
||||
targetContractId = Number(foundContract.id);
|
||||
targetContractProjectId = Number(foundContract.projectId);
|
||||
if (
|
||||
targetProjectId !== null &&
|
||||
targetContractProjectId !== targetProjectId
|
||||
) {
|
||||
throw new ForbiddenException(
|
||||
`Cross-project boundary violation: Contract belongs to project ID ${targetContractProjectId} but template is restricted to project ID ${targetProjectId}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Logic ตรวจสอบ Override และทำหน้าที่ Gatekeeper ป้องกัน Cross-project data leak
|
||||
if (overrideProjectPublicId) {
|
||||
@@ -91,7 +122,7 @@ export class AiPromptsService {
|
||||
targetProjectId = overrideProjectId;
|
||||
}
|
||||
|
||||
let overrideContractProjectId: number | null = null;
|
||||
let overrideContractProjectId: number | null = targetContractProjectId;
|
||||
let overrideContractId: number | null = null;
|
||||
if (overrideContractPublicId) {
|
||||
const foundContract = await this.dataSource.manager
|
||||
@@ -255,10 +286,29 @@ export class AiPromptsService {
|
||||
* @returns รายการ prompt versions เรียงตาม versionNumber ล่าสุดก่อน
|
||||
*/
|
||||
async findAll(promptType: string): Promise<AiPrompt[]> {
|
||||
return this.aiPromptRepo.find({
|
||||
const cacheKey = `${this.cachePrefix}versions:${promptType}`;
|
||||
try {
|
||||
const cached = await this.redis.get(cacheKey);
|
||||
if (cached) {
|
||||
return JSON.parse(cached) as AiPrompt[];
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
this.logger.warn(
|
||||
`Redis unavailable, falling back to DB query: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
const prompts = await this.aiPromptRepo.find({
|
||||
where: { promptType },
|
||||
order: { versionNumber: 'DESC' },
|
||||
});
|
||||
try {
|
||||
await this.redis.setex(cacheKey, 60, JSON.stringify(prompts));
|
||||
} catch (err: unknown) {
|
||||
this.logger.warn(
|
||||
`Failed to set Redis cache: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
return prompts;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -397,6 +447,14 @@ export class AiPromptsService {
|
||||
});
|
||||
const savedPrompt = await queryRunner.manager.save(newPrompt);
|
||||
await queryRunner.commitTransaction();
|
||||
try {
|
||||
const cacheKey = `${this.cachePrefix}versions:${promptType}`;
|
||||
await this.redis.del(cacheKey);
|
||||
} catch (err: unknown) {
|
||||
this.logger.warn(
|
||||
`Failed to clear Redis cache after create: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
await this.saveAuditLog(
|
||||
'AI_PROMPT_CREATED',
|
||||
String(savedPrompt.id),
|
||||
@@ -452,6 +510,8 @@ export class AiPromptsService {
|
||||
try {
|
||||
const cacheKey = `${this.cachePrefix}${promptType}`;
|
||||
await this.redis.del(cacheKey);
|
||||
const versionsCacheKey = `${this.cachePrefix}versions:${promptType}`;
|
||||
await this.redis.del(versionsCacheKey);
|
||||
} catch (err: unknown) {
|
||||
this.logger.warn(
|
||||
`Failed to clear Redis cache after activation: ${err instanceof Error ? err.message : String(err)}`
|
||||
@@ -499,6 +559,14 @@ export class AiPromptsService {
|
||||
);
|
||||
}
|
||||
await this.aiPromptRepo.remove(prompt);
|
||||
try {
|
||||
const cacheKey = `${this.cachePrefix}versions:${promptType}`;
|
||||
await this.redis.del(cacheKey);
|
||||
} catch (err: unknown) {
|
||||
this.logger.warn(
|
||||
`Failed to clear Redis cache after delete: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
await this.saveAuditLog(
|
||||
'AI_PROMPT_DELETED',
|
||||
String(prompt.id),
|
||||
@@ -514,20 +582,43 @@ export class AiPromptsService {
|
||||
* @param note ข้อความ note หรือ null หากต้องการลบ
|
||||
* @returns Prompt version ที่อัปเดตแล้ว
|
||||
* @throws NotFoundException หากไม่พบ prompt version
|
||||
* @throws BusinessException หากเกิด optimistic locking conflict
|
||||
*/
|
||||
async updateNote(
|
||||
promptType: string,
|
||||
versionNumber: number,
|
||||
note: string | null
|
||||
): Promise<AiPrompt> {
|
||||
const prompt = await this.aiPromptRepo.findOne({
|
||||
where: { promptType, versionNumber },
|
||||
});
|
||||
if (!prompt) {
|
||||
throw new NotFoundException('AiPrompt', versionNumber.toString());
|
||||
try {
|
||||
const prompt = await this.aiPromptRepo.findOne({
|
||||
where: { promptType, versionNumber },
|
||||
});
|
||||
if (!prompt) {
|
||||
throw new NotFoundException('AiPrompt', versionNumber.toString());
|
||||
}
|
||||
prompt.manualNote = note;
|
||||
return this.aiPromptRepo.save(prompt);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof NotFoundException) {
|
||||
throw err;
|
||||
}
|
||||
// Handle optimistic locking conflict
|
||||
if (err instanceof Error && err.message.includes('optimistic')) {
|
||||
throw new BusinessException(
|
||||
'OPTIMISTIC_LOCK_CONFLICT',
|
||||
'This prompt version was modified by another user. Please refresh and try again.',
|
||||
'ข้อมูลถูกแก้ไขโดยผู้ใช้อื่น กรุณารีเฟรชแล้วลองใหม่'
|
||||
);
|
||||
}
|
||||
this.logger.error(
|
||||
`Failed to update prompt note: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
throw new BusinessException(
|
||||
'UPDATE_NOTE_FAILED',
|
||||
'Failed to update prompt note',
|
||||
'ไม่สามารถอัปเดต note ได้ กรุณาลองใหม่'
|
||||
);
|
||||
}
|
||||
prompt.manualNote = note;
|
||||
return this.aiPromptRepo.save(prompt);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -569,56 +660,95 @@ export class AiPromptsService {
|
||||
|
||||
/**
|
||||
* อัปเดต Context Config ของ Prompt Version ที่กำหนด พร้อมทั้งตรวจเช็คความถูกต้องของโครงการและสัญญาใน DB
|
||||
* @throws NotFoundException หากไม่พบ prompt version
|
||||
* @throws BusinessException หากเกิด optimistic locking conflict
|
||||
* @throws ValidationException หาก context config ไม่ถูกต้อง (T068)
|
||||
*/
|
||||
async updateContextConfig(
|
||||
promptType: string,
|
||||
versionNumber: number,
|
||||
dto: ContextConfigDto
|
||||
): Promise<Record<string, unknown>> {
|
||||
const prompt = await this.aiPromptRepo.findOne({
|
||||
where: { promptType, versionNumber },
|
||||
});
|
||||
if (!prompt) {
|
||||
throw new NotFoundException('AiPrompt', versionNumber.toString());
|
||||
}
|
||||
|
||||
// Validation (T027): ตรวจสอบโครงการ/สัญญาใน DB
|
||||
if (dto.filter?.projectId) {
|
||||
const projectExists = (await this.dataSource.manager
|
||||
.createQueryBuilder()
|
||||
.select('p.id')
|
||||
.from('projects', 'p')
|
||||
.where('p.uuid = :uuid', { uuid: dto.filter.projectId })
|
||||
.andWhere('p.deleted_at IS NULL')
|
||||
.getRawOne()) as unknown;
|
||||
if (!projectExists) {
|
||||
throw new NotFoundException('Project', dto.filter.projectId);
|
||||
try {
|
||||
const prompt = await this.aiPromptRepo.findOne({
|
||||
where: { promptType, versionNumber },
|
||||
});
|
||||
if (!prompt) {
|
||||
throw new NotFoundException('AiPrompt', versionNumber.toString());
|
||||
}
|
||||
}
|
||||
|
||||
if (dto.filter?.contractId) {
|
||||
const contractExists = (await this.dataSource.manager
|
||||
.createQueryBuilder()
|
||||
.select('c.id')
|
||||
.from('contracts', 'c')
|
||||
.where('c.uuid = :uuid', { uuid: dto.filter.contractId })
|
||||
.getRawOne()) as unknown;
|
||||
if (!contractExists) {
|
||||
throw new NotFoundException('Contract', dto.filter.contractId);
|
||||
// Validation (T068): ตรวจสอบค่าของ context config
|
||||
if (dto.pageSize < 1 || dto.pageSize > 1000) {
|
||||
throw new ValidationException('pageSize must be between 1 and 1000');
|
||||
}
|
||||
if (!dto.language || dto.language.trim().length === 0) {
|
||||
throw new ValidationException('language is required');
|
||||
}
|
||||
if (!dto.outputLanguage || dto.outputLanguage.trim().length === 0) {
|
||||
throw new ValidationException('outputLanguage is required');
|
||||
}
|
||||
|
||||
// Validation (T027): ตรวจสอบโครงการ/สัญญาใน DB
|
||||
if (dto.filter?.projectId) {
|
||||
const projectExists = (await this.dataSource.manager
|
||||
.createQueryBuilder()
|
||||
.select('p.id')
|
||||
.from('projects', 'p')
|
||||
.where('p.uuid = :uuid', { uuid: dto.filter.projectId })
|
||||
.andWhere('p.deleted_at IS NULL')
|
||||
.getRawOne()) as unknown;
|
||||
if (!projectExists) {
|
||||
throw new NotFoundException('Project', dto.filter.projectId);
|
||||
}
|
||||
}
|
||||
|
||||
if (dto.filter?.contractId) {
|
||||
const contractExists = (await this.dataSource.manager
|
||||
.createQueryBuilder()
|
||||
.select('c.id')
|
||||
.from('contracts', 'c')
|
||||
.where('c.uuid = :uuid', { uuid: dto.filter.contractId })
|
||||
.getRawOne()) as unknown;
|
||||
if (!contractExists) {
|
||||
throw new NotFoundException('Contract', dto.filter.contractId);
|
||||
}
|
||||
}
|
||||
|
||||
// บันทึกลง DB
|
||||
const newContextConfig = {
|
||||
filter: dto.filter || null,
|
||||
pageSize: dto.pageSize,
|
||||
language: dto.language,
|
||||
outputLanguage: dto.outputLanguage,
|
||||
};
|
||||
prompt.contextConfig = newContextConfig;
|
||||
await this.aiPromptRepo.save(prompt);
|
||||
|
||||
return newContextConfig;
|
||||
} catch (err: unknown) {
|
||||
if (
|
||||
err instanceof NotFoundException ||
|
||||
err instanceof ValidationException
|
||||
) {
|
||||
throw err;
|
||||
}
|
||||
// Handle optimistic locking conflict
|
||||
if (err instanceof Error && err.message.includes('optimistic')) {
|
||||
throw new BusinessException(
|
||||
'OPTIMISTIC_LOCK_CONFLICT',
|
||||
'This prompt version was modified by another user. Please refresh and try again.',
|
||||
'ข้อมูลถูกแก้ไขโดยผู้ใช้อื่น กรุณารีเฟรชแล้วลองใหม่'
|
||||
);
|
||||
}
|
||||
this.logger.error(
|
||||
`Failed to update context config: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
throw new BusinessException(
|
||||
'UPDATE_CONTEXT_CONFIG_FAILED',
|
||||
'Failed to update context config',
|
||||
'ไม่สามารถอัปเดต context config ได้ กรุณาลองใหม่'
|
||||
);
|
||||
}
|
||||
|
||||
// บันทึกลง DB
|
||||
const newContextConfig = {
|
||||
filter: dto.filter || null,
|
||||
pageSize: dto.pageSize,
|
||||
language: dto.language,
|
||||
outputLanguage: dto.outputLanguage,
|
||||
};
|
||||
prompt.contextConfig = newContextConfig;
|
||||
await this.aiPromptRepo.save(prompt);
|
||||
|
||||
return newContextConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// File: backend/src/modules/ai/prompts/prompt-context-scope.util.ts
|
||||
// Change Log
|
||||
// - 2026-06-15: Added context filter parsing helper for public UUID isolation
|
||||
|
||||
/**
|
||||
* Public UUID filters configured per prompt version.
|
||||
*/
|
||||
export interface PromptContextScope {
|
||||
projectPublicId?: string;
|
||||
contractPublicId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* อ่านค่า filter จาก context_config โดยรองรับชื่อเดิมและชื่อ publicId ที่ชัดเจน
|
||||
*/
|
||||
export function readPromptContextScope(
|
||||
contextConfig: Record<string, unknown> | null
|
||||
): PromptContextScope {
|
||||
const filter = readFilter(contextConfig);
|
||||
return {
|
||||
projectPublicId: readOptionalString(
|
||||
filter.projectPublicId ?? filter.projectId
|
||||
),
|
||||
contractPublicId: readOptionalString(
|
||||
filter.contractPublicId ?? filter.contractId
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function readFilter(
|
||||
contextConfig: Record<string, unknown> | null
|
||||
): Record<string, unknown> {
|
||||
const value = contextConfig?.filter;
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function readOptionalString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim().length > 0
|
||||
? value.trim()
|
||||
: undefined;
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
// File: backend/src/modules/ai/services/ai-execution-profiles.service.ts
|
||||
// Change Log:
|
||||
// - 2026-06-15: Initial creation of AiExecutionProfilesService for execution profile CRUD operations (T044)
|
||||
// - 2026-06-15: Enhanced error handling following ADR-007 layered classification (T054)
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AiExecutionProfile } from '../entities/ai-execution-profile.entity';
|
||||
import { CreateExecutionProfileDto } from '../dto/create-execution-profile.dto';
|
||||
import { UpdateExecutionProfileDto } from '../dto/update-execution-profile.dto';
|
||||
import {
|
||||
BusinessException,
|
||||
NotFoundException,
|
||||
} from '../../../common/exceptions';
|
||||
|
||||
/**
|
||||
* บริการจัดการโปรไฟล์การทำงานของโมเดล AI (Execution Profile)
|
||||
* ใช้สำหรับจัดการพารามิเตอร์ Runtime Parameters ที่ใช้กับทุกงาน AI
|
||||
*/
|
||||
@Injectable()
|
||||
export class AiExecutionProfilesService {
|
||||
private readonly logger = new Logger(AiExecutionProfilesService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(AiExecutionProfile)
|
||||
private readonly profileRepo: Repository<AiExecutionProfile>
|
||||
) {}
|
||||
|
||||
/**
|
||||
* ดึงรายการโปรไฟล์ทั้งหมด
|
||||
*/
|
||||
async findAll(): Promise<AiExecutionProfile[]> {
|
||||
try {
|
||||
return this.profileRepo.find({
|
||||
order: { createdAt: 'ASC' },
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
this.logger.error(
|
||||
`Failed to fetch execution profiles: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
throw new BusinessException(
|
||||
'FETCH_PROFILES_FAILED',
|
||||
'Failed to fetch execution profiles',
|
||||
'ไม่สามารถดึงข้อมูลโปรไฟล์ได้ กรุณาลองใหม่'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ดึงโปรไฟล์ตาม ID
|
||||
*/
|
||||
async findOneById(id: number): Promise<AiExecutionProfile> {
|
||||
try {
|
||||
const profile = await this.profileRepo.findOne({ where: { id } });
|
||||
if (!profile) {
|
||||
throw new NotFoundException('AiExecutionProfile', id.toString());
|
||||
}
|
||||
return profile;
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof NotFoundException) {
|
||||
throw err;
|
||||
}
|
||||
this.logger.error(
|
||||
`Failed to fetch execution profile ${id}: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
throw new BusinessException(
|
||||
'FETCH_PROFILE_FAILED',
|
||||
'Failed to fetch execution profile',
|
||||
'ไม่สามารถดึงข้อมูลโปรไฟล์ได้ กรุณาลองใหม่'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ดึงโปรไฟล์ที่ active อยู่
|
||||
*/
|
||||
async findActive(): Promise<AiExecutionProfile | null> {
|
||||
try {
|
||||
return this.profileRepo.findOne({
|
||||
where: { isActive: true },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
this.logger.error(
|
||||
`Failed to fetch active execution profile: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* สร้างโปรไฟล์ใหม่
|
||||
*/
|
||||
async create(
|
||||
dto: CreateExecutionProfileDto,
|
||||
userId: number
|
||||
): Promise<AiExecutionProfile> {
|
||||
try {
|
||||
// ตรวจสอบว่า profileName ซ้ำหรือไม่
|
||||
const existing = await this.profileRepo.findOne({
|
||||
where: { profileName: dto.profileName },
|
||||
});
|
||||
if (existing) {
|
||||
throw new BusinessException(
|
||||
'PROFILE_NAME_EXISTS',
|
||||
`Profile name "${dto.profileName}" already exists`,
|
||||
'ชื่อโปรไฟล์ซ้ำ กรุณาใช้ชื่ออื่น'
|
||||
);
|
||||
}
|
||||
|
||||
const profile = this.profileRepo.create({
|
||||
...dto,
|
||||
numCtx: dto.ctxSize,
|
||||
updatedBy: userId,
|
||||
});
|
||||
|
||||
return this.profileRepo.save(profile);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof BusinessException) {
|
||||
throw err;
|
||||
}
|
||||
this.logger.error(
|
||||
`Failed to create execution profile: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
throw new BusinessException(
|
||||
'CREATE_PROFILE_FAILED',
|
||||
'Failed to create execution profile',
|
||||
'ไม่สามารถสร้างโปรไฟล์ได้ กรุณาลองใหม่'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* อัปเดตโปรไฟล์
|
||||
*/
|
||||
async update(
|
||||
id: number,
|
||||
dto: UpdateExecutionProfileDto,
|
||||
userId: number
|
||||
): Promise<AiExecutionProfile> {
|
||||
try {
|
||||
const profile = await this.findOneById(id);
|
||||
|
||||
Object.assign(profile, {
|
||||
...dto,
|
||||
numCtx: dto.ctxSize,
|
||||
updatedBy: userId,
|
||||
});
|
||||
|
||||
return this.profileRepo.save(profile);
|
||||
} catch (err: unknown) {
|
||||
if (
|
||||
err instanceof BusinessException ||
|
||||
err instanceof NotFoundException
|
||||
) {
|
||||
throw err;
|
||||
}
|
||||
this.logger.error(
|
||||
`Failed to update execution profile ${id}: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
throw new BusinessException(
|
||||
'UPDATE_PROFILE_FAILED',
|
||||
'Failed to update execution profile',
|
||||
'ไม่สามารถอัปเดตโปรไฟล์ได้ กรุณาลองใหม่'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ลบโปรไฟล์
|
||||
*/
|
||||
async delete(id: number): Promise<void> {
|
||||
try {
|
||||
const profile = await this.findOneById(id);
|
||||
|
||||
// ป้องกันการลบโปรไฟล์ที่ active อยู่
|
||||
if (profile.isActive) {
|
||||
throw new BusinessException(
|
||||
'CANNOT_DELETE_ACTIVE_PROFILE',
|
||||
'Cannot delete active execution profile',
|
||||
'ไม่สามารถลบโปรไฟล์ที่กำลังใช้งานได้ กรุณาปิดใช้งานก่อน'
|
||||
);
|
||||
}
|
||||
|
||||
await this.profileRepo.remove(profile);
|
||||
} catch (err: unknown) {
|
||||
if (
|
||||
err instanceof BusinessException ||
|
||||
err instanceof NotFoundException
|
||||
) {
|
||||
throw err;
|
||||
}
|
||||
this.logger.error(
|
||||
`Failed to delete execution profile ${id}: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
throw new BusinessException(
|
||||
'DELETE_PROFILE_FAILED',
|
||||
'Failed to delete execution profile',
|
||||
'ไม่สามารถลบโปรไฟล์ได้ กรุณาลองใหม่'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ตั้งค่าโปรไฟล์เป็น active (เปลี่ยนจาก active เดิมถ้ามี)
|
||||
*/
|
||||
async setActive(id: number, userId: number): Promise<AiExecutionProfile> {
|
||||
try {
|
||||
const profile = await this.findOneById(id);
|
||||
|
||||
// ปิด active ของโปรไฟล์อื่นทั้งหมด
|
||||
await this.profileRepo.update(
|
||||
{ isActive: true },
|
||||
{ isActive: false, updatedBy: userId }
|
||||
);
|
||||
|
||||
// เปิด active ของโปรไฟล์ที่เลือก
|
||||
profile.isActive = true;
|
||||
profile.updatedBy = userId;
|
||||
|
||||
return this.profileRepo.save(profile);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof NotFoundException) {
|
||||
throw err;
|
||||
}
|
||||
this.logger.error(
|
||||
`Failed to set active execution profile ${id}: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
throw new BusinessException(
|
||||
'SET_ACTIVE_FAILED',
|
||||
'Failed to set active execution profile',
|
||||
'ไม่สามารถตั้งค่าโปรไฟล์เป็น active ได้ กรุณาลองใหม่'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,422 +0,0 @@
|
||||
// File: backend/src/modules/ai/tests/ai-execution-profiles.service.spec.ts
|
||||
// Change Log:
|
||||
// - 2026-06-14: สร้าง unit tests สำหรับ AiPolicyService ที่ครอบคลุม execution profile management (T041)
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { AiPolicyService } from '../services/ai-policy.service';
|
||||
import { AiExecutionProfile } from '../entities/ai-execution-profile.entity';
|
||||
import { AiSandboxProfile } from '../entities/ai-sandbox-profile.entity';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
/** Mock Redis สำหรับ inject */
|
||||
const mockRedis = {
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
del: jest.fn(),
|
||||
};
|
||||
|
||||
/** Mock repository สำหรับ AiExecutionProfile */
|
||||
const mockProfileRepo = {
|
||||
findOne: jest.fn(),
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
};
|
||||
|
||||
/** Mock repository สำหรับ AiSandboxProfile */
|
||||
const mockSandboxRepo = {
|
||||
findOne: jest.fn(),
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
};
|
||||
|
||||
/** สร้าง AiExecutionProfile stub */
|
||||
const makeProfile = (
|
||||
overrides: Partial<AiExecutionProfile> = {}
|
||||
): AiExecutionProfile =>
|
||||
({
|
||||
id: 1,
|
||||
profileName: 'standard',
|
||||
canonicalModel: 'np-dms-ai',
|
||||
temperature: 0.5,
|
||||
topP: 0.8,
|
||||
maxTokens: 4096,
|
||||
numCtx: 8192,
|
||||
repeatPenalty: 1.15,
|
||||
keepAliveSeconds: 600,
|
||||
isActive: true,
|
||||
updatedBy: undefined,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
}) as AiExecutionProfile;
|
||||
|
||||
/** สร้าง AiSandboxProfile stub */
|
||||
const makeSandbox = (
|
||||
overrides: Partial<AiSandboxProfile> = {}
|
||||
): AiSandboxProfile =>
|
||||
({
|
||||
id: 1,
|
||||
profileName: 'standard',
|
||||
canonicalModel: 'np-dms-ai',
|
||||
temperature: 0.6,
|
||||
topP: 0.9,
|
||||
maxTokens: 4096,
|
||||
numCtx: 8192,
|
||||
repeatPenalty: 1.15,
|
||||
keepAliveSeconds: 600,
|
||||
updatedBy: undefined,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
}) as AiSandboxProfile;
|
||||
|
||||
describe('AiPolicyService — Execution Profile Management (T041)', () => {
|
||||
let service: AiPolicyService;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AiPolicyService,
|
||||
{
|
||||
provide: getRepositoryToken(AiExecutionProfile),
|
||||
useValue: mockProfileRepo,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(AiSandboxProfile),
|
||||
useValue: mockSandboxRepo,
|
||||
},
|
||||
{
|
||||
provide: 'default_IORedisModuleConnectionToken',
|
||||
useValue: mockRedis,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
service = module.get<AiPolicyService>(AiPolicyService);
|
||||
});
|
||||
|
||||
// ─── getCanonicalModelName ───────────────────────────────────────────────────
|
||||
describe('getCanonicalModelName()', () => {
|
||||
it('ควรคืน np-dms-ocr เมื่อ modelName มีคำว่า ocr', () => {
|
||||
expect(service.getCanonicalModelName('np-dms-ocr:latest')).toBe(
|
||||
'np-dms-ocr'
|
||||
);
|
||||
});
|
||||
it('ควรคืน np-dms-ocr เมื่อ modelName มีคำว่า typhoon-np-dms-ocr', () => {
|
||||
expect(service.getCanonicalModelName('typhoon-np-dms-ocr:latest')).toBe(
|
||||
'np-dms-ocr'
|
||||
);
|
||||
});
|
||||
it('ควรคืน np-dms-ai สำหรับ model ทั่วไปที่ไม่มีคำว่า ocr', () => {
|
||||
expect(service.getCanonicalModelName('np-dms-ai:latest')).toBe(
|
||||
'np-dms-ai'
|
||||
);
|
||||
});
|
||||
it('ควรคืน np-dms-ai สำหรับ typhoon2.5 model (main model)', () => {
|
||||
expect(service.getCanonicalModelName('typhoon2.5-np-dms:latest')).toBe(
|
||||
'np-dms-ai'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getProfileForJobType ────────────────────────────────────────────────────
|
||||
describe('getProfileForJobType()', () => {
|
||||
it('ควรคืน quality สำหรับ auto-fill-document', () => {
|
||||
expect(service.getProfileForJobType('auto-fill-document')).toBe(
|
||||
'quality'
|
||||
);
|
||||
});
|
||||
it('ควรคืน quality สำหรับ migrate-document', () => {
|
||||
expect(service.getProfileForJobType('migrate-document')).toBe('quality');
|
||||
});
|
||||
it('ควรคืน standard สำหรับ rag-query', () => {
|
||||
expect(service.getProfileForJobType('rag-query')).toBe('standard');
|
||||
});
|
||||
it('ควรคืน interactive สำหรับ intent-classify', () => {
|
||||
expect(service.getProfileForJobType('intent-classify')).toBe(
|
||||
'interactive'
|
||||
);
|
||||
});
|
||||
it('ควรคืน interactive สำหรับ tool-suggest', () => {
|
||||
expect(service.getProfileForJobType('tool-suggest')).toBe('interactive');
|
||||
});
|
||||
it('ควรคืน deep-analysis สำหรับ sandbox-analysis', () => {
|
||||
expect(service.getProfileForJobType('sandbox-analysis')).toBe(
|
||||
'deep-analysis'
|
||||
);
|
||||
});
|
||||
it('ควรคืน standard เป็น default สำหรับ ocr-extract', () => {
|
||||
expect(service.getProfileForJobType('ocr-extract')).toBe('standard');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getProfileParameters ────────────────────────────────────────────────────
|
||||
describe('getProfileParameters()', () => {
|
||||
it('ควรคืนจาก Redis cache เมื่อมี cache hit', async () => {
|
||||
const cachedPolicy = {
|
||||
canonicalModel: 'np-dms-ai',
|
||||
temperature: 0.5,
|
||||
topP: 0.8,
|
||||
maxTokens: 4096,
|
||||
numCtx: 8192,
|
||||
repeatPenalty: 1.15,
|
||||
keepAliveSeconds: 600,
|
||||
};
|
||||
mockRedis.get.mockResolvedValueOnce(JSON.stringify(cachedPolicy));
|
||||
const result = await service.getProfileParameters('standard');
|
||||
expect(result.temperature).toBe(0.5);
|
||||
expect(mockProfileRepo.findOne).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ควร fallback ไปยัง DB เมื่อ Redis cache miss', async () => {
|
||||
mockRedis.get.mockResolvedValueOnce(null); // cache miss
|
||||
mockProfileRepo.findOne.mockResolvedValueOnce(
|
||||
makeProfile({ temperature: 0.3 })
|
||||
);
|
||||
mockRedis.set.mockResolvedValueOnce('OK');
|
||||
const result = await service.getProfileParameters('standard');
|
||||
expect(result.temperature).toBe(0.3);
|
||||
expect(mockProfileRepo.findOne).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('ควร fallback ไปยัง hardcoded defaults เมื่อ DB ก็ไม่มีข้อมูล', async () => {
|
||||
mockRedis.get.mockResolvedValueOnce(null);
|
||||
mockProfileRepo.findOne.mockResolvedValueOnce(null); // ไม่มีใน DB
|
||||
const result = await service.getProfileParameters('quality');
|
||||
expect(result.temperature).toBe(0.1); // default quality profile
|
||||
});
|
||||
|
||||
it('ควร fallback ไปยัง DB เมื่อ Redis throw error', async () => {
|
||||
mockRedis.get.mockRejectedValueOnce(new Error('Redis CONN error'));
|
||||
mockProfileRepo.findOne.mockResolvedValueOnce(makeProfile());
|
||||
mockRedis.set.mockResolvedValueOnce('OK');
|
||||
const result = await service.getProfileParameters('standard');
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it('ควร fallback ไปยัง defaults เมื่อ DB ก็ throw error', async () => {
|
||||
mockRedis.get.mockResolvedValueOnce(null);
|
||||
mockProfileRepo.findOne.mockRejectedValueOnce(new Error('DB timeout'));
|
||||
const result = await service.getProfileParameters('interactive');
|
||||
expect(result.temperature).toBe(0.7); // default interactive profile
|
||||
});
|
||||
|
||||
it('ควรไม่ throw เมื่อ cache write ล้มเหลว (graceful)', async () => {
|
||||
mockRedis.get.mockResolvedValueOnce(null);
|
||||
mockProfileRepo.findOne.mockResolvedValueOnce(makeProfile());
|
||||
mockRedis.set.mockRejectedValueOnce(new Error('Redis write failed'));
|
||||
const result = await service.getProfileParameters('standard');
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getModelDefaults ────────────────────────────────────────────────────────
|
||||
describe('getModelDefaults()', () => {
|
||||
it('ควรคืนจาก Redis cache เมื่อมี cache hit', async () => {
|
||||
const cachedOcrPolicy = {
|
||||
canonicalModel: 'np-dms-ocr',
|
||||
temperature: 0.1,
|
||||
topP: 0.1,
|
||||
maxTokens: null,
|
||||
numCtx: null,
|
||||
repeatPenalty: 1.1,
|
||||
keepAliveSeconds: 0,
|
||||
};
|
||||
mockRedis.get.mockResolvedValueOnce(JSON.stringify(cachedOcrPolicy));
|
||||
const result = await service.getModelDefaults('np-dms-ocr');
|
||||
expect(result.canonicalModel).toBe('np-dms-ocr');
|
||||
expect(result.temperature).toBe(0.1);
|
||||
});
|
||||
|
||||
it('ควร fallback ไปยัง DB เมื่อ Redis cache miss', async () => {
|
||||
mockRedis.get.mockResolvedValueOnce(null);
|
||||
mockProfileRepo.findOne.mockResolvedValueOnce(
|
||||
makeProfile({ canonicalModel: 'np-dms-ocr', temperature: 0.05 })
|
||||
);
|
||||
mockRedis.set.mockResolvedValueOnce('OK');
|
||||
const result = await service.getModelDefaults('np-dms-ocr');
|
||||
expect(result.temperature).toBe(0.05);
|
||||
});
|
||||
|
||||
it('ควรคืน defaultOcrPolicy เมื่อไม่มีใน DB (np-dms-ocr)', async () => {
|
||||
mockRedis.get.mockResolvedValueOnce(null);
|
||||
mockProfileRepo.findOne.mockResolvedValueOnce(null);
|
||||
const result = await service.getModelDefaults('np-dms-ocr');
|
||||
expect(result.canonicalModel).toBe('np-dms-ocr');
|
||||
expect(result.keepAliveSeconds).toBe(0);
|
||||
});
|
||||
|
||||
it('ควรคืน standard defaults เมื่อไม่มีใน DB (np-dms-ai)', async () => {
|
||||
mockRedis.get.mockResolvedValueOnce(null);
|
||||
mockProfileRepo.findOne.mockResolvedValueOnce(null);
|
||||
const result = await service.getModelDefaults('np-dms-ai');
|
||||
expect(result.canonicalModel).toBe('np-dms-ai');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── saveSandboxDraft ────────────────────────────────────────────────────────
|
||||
describe('saveSandboxDraft()', () => {
|
||||
it('ควรอัปเดต draft ที่มีอยู่แล้ว', async () => {
|
||||
const existingDraft = makeSandbox({ temperature: 0.5 });
|
||||
mockSandboxRepo.findOne.mockResolvedValueOnce(existingDraft);
|
||||
mockSandboxRepo.save.mockResolvedValueOnce({
|
||||
...existingDraft,
|
||||
temperature: 0.8,
|
||||
});
|
||||
const result = await service.saveSandboxDraft('standard', {
|
||||
temperature: 0.8,
|
||||
});
|
||||
expect(result.temperature).toBe(0.8);
|
||||
});
|
||||
|
||||
it('ควรสร้าง draft ใหม่จาก production เมื่อยังไม่มี draft', async () => {
|
||||
mockSandboxRepo.findOne.mockResolvedValueOnce(null); // ไม่มี draft
|
||||
// getProductionPolicy → getProfileParameters → Redis miss → DB
|
||||
mockRedis.get.mockResolvedValueOnce(null);
|
||||
mockProfileRepo.findOne.mockResolvedValueOnce(makeProfile());
|
||||
mockRedis.set.mockResolvedValueOnce('OK');
|
||||
const newDraft = makeSandbox({ topP: 0.9 });
|
||||
mockSandboxRepo.create.mockReturnValueOnce(newDraft);
|
||||
mockSandboxRepo.save.mockResolvedValueOnce({ ...newDraft, topP: 0.9 });
|
||||
const result = await service.saveSandboxDraft(
|
||||
'standard',
|
||||
{ topP: 0.9 },
|
||||
1
|
||||
);
|
||||
expect(result.topP).toBe(0.9);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── resetSandboxToProduction ────────────────────────────────────────────────
|
||||
describe('resetSandboxToProduction()', () => {
|
||||
it('ควร reset draft ที่มีอยู่ให้ตรงกับ production', async () => {
|
||||
mockRedis.get.mockResolvedValueOnce(null);
|
||||
mockProfileRepo.findOne.mockResolvedValueOnce(
|
||||
makeProfile({ temperature: 0.5 })
|
||||
);
|
||||
mockRedis.set.mockResolvedValueOnce('OK');
|
||||
const existingDraft = makeSandbox({ temperature: 0.9 });
|
||||
mockSandboxRepo.findOne.mockResolvedValueOnce(existingDraft);
|
||||
mockSandboxRepo.save.mockResolvedValueOnce({
|
||||
...existingDraft,
|
||||
temperature: 0.5,
|
||||
});
|
||||
const result = await service.resetSandboxToProduction('standard', 1);
|
||||
expect(result.temperature).toBe(0.5);
|
||||
});
|
||||
|
||||
it('ควรสร้าง draft ใหม่เมื่อยังไม่มี draft แล้ว reset ไปยัง production', async () => {
|
||||
mockRedis.get.mockResolvedValueOnce(null);
|
||||
mockProfileRepo.findOne.mockResolvedValueOnce(
|
||||
makeProfile({ temperature: 0.5 })
|
||||
);
|
||||
mockRedis.set.mockResolvedValueOnce('OK');
|
||||
mockSandboxRepo.findOne.mockResolvedValueOnce(null); // ไม่มี draft
|
||||
const newDraft = makeSandbox();
|
||||
mockSandboxRepo.create.mockReturnValueOnce(newDraft);
|
||||
mockSandboxRepo.save.mockResolvedValueOnce({
|
||||
...newDraft,
|
||||
temperature: 0.5,
|
||||
});
|
||||
const result = await service.resetSandboxToProduction('standard');
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── createJobPayload ────────────────────────────────────────────────────────
|
||||
describe('createJobPayload()', () => {
|
||||
it('ควรสร้าง payload ที่ถูกต้องสำหรับ rag-query job', async () => {
|
||||
mockRedis.get.mockResolvedValue(null);
|
||||
mockProfileRepo.findOne.mockResolvedValue(makeProfile());
|
||||
mockRedis.set.mockResolvedValue('OK');
|
||||
const payload = await service.createJobPayload(
|
||||
'rag-query',
|
||||
'doc-id-123',
|
||||
'att-id-456'
|
||||
);
|
||||
expect(payload.jobType).toBe('rag-query');
|
||||
expect(payload.documentPublicId).toBe('doc-id-123');
|
||||
expect(payload.canonicalModel).toBe('np-dms-ai');
|
||||
expect(payload.snapshotParams.temperature).toBeDefined();
|
||||
expect(payload.ocrSnapshotParams).toBeUndefined(); // rag-query ไม่มี OCR snapshot
|
||||
});
|
||||
|
||||
it('ควรสร้าง payload ที่มี ocrSnapshotParams สำหรับ migrate-document job', async () => {
|
||||
mockRedis.get.mockResolvedValue(null);
|
||||
mockProfileRepo.findOne.mockResolvedValue(makeProfile());
|
||||
mockRedis.set.mockResolvedValue('OK');
|
||||
const payload = await service.createJobPayload(
|
||||
'migrate-document',
|
||||
'doc-id-789'
|
||||
);
|
||||
expect(payload.canonicalModel).toBe('np-dms-ai'); // main model for migrate
|
||||
expect(payload.ocrSnapshotParams).toBeDefined();
|
||||
expect(payload.ocrSnapshotParams?.temperature).toBeDefined();
|
||||
});
|
||||
|
||||
it('ควรสร้าง payload ที่ใช้ np-dms-ocr สำหรับ ocr-extract job', async () => {
|
||||
mockRedis.get.mockResolvedValue(null);
|
||||
mockProfileRepo.findOne.mockResolvedValue(
|
||||
makeProfile({ canonicalModel: 'np-dms-ocr', temperature: 0.1 })
|
||||
);
|
||||
mockRedis.set.mockResolvedValue('OK');
|
||||
const payload = await service.createJobPayload(
|
||||
'ocr-extract',
|
||||
'doc-id-ocr'
|
||||
);
|
||||
expect(payload.canonicalModel).toBe('np-dms-ocr');
|
||||
expect(payload.ocrSnapshotParams).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── applyProfile validation ─────────────────────────────────────────────────
|
||||
describe('applyProfile() — parameter validation', () => {
|
||||
it('ควรโยน BadRequestException เมื่อ temperature > 1', async () => {
|
||||
const draft = makeSandbox({ temperature: 1.5, profileName: 'standard' });
|
||||
mockSandboxRepo.findOne.mockResolvedValueOnce(draft);
|
||||
await expect(service.applyProfile('standard', 1)).rejects.toBeInstanceOf(
|
||||
BadRequestException
|
||||
);
|
||||
});
|
||||
|
||||
it('ควรโยน BadRequestException เมื่อ topP < 0', async () => {
|
||||
const draft = makeSandbox({
|
||||
temperature: 0.5,
|
||||
topP: -0.1,
|
||||
profileName: 'standard',
|
||||
});
|
||||
mockSandboxRepo.findOne.mockResolvedValueOnce(draft);
|
||||
await expect(service.applyProfile('standard', 1)).rejects.toBeInstanceOf(
|
||||
BadRequestException
|
||||
);
|
||||
});
|
||||
|
||||
it('ควรโยน BadRequestException เมื่อ repeatPenalty < 1', async () => {
|
||||
const draft = makeSandbox({
|
||||
temperature: 0.5,
|
||||
topP: 0.8,
|
||||
repeatPenalty: 0.9,
|
||||
profileName: 'standard',
|
||||
});
|
||||
mockSandboxRepo.findOne.mockResolvedValueOnce(draft);
|
||||
await expect(service.applyProfile('standard')).rejects.toBeInstanceOf(
|
||||
BadRequestException
|
||||
);
|
||||
});
|
||||
|
||||
it('ควรโยน BadRequestException เมื่อ keepAliveSeconds < 0', async () => {
|
||||
const draft = makeSandbox({
|
||||
temperature: 0.5,
|
||||
topP: 0.8,
|
||||
repeatPenalty: 1.1,
|
||||
keepAliveSeconds: -1,
|
||||
profileName: 'standard',
|
||||
});
|
||||
mockSandboxRepo.findOne.mockResolvedValueOnce(draft);
|
||||
await expect(service.applyProfile('standard')).rejects.toBeInstanceOf(
|
||||
BadRequestException
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user