feat(ai): implement unified prompt management UX/UI (ADR-037)
- 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:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user