feat(ai): implement unified prompt management UX/UI (ADR-037)
CI / CD Pipeline / build (push) Failing after 3m23s
CI / CD Pipeline / deploy (push) Has been skipped

- Add context config endpoints (GET/PUT /api/ai/prompts/:type/:version/context-config)
- Add execution profile endpoints (CRUD /api/ai/execution-profiles)
- Add sandbox RAG Prep endpoint (POST /api/ai/admin/sandbox/rag-prep)
- Create Prompt Management UI with multi-type support
- Add ContextConfigEditor, PromptEditor, RuntimeParametersPanel components
- Add SandboxTabs for 3-step workflow (OCR, Extract, RAG Prep)
- Add database deltas for ai_execution_profiles and additional prompt types
- Update quickstart.md with production backend URLs
- Add comprehensive test coverage for new features
This commit is contained in:
2026-06-14 19:55:43 +07:00
parent 56f9544cb0
commit 67da186672
64 changed files with 6327 additions and 6107 deletions
+3 -1
View File
@@ -4,6 +4,7 @@
// - 2026-05-14: เพิ่ม JSDoc idempotency contract สำหรับทุก enqueue method (💡 S3).
// - 2026-05-21: เพิ่มการลงทะเบียน QUEUE_AI_BATCH และ enqueueSandboxJob สำหรับ Superadmin sandbox.
// - 2026-05-21: แก้ไข ESLint error โดยการเปลี่ยน Queue<any> เป็น Queue<unknown> สำหรับ batchQueue
// - 2026-06-14: เพิ่ม sandbox-rag-prep ใน enqueueSandboxJob (T039)
import { Injectable } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue, JobsOptions } from 'bullmq';
@@ -122,7 +123,8 @@ export class AiQueueService {
| 'sandbox-rag'
| 'sandbox-extract'
| 'sandbox-ocr-only'
| 'sandbox-ai-extract',
| 'sandbox-ai-extract'
| 'sandbox-rag-prep',
payload: {
idempotencyKey: string;
projectPublicId?: string;
+29
View File
@@ -16,6 +16,7 @@
// - 2026-06-11: แก้ไขการส่งพารามิเตอร์ให้กับ queueSuggestJob ใน suggestDocumentMetadata
// - 2026-06-13: T024-T026 — เพิ่ม sandbox parameter endpoints (GET/PUT/POST reset) ตาม ADR-036
// - 2026-06-13: T036, T037, T039, T040, T041 — เพิ่ม endpoints apply sandbox profile และ get production parameters พร้อม idempotency, CASL, validation และ audit
// - 2026-06-14: เพิ่ม POST /ai/admin/sandbox/rag-prep endpoint (T033)
// Controller สำหรับ AI Gateway Endpoints (ADR-023)
import {
@@ -63,6 +64,7 @@ import {
import { AiRagService } from './ai-rag.service';
import { AiQueueService } from './ai-queue.service';
import { AiRagQueryDto } from './dto/ai-rag-query.dto';
import { SandboxRagPrepDto } from './dto/sandbox-rag-prep.dto';
import { ExtractDocumentDto } from './dto/extract-document.dto';
import { AiCallbackDto } from './dto/ai-callback.dto';
import { CreateAiJobDto } from './dto/create-ai-job.dto';
@@ -430,6 +432,7 @@ export class AiController {
@ApiBearerAuth()
@RequirePermission('system.manage_all')
@HttpCode(HttpStatus.ACCEPTED)
@Throttle({ default: { limit: 10, ttl: 60000 } })
@ApiOperation({
summary:
'AI Admin Sandbox RAG Query — ส่ง sandbox RAG เข้า queue ai-batch (T035)',
@@ -483,6 +486,7 @@ export class AiController {
@RequirePermission('system.manage_all')
@UseInterceptors(FileInterceptor('file'))
@HttpCode(HttpStatus.ACCEPTED)
@Throttle({ default: { limit: 10, ttl: 60000 } })
@ApiOperation({
summary:
'AI Admin Sandbox OCR Extract — อัปโหลดไฟล์เพื่อทำ OCR Sandbox (T041 & T042)',
@@ -542,6 +546,7 @@ export class AiController {
@RequirePermission('system.manage_all')
@UseInterceptors(FileInterceptor('file'))
@HttpCode(HttpStatus.ACCEPTED)
@Throttle({ default: { limit: 10, ttl: 60000 } })
@ApiOperation({
summary: 'Step 1: Run OCR Only — สำหรับตรวจคุณภาพ OCR ก่อนทดสอบ AI',
description:
@@ -636,6 +641,7 @@ export class AiController {
@Post('admin/sandbox/ai-extract')
@UseGuards(JwtAuthGuard, RbacGuard)
@RequirePermission('system.manage_all')
@Throttle({ default: { limit: 10, ttl: 60000 } })
@ApiOperation({
summary: 'Step 2: Run AI Extraction — ใช้ OCR text ที่ cache จาก Step 1',
description:
@@ -668,6 +674,29 @@ export class AiController {
return { requestPublicId, jobId, status: 'queued' };
}
@Post('admin/sandbox/rag-prep')
@UseGuards(JwtAuthGuard, RbacGuard)
@RequirePermission('system.manage_all')
@Throttle({ default: { limit: 10, ttl: 60000 } })
@ApiOperation({
summary: 'Step 3: Run RAG Prep Sandbox testing (T033)',
description:
'รับข้อความ OCR และ profileId แล้วรัน semantic chunking และ embedding preview',
})
async submitSandboxRagPrep(
@Body() dto: SandboxRagPrepDto
): Promise<{ requestPublicId: string; jobId: string; status: string }> {
const requestPublicId = uuidv7();
const jobId = await this.aiQueueService.enqueueSandboxJob(
'sandbox-rag-prep',
{
idempotencyKey: requestPublicId,
extraPayload: { text: dto.text, profileId: dto.profileId },
}
);
return { requestPublicId, jobId, status: 'queued' };
}
// --- Webhook Callback จาก n8n (Service Account) ---
@Post('callback')
@@ -0,0 +1,38 @@
// File: backend/src/modules/ai/dto/context-config.dto.ts
// Change Log:
// - 2026-06-14: Created ContextConfigDto for prompt context management (conforming to task T006)
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsInt, IsString, IsObject, Min } from 'class-validator';
export class ContextFilterDto {
@ApiPropertyOptional({ type: String, nullable: true })
@IsOptional()
@IsString()
projectId!: string | null;
@ApiPropertyOptional({ type: String, nullable: true })
@IsOptional()
@IsString()
contractId!: string | null;
}
export class ContextConfigDto {
@ApiPropertyOptional({ type: ContextFilterDto, nullable: true })
@IsOptional()
@IsObject()
filter?: ContextFilterDto | null;
@ApiProperty({ type: Number, minimum: 1 })
@IsInt()
@Min(1)
pageSize!: number;
@ApiProperty({ type: String })
@IsString()
language!: string;
@ApiProperty({ type: String })
@IsString()
outputLanguage!: string;
}
@@ -0,0 +1,66 @@
// File: backend/src/modules/ai/dto/create-execution-profile.dto.ts
// Change Log:
// - 2026-06-14: Created CreateExecutionProfileDto for AI execution profile creation (conforming to task T008)
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsNotEmpty,
IsOptional,
IsString,
IsNumber,
IsInt,
Min,
Max,
} from 'class-validator';
export class CreateExecutionProfileDto {
@ApiProperty({ description: 'Profile Name' })
@IsNotEmpty()
@IsString()
profileName!: string;
@ApiPropertyOptional({
description: 'Canonical Model',
enum: ['np-dms-ai', 'np-dms-ocr'],
})
@IsOptional()
@IsString()
canonicalModel?: 'np-dms-ai' | 'np-dms-ocr';
@ApiProperty({ description: 'Temperature parameter' })
@IsNumber()
@Min(0.0)
@Max(1.0)
temperature!: number;
@ApiProperty({ description: 'Top-P parameter' })
@IsNumber()
@Min(0.0)
@Max(1.0)
topP!: number;
@ApiProperty({ description: 'Repeat penalty parameter' })
@IsNumber()
@Min(1.0)
@Max(2.0)
repeatPenalty!: number;
@ApiPropertyOptional({ description: 'Maximum tokens to generate' })
@IsOptional()
@IsInt()
@Min(1)
maxTokens?: number | null;
@ApiPropertyOptional({
description: 'Context window size (num_ctx / ctxSize)',
})
@IsOptional()
@IsInt()
@Min(1)
ctxSize?: number | null;
@ApiProperty({ description: 'Keep alive in seconds' })
@IsInt()
@Min(0)
keepAlive!: number;
}
@@ -0,0 +1,18 @@
// File: backend/src/modules/ai/dto/sandbox-rag-prep.dto.ts
// Change Log:
// - 2026-06-14: Created SandboxRagPrepDto for Sandbox RAG Prep testing (conforming to task T007)
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class SandboxRagPrepDto {
@ApiProperty({ description: 'Text to prepare for RAG (OCR text)' })
@IsNotEmpty()
@IsString()
text!: string;
@ApiPropertyOptional({ description: 'Execution profile public ID to use' })
@IsOptional()
@IsString()
profileId?: string | null;
}
@@ -0,0 +1,47 @@
// File: backend/src/modules/ai/dto/update-execution-profile.dto.ts
// Change Log:
// - 2026-06-14: Created UpdateExecutionProfileDto for AI execution profile updates (conforming to task T009)
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsNumber, IsInt, Min, Max } from 'class-validator';
export class UpdateExecutionProfileDto {
@ApiPropertyOptional({ description: 'Temperature parameter' })
@IsOptional()
@IsNumber()
@Min(0.0)
@Max(1.0)
temperature?: number;
@ApiPropertyOptional({ description: 'Top-P parameter' })
@IsOptional()
@IsNumber()
@Min(0.0)
@Max(1.0)
topP?: number;
@ApiPropertyOptional({ description: 'Repeat penalty parameter' })
@IsOptional()
@IsNumber()
@Min(1.0)
@Max(2.0)
repeatPenalty?: number;
@ApiPropertyOptional({ description: 'Maximum tokens to generate' })
@IsOptional()
@IsInt()
@Min(1)
maxTokens?: number | null;
@ApiPropertyOptional({ description: 'Context window size' })
@IsOptional()
@IsInt()
@Min(1)
ctxSize?: number | null;
@ApiPropertyOptional({ description: 'Keep alive in seconds' })
@IsOptional()
@IsInt()
@Min(0)
keepAlive?: number;
}
@@ -1,6 +1,7 @@
// File: backend/src/modules/ai/processors/ai-batch.processor.ts
// Change Log
// - 2026-06-08: แก้ไขปัญหา LLM JSON response truncated โดยการเพิ่ม num_ctx เป็น 16384 ใน sandbox-extract, sandbox-ai-extract และ migrate-document (แก้ไขโดย AGY Gemini 3.5 Flash (Medium))
// - 2026-06-14: เพิ่ม case sandbox-rag-prep และ processSandboxRagPrep (T035)
// - 2026-05-15: เพิ่ม processor สำหรับ ai-batch queue ตาม ADR-023A.
// - 2026-05-15: เพิ่ม EmbeddingService สำหรับ embed-document logic (T022).
// - 2026-05-21: เพิ่มการรองรับ sandbox-rag และ sandbox-extract สำหรับ Superadmin sandbox.
@@ -70,6 +71,7 @@ export type AiBatchJobType =
| 'sandbox-extract'
| 'sandbox-ocr-only'
| 'sandbox-ai-extract'
| 'sandbox-rag-prep'
| 'migrate-document'
| 'rag-prepare'
| 'ai-suggest'
@@ -294,7 +296,10 @@ export class AiBatchProcessor extends WorkerHost {
async process(job: Job<AiBatchJobData>): Promise<void> {
const isSandbox =
job.data.jobType === 'sandbox-rag' ||
job.data.jobType === 'sandbox-extract';
job.data.jobType === 'sandbox-extract' ||
job.data.jobType === 'sandbox-ocr-only' ||
job.data.jobType === 'sandbox-ai-extract' ||
job.data.jobType === 'sandbox-rag-prep';
if (!isSandbox) {
await this.setAiProcessingStatus(job.data.documentPublicId, 'PROCESSING');
}
@@ -362,6 +367,12 @@ export class AiBatchProcessor extends WorkerHost {
);
await this.processSandboxAiExtract(job.data);
return;
case 'sandbox-rag-prep':
this.logger.log(
`Sandbox RAG Prep job processing — jobId=${String(job.id)}`
);
await this.processSandboxRagPrep(job.data);
return;
case 'migrate-document':
this.logger.log(
`Migrate document job processing — jobId=${String(job.id)}`
@@ -1530,4 +1541,149 @@ export class AiBatchProcessor extends WorkerHost {
const confidence = suggestion['confidenceScore'];
return typeof confidence === 'number' ? confidence : undefined;
}
private async processSandboxRagPrep(data: AiBatchJobData): Promise<void> {
const { idempotencyKey, payload } = data;
const text = payload.text as string;
const profileId = payload.profileId as string | undefined;
await this.redis.setex(
`ai:rag:result:${idempotencyKey}`,
3600,
JSON.stringify({
requestPublicId: idempotencyKey,
status: 'processing',
})
);
try {
if (!text) {
throw new Error('text is required for sandbox-rag-prep job');
}
const activePrompt =
await this.aiPromptsService.getActive('rag_prep_prompt');
if (!activePrompt) {
throw new Error('No active rag_prep_prompt version found');
}
const promptText = activePrompt.template
.replace('{{text}}', text)
.replace('{{ocr_text}}', text);
let sandboxParams;
if (profileId) {
try {
sandboxParams =
await this.aiPolicyService.getSandboxParameters(profileId);
} catch (err) {
this.logger.warn(
`Failed to fetch sandbox parameters for profileId=${profileId}: ${String(err)}`
);
}
}
if (!sandboxParams) {
try {
sandboxParams =
await this.aiPolicyService.getSandboxParameters('standard');
} catch (err) {
this.logger.warn(
`Failed to fetch sandbox parameters for standard: ${String(err)}`
);
}
}
const generateOptions = {
options: {
num_ctx: sandboxParams?.numCtx ?? 8192,
num_predict: sandboxParams?.maxTokens ?? 4096,
temperature: sandboxParams?.temperature,
top_p: sandboxParams?.topP,
repeat_penalty: sandboxParams?.repeatPenalty,
},
};
const llmOutput = await this.ollamaService.generate(
promptText,
generateOptions
);
const parsed = this.parseChunkTags(llmOutput);
const chunks =
parsed.length > 0 ? parsed : this.fixedSizeChunk(text, 512, 64);
const ragChunks: Array<{ text: string; summary: string }> = [];
const ragVectors: number[][] = [];
for (const chunk of chunks) {
try {
const embedResult = await this.ocrService.embedViaSidecar(chunk.text);
ragChunks.push({
text: chunk.text,
summary: chunk.topic,
});
ragVectors.push(embedResult.dense);
} catch (err) {
this.logger.error(
`Sandbox embed failed for chunk: ${chunk.topic}`,
err
);
}
}
await this.redis.setex(
`ai:rag:result:${idempotencyKey}`,
3600,
JSON.stringify({
requestPublicId: idempotencyKey,
status: 'completed',
ragChunks,
ragVectors,
completedAt: new Date().toISOString(),
})
);
} catch (err: unknown) {
const errMsg = err instanceof Error ? err.message : String(err);
this.logger.error(`Sandbox RAG Prep failed: ${errMsg}`);
await this.redis.setex(
`ai:rag:result:${idempotencyKey}`,
3600,
JSON.stringify({
requestPublicId: idempotencyKey,
status: 'failed',
errorMessage: errMsg,
completedAt: new Date().toISOString(),
})
);
throw err;
}
}
private parseChunkTags(
llmOutput: string
): Array<{ topic: string; text: string }> {
const chunks: Array<{ topic: string; text: string }> = [];
const regex = /<chunk\s+topic="([^"]*)"\s*>([\s\S]*?)<\/chunk\s*>/gi;
let match;
while ((match = regex.exec(llmOutput)) !== null) {
const topic = match[1]?.trim() || 'ทั่วไป';
const text = match[2]?.trim();
if (text) {
chunks.push({ topic, text });
}
}
return chunks;
}
private fixedSizeChunk(
text: string,
chunkSize: number,
overlap: number
): Array<{ topic: string; text: string }> {
const chunks: Array<{ topic: string; text: string }> = [];
const cleanText = text.replace(/\s+/g, ' ').trim();
const textLength = cleanText.length;
let startIndex = 0;
let chunkIndex = 0;
while (startIndex < textLength) {
const endIndex = Math.min(startIndex + chunkSize, textLength);
const chunkText = cleanText.substring(startIndex, endIndex);
chunks.push({
topic: `ส่วนที่ ${chunkIndex + 1}`,
text: chunkText,
});
startIndex += chunkSize - overlap;
chunkIndex += 1;
}
return chunks;
}
}
@@ -6,6 +6,7 @@ import {
Controller,
Get,
Post,
Put,
Delete,
Patch,
Body,
@@ -26,6 +27,7 @@ import { AiPrompt } from './ai-prompts.entity';
import { CreateAiPromptDto } from './dto/create-ai-prompt.dto';
import { UpdatePromptNoteDto } from './dto/update-prompt-note.dto';
import { AiPromptResponseDto } from './dto/ai-prompt-response.dto';
import { ContextConfigDto } from '../dto/context-config.dto';
import { plainToInstance } from 'class-transformer';
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../../common/guards/rbac.guard';
@@ -137,4 +139,42 @@ export class AiPromptsController {
);
return { data: this.mapToDto(updated) };
}
@Get(':promptType/:versionNumber/context-config')
@RequirePermission('system.manage_all')
@ApiOperation({ summary: 'ดึง Context Config ของ Prompt Version ที่กำหนด' })
@ApiParam({ name: 'promptType', example: 'ocr_extraction' })
@ApiParam({ name: 'versionNumber', type: Number })
async getContextConfig(
@Param('promptType') promptType: string,
@Param('versionNumber', ParseIntPipe) versionNumber: number
): Promise<{ data: Record<string, unknown> | null }> {
const config = await this.promptsService.getContextConfig(
promptType,
versionNumber
);
return { data: config };
}
@Put(':promptType/:versionNumber/context-config')
@RequirePermission('system.manage_all')
@Audit('ai_prompt.update_context_config', 'AiPrompt')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'อัปเดต Context Config ของ Prompt Version ที่กำหนด',
})
@ApiParam({ name: 'promptType', example: 'ocr_extraction' })
@ApiParam({ name: 'versionNumber', type: Number })
async updateContextConfig(
@Param('promptType') promptType: string,
@Param('versionNumber', ParseIntPipe) versionNumber: number,
@Body() dto: ContextConfigDto
): Promise<{ data: Record<string, unknown> }> {
const updated = await this.promptsService.updateContextConfig(
promptType,
versionNumber,
dto
);
return { data: updated };
}
}
@@ -223,7 +223,7 @@ describe('AiPromptsService', () => {
});
});
describe('create', () => {
it('ควรปฏิเสธ template ที่ไม่มี {{ocr_text}} placeholder', async () => {
it('ควรปฏิเสธ template ที่ไม่มี {{ocr_text}} placeholder สำหรับ ocr_extraction', async () => {
await expect(
service.create(
'ocr_extraction',
@@ -232,6 +232,36 @@ describe('AiPromptsService', () => {
)
).rejects.toThrow(ValidationException);
});
it('ควรปฏิเสธ template ที่ไม่มี {{query}} หรือ {{context}} placeholder สำหรับ rag_query_prompt', async () => {
await expect(
service.create(
'rag_query_prompt',
{ template: 'Invalid template context' },
1
)
).rejects.toThrow(ValidationException);
await expect(
service.create(
'rag_query_prompt',
{ template: 'Invalid template query {{query}}' },
1
)
).rejects.toThrow(ValidationException);
});
it('ควรปฏิเสธ template ที่ไม่มี {{text}} placeholder สำหรับ rag_prep_prompt', async () => {
await expect(
service.create('rag_prep_prompt', { template: 'Invalid template' }, 1)
).rejects.toThrow(ValidationException);
});
it('ควรปฏิเสธ template ที่ไม่มี {{document_text}} placeholder สำหรับ classification_prompt', async () => {
await expect(
service.create(
'classification_prompt',
{ template: 'Invalid template' },
1
)
).rejects.toThrow(ValidationException);
});
it('ควรปฏิเสธ template ที่ตัวอักษรเกิน 4,000 ตัว', async () => {
const longTemplate = 'a'.repeat(4005) + '{{ocr_text}}';
await expect(
@@ -363,4 +393,84 @@ describe('AiPromptsService', () => {
expect(mockAiPromptRepo.findOne).toHaveBeenCalled();
});
});
describe('contextConfig CRUD', () => {
it('ควร getContextConfig สำเร็จ', async () => {
const prompt = {
id: 1,
promptType: 'ocr_extraction',
versionNumber: 1,
contextConfig: {
pageSize: 5,
language: 'th',
outputLanguage: 'th',
filter: null,
},
};
mockAiPromptRepo.findOne.mockResolvedValue(prompt);
const result = await service.getContextConfig('ocr_extraction', 1);
expect(result).toEqual(prompt.contextConfig);
});
it('ควรโยน NotFoundException เมื่อ getContextConfig ไม่พบเวอร์ชัน', async () => {
mockAiPromptRepo.findOne.mockResolvedValue(null);
await expect(
service.getContextConfig('ocr_extraction', 99)
).rejects.toThrow(NotFoundException);
});
it('ควร updateContextConfig สำเร็จและตรวจสอบโครงการ/สัญญาสำเร็จ', async () => {
const prompt = {
id: 1,
promptType: 'ocr_extraction',
versionNumber: 1,
contextConfig: null,
};
mockAiPromptRepo.findOne.mockResolvedValue(prompt);
mockAiPromptRepo.save.mockResolvedValue({
...prompt,
contextConfig: {
pageSize: 5,
language: 'th',
outputLanguage: 'th',
filter: { projectId: 'p-1', contractId: 'c-1' },
},
});
// จำลองให้โครงการและสัญญาถูกต้องใน DB
mockQueryBuilder.getRawOne
.mockResolvedValueOnce({ id: 10 }) // project check
.mockResolvedValueOnce({ id: 20 }); // contract check
const result = await service.updateContextConfig('ocr_extraction', 1, {
pageSize: 5,
language: 'th',
outputLanguage: 'th',
filter: { projectId: 'p-1', contractId: 'c-1' },
});
expect(result.pageSize).toBe(5);
expect(mockAiPromptRepo.save).toHaveBeenCalled();
});
it('ควรโยน NotFoundException เมื่อ updateContextConfig ส่ง project UUID ที่ไม่มีอยู่ใน DB', async () => {
const prompt = {
id: 1,
promptType: 'ocr_extraction',
versionNumber: 1,
contextConfig: null,
};
mockAiPromptRepo.findOne.mockResolvedValue(prompt);
mockQueryBuilder.getRawOne.mockResolvedValueOnce(null); // project not found
await expect(
service.updateContextConfig('ocr_extraction', 1, {
pageSize: 5,
language: 'th',
outputLanguage: 'th',
filter: { projectId: 'invalid-proj-uuid', contractId: null },
})
).rejects.toThrow(NotFoundException);
});
});
});
@@ -13,6 +13,7 @@ import { randomUUID } from 'crypto';
import { AiPrompt } from './ai-prompts.entity';
import { AuditLog } from '../../../common/entities/audit-log.entity';
import { CreateAiPromptDto } from './dto/create-ai-prompt.dto';
import { ContextConfigDto } from '../dto/context-config.dto';
import {
BusinessException,
ValidationException,
@@ -343,8 +344,31 @@ export class AiPromptsService {
dto: CreateAiPromptDto,
userId: number
): Promise<AiPrompt> {
if (!dto.template.includes('{{ocr_text}}')) {
throw new ValidationException('template ต้องมี {{ocr_text}} placeholder');
if (promptType === 'ocr_extraction') {
if (!dto.template.includes('{{ocr_text}}')) {
throw new ValidationException(
'template ต้องมี {{ocr_text}} placeholder'
);
}
} else if (promptType === 'rag_query_prompt') {
if (
!dto.template.includes('{{query}}') ||
!dto.template.includes('{{context}}')
) {
throw new ValidationException(
'template ต้องมี {{query}} และ {{context}} placeholder'
);
}
} else if (promptType === 'rag_prep_prompt') {
if (!dto.template.includes('{{text}}')) {
throw new ValidationException('template ต้องมี {{text}} placeholder');
}
} else if (promptType === 'classification_prompt') {
if (!dto.template.includes('{{document_text}}')) {
throw new ValidationException(
'template ต้องมี {{document_text}} placeholder'
);
}
}
if (dto.template.length > 4000) {
throw new ValidationException('Template exceeds 4,000 character limit');
@@ -527,6 +551,76 @@ export class AiPromptsService {
}
}
/**
* ดึง Context Config ของ Prompt Version ที่กำหนด
*/
async getContextConfig(
promptType: string,
versionNumber: number
): Promise<Record<string, unknown> | null> {
const prompt = await this.aiPromptRepo.findOne({
where: { promptType, versionNumber },
});
if (!prompt) {
throw new NotFoundException('AiPrompt', versionNumber.toString());
}
return prompt.contextConfig;
}
/**
* อัปเดต Context Config ของ Prompt Version ที่กำหนด พร้อมทั้งตรวจเช็คความถูกต้องของโครงการและสัญญาใน DB
*/
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);
}
}
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;
}
/**
* บันทึกข้อมูลการปฏิบัติการของผู้ใช้ลงในตารางหลัก audit_logs
*/
@@ -3,6 +3,7 @@
// - 2026-06-03: สร้าง unit test สำหรับ OllamaService ครอบคลุม generate() model option,
// getOcrModelName(), และ loadModel() keepAlive param ตาม ADR-034
// - 2026-06-13: ADR-036 — อัปเดต expected model tags เป็น np-dms-ai/np-dms-ocr
// - 2026-06-14: เพิ่ม tests สำหรับ generateEmbedding, checkHealth, unloadModel เพื่อเพิ่ม branch coverage
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
@@ -132,5 +133,125 @@ describe('OllamaService (ADR-034)', () => {
expect(result).toBe(false);
expect(mockedAxios.post).not.toHaveBeenCalled();
});
it('ควรคืน false และ log error เมื่อ axios throw ระหว่าง loadModel', async () => {
mockedAxios.get = jest
.fn()
.mockRejectedValueOnce(new Error('ECONNREFUSED'));
const result = await service.loadModel('np-dms-ai:latest');
expect(result).toBe(false);
});
});
describe('getEmbeddingModelName()', () => {
it('ควรคืน nomic-embed-text เป็น embedding model', () => {
expect(service.getEmbeddingModelName()).toBe('nomic-embed-text');
});
});
describe('generateEmbedding()', () => {
it('ควรคืน embedding vector เมื่อ Ollama ตอบกลับสำเร็จ', async () => {
const mockVector = [0.1, 0.2, 0.3];
mockedAxios.post = jest.fn().mockResolvedValueOnce({
data: { embedding: mockVector },
});
const result = await service.generateEmbedding('test text');
expect(result).toEqual(mockVector);
expect(mockedAxios.post).toHaveBeenCalledWith(
expect.stringContaining('/api/embeddings'),
expect.objectContaining({
model: 'nomic-embed-text',
prompt: 'test text',
}),
expect.anything()
);
});
it('ควร throw error เมื่อ Ollama embedding ล้มเหลว', async () => {
mockedAxios.post = jest
.fn()
.mockRejectedValueOnce(new Error('Embedding failed'));
await expect(service.generateEmbedding('test')).rejects.toThrow(
'Embedding failed'
);
});
});
describe('checkHealth()', () => {
it('ควรคืน HEALTHY พร้อมโมเดลที่โหลดอยู่จาก /api/ps เมื่อ Ollama ตอบกลับสำเร็จ', async () => {
mockedAxios.get = jest
.fn()
.mockResolvedValueOnce({ data: {} }) // /api/tags
.mockResolvedValueOnce({
data: { models: [{ name: 'np-dms-ai:latest' }] },
}); // /api/ps
const result = await service.checkHealth();
expect(result.status).toBe('HEALTHY');
expect(result.models).toContain('np-dms-ai:latest');
expect(result.latencyMs).toBeGreaterThanOrEqual(0);
});
it('ควรคืน HEALTHY พร้อม fallback models เมื่อ /api/ps ไม่มีข้อมูล', async () => {
mockedAxios.get = jest
.fn()
.mockResolvedValueOnce({ data: {} }) // /api/tags OK
.mockResolvedValueOnce({ data: { models: [] } }); // /api/ps empty
const result = await service.checkHealth();
expect(result.status).toBe('HEALTHY');
expect(result.models).toContain('np-dms-ai:latest'); // fallback
});
it('ควรคืน HEALTHY แม้ /api/ps throw error (graceful degradation)', async () => {
mockedAxios.get = jest
.fn()
.mockResolvedValueOnce({ data: {} }) // /api/tags OK
.mockRejectedValueOnce(new Error('ps endpoint error')); // /api/ps fails
const result = await service.checkHealth();
expect(result.status).toBe('HEALTHY');
});
it('ควรคืน DEGRADED เมื่อ /api/tags timeout', async () => {
mockedAxios.get = jest
.fn()
.mockRejectedValueOnce(new Error('timeout error'));
const result = await service.checkHealth();
expect(result.status).toBe('DEGRADED');
expect(result.error).toContain('timeout');
});
it('ควรคืน DEGRADED เมื่อ error message มี code ECONNABORTED', async () => {
mockedAxios.get = jest
.fn()
.mockRejectedValueOnce(new Error('code ECONNABORTED'));
const result = await service.checkHealth();
expect(result.status).toBe('DEGRADED');
});
it('ควรคืน DOWN เมื่อ connection ถูกปฏิเสธ (ไม่ใช่ timeout)', async () => {
mockedAxios.get = jest
.fn()
.mockRejectedValueOnce(new Error('ECONNREFUSED'));
const result = await service.checkHealth();
expect(result.status).toBe('DOWN');
});
});
describe('unloadModel()', () => {
it('ควรคืน true เมื่อ unload สำเร็จ', async () => {
mockedAxios.post = jest.fn().mockResolvedValueOnce({ data: {} });
const result = await service.unloadModel('np-dms-ocr:latest');
expect(result).toBe(true);
expect(mockedAxios.post).toHaveBeenCalledWith(
expect.stringContaining('/api/generate'),
expect.objectContaining({ model: 'np-dms-ocr:latest', keep_alive: 0 }),
expect.anything()
);
});
it('ควรคืน false เมื่อ unload ล้มเหลว', async () => {
mockedAxios.post = jest
.fn()
.mockRejectedValueOnce(new Error('Unload failed'));
const result = await service.unloadModel('np-dms-ocr:latest');
expect(result).toBe(false);
});
});
describe('generate() error path', () => {
it('ควร throw error เมื่อ Ollama generate ล้มเหลว', async () => {
mockedAxios.post = jest
.fn()
.mockRejectedValueOnce(new Error('LLM timeout'));
await expect(service.generate('test prompt')).rejects.toThrow(
'LLM timeout'
);
});
});
});
@@ -0,0 +1,260 @@
// File: src/modules/ai/services/sandbox-ocr-engine.service.spec.ts
// Change Log:
// - 2026-06-14: สร้าง unit tests สำหรับ SandboxOcrEngineService ครอบคลุม detectAndExtract ทุก engine
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
import * as fs from 'fs';
import { SandboxOcrEngineService } from './sandbox-ocr-engine.service';
import { OcrService } from './ocr.service';
jest.mock('axios');
jest.mock('fs');
const mockedAxios = axios as jest.Mocked<typeof axios>;
const mockedFs = fs as jest.Mocked<typeof fs>;
/** OcrService mock สำหรับ tesseract/fast-path */
const mockOcrService = {
detectAndExtract: jest.fn(),
};
/** ConfigService mock */
const mockConfigService = {
get: jest.fn(<T>(key: string, defaultValue?: T): T | undefined => {
const cfg: Record<string, unknown> = {
OCR_API_URL: 'http://localhost:8765',
OCR_SIDECAR_API_KEY: 'test-api-key-2026',
};
return (cfg[key] as T | undefined) ?? defaultValue;
}),
};
describe('SandboxOcrEngineService', () => {
let service: SandboxOcrEngineService;
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
SandboxOcrEngineService,
{ provide: ConfigService, useValue: mockConfigService },
{ provide: OcrService, useValue: mockOcrService },
],
}).compile();
service = module.get<SandboxOcrEngineService>(SandboxOcrEngineService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('detectAndExtract() — engine=auto', () => {
it('ควร route ไปยัง OcrService เมื่อ engine=auto', async () => {
mockOcrService.detectAndExtract.mockResolvedValueOnce({
text: 'auto extracted text',
ocrUsed: true,
});
const result = await service.detectAndExtract('/tmp/file.pdf', 'auto');
expect(result.text).toBe('auto extracted text');
expect(result.engineUsed).toBe('tesseract');
expect(result.fallbackUsed).toBe(false);
expect(mockOcrService.detectAndExtract).toHaveBeenCalledWith({
pdfPath: '/tmp/file.pdf',
});
});
it('ควรใช้ fast-path engineUsed เมื่อ OcrService คืน ocrUsed=false', async () => {
mockOcrService.detectAndExtract.mockResolvedValueOnce({
text: 'embedded text',
ocrUsed: false,
});
const result = await service.detectAndExtract('/tmp/file.pdf', 'auto');
expect(result.engineUsed).toBe('fast-path');
expect(result.fallbackUsed).toBe(false);
});
});
describe('detectAndExtract() — engine=tesseract', () => {
it('ควร route ไปยัง OcrService เมื่อ engine=tesseract', async () => {
mockOcrService.detectAndExtract.mockResolvedValueOnce({
text: 'tesseract text',
ocrUsed: true,
});
const result = await service.detectAndExtract(
'/tmp/file.pdf',
'tesseract'
);
expect(result.engineUsed).toBe('tesseract');
expect(result.fallbackUsed).toBe(false);
});
});
describe('detectAndExtract() — engine=typhoon-np-dms-ocr (legacy alias)', () => {
it('ควรแปลง typhoon-np-dms-ocr เป็น np-dms-ocr และส่งไปยัง sidecar', async () => {
const mockBuffer = Buffer.from('pdf content');
(mockedFs.readFileSync as jest.Mock).mockReturnValueOnce(mockBuffer);
mockedAxios.post = jest.fn().mockResolvedValueOnce({
data: {
text: 'ocr text via alias',
ocrUsed: true,
engineUsed: 'np-dms-ocr',
},
});
const result = await service.detectAndExtract(
'/tmp/file.pdf',
'typhoon-np-dms-ocr'
);
expect(result.text).toBe('ocr text via alias');
expect(result.engineUsed).toBe('np-dms-ocr');
expect(result.fallbackUsed).toBe(false);
});
});
describe('detectAndExtract() — engine=np-dms-ocr (sidecar path)', () => {
it('ควรส่ง file ไปยัง sidecar /ocr-upload สำเร็จ', async () => {
const mockBuffer = Buffer.from('pdf binary data');
(mockedFs.readFileSync as jest.Mock).mockReturnValueOnce(mockBuffer);
mockedAxios.post = jest.fn().mockResolvedValueOnce({
data: {
text: 'extracted from typhoon',
ocrUsed: true,
engineUsed: 'np-dms-ocr',
},
});
const result = await service.detectAndExtract(
'/tmp/doc.pdf',
'np-dms-ocr'
);
expect(result.text).toBe('extracted from typhoon');
expect(result.ocrUsed).toBe(true);
expect(result.engineUsed).toBe('np-dms-ocr');
expect(result.fallbackUsed).toBe(false);
expect(mockedAxios.post).toHaveBeenCalledWith(
expect.stringContaining('/ocr-upload'),
expect.any(FormData),
expect.objectContaining({
headers: expect.objectContaining({
'X-API-Key': 'test-api-key-2026',
}),
})
);
});
it('ควรส่ง typhoonOptions (temperature, topP, repeatPenalty) ไปใน form data', async () => {
const mockBuffer = Buffer.from('pdf data');
(mockedFs.readFileSync as jest.Mock).mockReturnValueOnce(mockBuffer);
mockedAxios.post = jest.fn().mockResolvedValueOnce({
data: { text: 'result', ocrUsed: true, engineUsed: 'np-dms-ocr' },
});
await service.detectAndExtract('/tmp/doc.pdf', 'np-dms-ocr', {
temperature: 0.5,
topP: 0.8,
repeatPenalty: 1.2,
});
expect(mockedAxios.post).toHaveBeenCalled();
});
it('ควรใช้ fallback values เมื่อ sidecar response ไม่มี text/ocrUsed/engineUsed', async () => {
const mockBuffer = Buffer.from('pdf data');
(mockedFs.readFileSync as jest.Mock).mockReturnValueOnce(mockBuffer);
mockedAxios.post = jest.fn().mockResolvedValueOnce({
data: {},
});
const result = await service.detectAndExtract(
'/tmp/doc.pdf',
'np-dms-ocr'
);
expect(result.text).toBe('');
expect(result.ocrUsed).toBe(true);
expect(result.engineUsed).toBe('np-dms-ocr'); // resolvedEngineType fallback
});
it('ควร fallback ไปยัง Tesseract เมื่อ fs.readFileSync ล้มเหลว (outer catch fallback)', async () => {
(mockedFs.readFileSync as jest.Mock).mockImplementationOnce(() => {
throw new Error('ENOENT: file not found');
});
// service จะ catch error และ fallback ไปยัง Tesseract
mockOcrService.detectAndExtract.mockResolvedValueOnce({
text: 'tesseract fallback text',
ocrUsed: true,
});
const result = await service.detectAndExtract(
'/tmp/missing.pdf',
'np-dms-ocr'
);
expect(result.fallbackUsed).toBe(true);
expect(result.engineUsed).toBe('tesseract');
});
it('ควร fallback ไปยัง Tesseract เมื่อ sidecar HTTP error เกิดขึ้น', async () => {
const mockBuffer = Buffer.from('pdf data');
(mockedFs.readFileSync as jest.Mock).mockReturnValueOnce(mockBuffer);
mockedAxios.post = jest.fn().mockRejectedValueOnce(
Object.assign(new Error('Request failed'), {
response: { status: 500, data: { detail: 'Internal Server Error' } },
})
);
mockOcrService.detectAndExtract.mockResolvedValueOnce({
text: 'tesseract fallback result',
ocrUsed: true,
});
const result = await service.detectAndExtract(
'/tmp/doc.pdf',
'np-dms-ocr'
);
expect(result.text).toBe('tesseract fallback result');
expect(result.fallbackUsed).toBe(true);
expect(result.engineUsed).toBe('tesseract');
});
it('ควร fallback ไปยัง fast-path เมื่อ sidecar error และ OcrService ส่ง ocrUsed=false', async () => {
const mockBuffer = Buffer.from('pdf data');
(mockedFs.readFileSync as jest.Mock).mockReturnValueOnce(mockBuffer);
mockedAxios.post = jest
.fn()
.mockRejectedValueOnce(new Error('Connection refused'));
mockOcrService.detectAndExtract.mockResolvedValueOnce({
text: 'embedded text',
ocrUsed: false,
});
const result = await service.detectAndExtract(
'/tmp/doc.pdf',
'np-dms-ocr'
);
expect(result.engineUsed).toBe('fast-path');
expect(result.fallbackUsed).toBe(true);
});
});
describe('detectAndExtract() — default engine (no arg)', () => {
it('ควรใช้ auto เป็น default engine เมื่อไม่ระบุ engineType', async () => {
mockOcrService.detectAndExtract.mockResolvedValueOnce({
text: 'default text',
ocrUsed: false,
});
const result = await service.detectAndExtract('/tmp/file.pdf');
expect(result.fallbackUsed).toBe(false);
});
});
describe('detectAndExtract() — edge cases', () => {
it('ควร handle axios error ที่ไม่มี response.status gracefully', async () => {
const mockBuffer = Buffer.from('pdf data');
(mockedFs.readFileSync as jest.Mock).mockReturnValueOnce(mockBuffer);
mockedAxios.post = jest
.fn()
.mockRejectedValueOnce(new Error('Network unreachable'));
mockOcrService.detectAndExtract.mockResolvedValueOnce({
text: 'fallback text',
ocrUsed: true,
});
const result = await service.detectAndExtract(
'/tmp/doc.pdf',
'np-dms-ocr'
);
expect(result.fallbackUsed).toBe(true);
});
});
});
@@ -0,0 +1,422 @@
// 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
);
});
});
});
@@ -1,6 +1,7 @@
// File: backend/src/modules/ai/tests/vram-monitor.service.spec.ts
// Change Log:
// - 2026-06-11: สร้าง unit tests สำหรับ VramMonitorService (US5)
// - 2026-06-14: เพิ่ม tests สำหรับ getVramStatus และ invalidateCache เพื่อเพิ่ม branch/function coverage
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
@@ -99,4 +100,61 @@ describe('VramMonitorService', () => {
expect(result).toBe(false);
});
});
describe('getVramStatus', () => {
it('ควรคืน status ที่ถูกต้องเมื่อ Ollama คืน models', async () => {
mockedAxios.get
.mockResolvedValueOnce({
// first call: /api/ps ใน getVramStatus
data: {
models: [
{ name: 'np-dms-ai:latest', size_vram: 3 * 1024 * 1024 * 1024 },
],
},
})
.mockResolvedValueOnce({
// second call: /api/ps ใน getVramHeadroom
data: {
models: [
{ name: 'np-dms-ai:latest', size_vram: 3 * 1024 * 1024 * 1024 },
],
},
});
const status = await service.getVramStatus(4000);
expect(status.loadedModels).toContain('np-dms-ai:latest');
expect(status.totalVramMb).toBe(8192);
expect(status.hasCapacity).toBe(true); // 8192MB - 3072MB = 5120MB free > 4000MB required
});
it('ควรคืน hasCapacity=true เมื่อมี VRAM เหลือเพียงพอ', async () => {
mockedAxios.get
.mockResolvedValueOnce({
data: {
models: [
{ name: 'np-dms-ai:latest', size_vram: 1 * 1024 * 1024 * 1024 },
],
},
})
.mockResolvedValueOnce({
data: {
models: [
{ name: 'np-dms-ai:latest', size_vram: 1 * 1024 * 1024 * 1024 },
],
},
});
const status = await service.getVramStatus(4000);
// 8192MB total - 1024MB used = 7168MB free > 4000MB
expect(status.hasCapacity).toBe(true);
});
it('ควรคืน fallback (hasCapacity=false) เมื่อ /api/ps ล้มเหลว', async () => {
mockedAxios.get.mockRejectedValue(new Error('Network error'));
const status = await service.getVramStatus();
expect(status.hasCapacity).toBe(false);
expect(status.freeVramMb).toBe(0);
expect(status.loadedModels).toEqual([]);
});
});
describe('invalidateCache', () => {
it('ควร resolve โดยไม่ throw (no-op)', async () => {
await expect(service.invalidateCache()).resolves.toBeUndefined();
});
});
});