690525:2327 ADR-023-229 dynamic prompt #01
This commit is contained in:
@@ -56,6 +56,8 @@ import { RbacGuard } from '../../common/guards/rbac.guard';
|
||||
import { IntentClassifierModule } from './intent-classifier/intent-classifier.module';
|
||||
import { AiToolModule } from './tool/ai-tool.module';
|
||||
import { CleanupTempFilesWorker } from './workers/cleanup-temp-files.worker';
|
||||
import { AiPromptsModule } from './prompts/ai-prompts.module';
|
||||
import { AiPrompt } from './prompts/ai-prompts.entity';
|
||||
import {
|
||||
QUEUE_AI_BATCH,
|
||||
QUEUE_AI_INGEST,
|
||||
@@ -81,6 +83,7 @@ import {
|
||||
CorrespondenceType,
|
||||
ImportTransaction,
|
||||
MigrationReviewQueue,
|
||||
AiPrompt,
|
||||
]),
|
||||
|
||||
BullModule.registerQueue(
|
||||
@@ -130,6 +133,8 @@ import {
|
||||
IntentClassifierModule,
|
||||
// ADR-025: AI Tool Layer (Tool Registry + CASL-enforced Tool Services)
|
||||
AiToolModule,
|
||||
// ADR-029: Dynamic Prompt Management for OCR Extraction
|
||||
AiPromptsModule,
|
||||
],
|
||||
controllers: [AiController],
|
||||
providers: [
|
||||
|
||||
@@ -19,6 +19,7 @@ import { Project } from '../../project/entities/project.entity';
|
||||
import { AiAuditLog } from '../entities/ai-audit-log.entity';
|
||||
import { TagsService } from '../../tags/tags.service';
|
||||
import { MigrationService } from '../../migration/migration.service';
|
||||
import { AiPromptsService } from '../prompts/ai-prompts.service';
|
||||
|
||||
describe('AiBatchProcessor', () => {
|
||||
let processor: AiBatchProcessor;
|
||||
@@ -90,6 +91,13 @@ describe('AiBatchProcessor', () => {
|
||||
createError: jest.fn().mockResolvedValue(undefined),
|
||||
enqueueRecord: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const mockAiPromptsService = {
|
||||
resolveActive: jest.fn().mockResolvedValue({
|
||||
resolvedPrompt: 'Resolved test prompt with OCR text',
|
||||
versionNumber: 2,
|
||||
}),
|
||||
saveTestResult: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
@@ -113,6 +121,7 @@ describe('AiBatchProcessor', () => {
|
||||
},
|
||||
{ provide: TagsService, useValue: mockTagsService },
|
||||
{ provide: MigrationService, useValue: mockMigrationService },
|
||||
{ provide: AiPromptsService, useValue: mockAiPromptsService },
|
||||
],
|
||||
}).compile();
|
||||
processor = module.get<AiBatchProcessor>(AiBatchProcessor);
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
// - 2026-05-21: พัฒนาระบบประมวลผล sandbox-extract พร้อมเชื่อมต่อ OcrService, OllamaService และ Redis cache
|
||||
// - 2026-05-21: แก้ไข ESLint unused variable สำหรับ parseError ใน catch block
|
||||
// - 2026-05-22: แก้ไข type compilation error ใน processMigrateDocument และนำช่องว่างภายในฟังก์ชันออก
|
||||
// - 2026-05-25: เชื่อมต่อ AiPromptsService และเปิดใช้งาน Dynamic Prompt สำหรับ OCR extraction ใน sandbox และ migration pipeline
|
||||
|
||||
import { Processor, WorkerHost } from '@nestjs/bullmq';
|
||||
import { Logger } from '@nestjs/common';
|
||||
@@ -25,11 +26,13 @@ import { AiAuditLog, AiAuditStatus } from '../entities/ai-audit-log.entity';
|
||||
import { TagsService } from '../../tags/tags.service';
|
||||
import { MigrationService } from '../../migration/migration.service';
|
||||
import { MigrationErrorType } from '../../migration/entities/migration-error.entity';
|
||||
import { AiPromptsService } from '../prompts/ai-prompts.service';
|
||||
|
||||
interface MigrateDocumentMetadata extends Record<string, unknown> {
|
||||
documentNumber?: string;
|
||||
subject?: string;
|
||||
category?: string;
|
||||
discipline?: string;
|
||||
date?: string;
|
||||
confidence?: number;
|
||||
tags?: string[];
|
||||
@@ -80,6 +83,7 @@ const parseMigrateDocumentMetadata = (
|
||||
documentNumber: readString(source.documentNumber),
|
||||
subject: readString(source.subject),
|
||||
category: readString(source.category),
|
||||
discipline: readString(source.discipline),
|
||||
date: readString(source.date),
|
||||
confidence:
|
||||
typeof source.confidence === 'number' ? source.confidence : undefined,
|
||||
@@ -107,6 +111,7 @@ export class AiBatchProcessor extends WorkerHost {
|
||||
private readonly ollamaService: OllamaService,
|
||||
private readonly tagsService: TagsService,
|
||||
private readonly migrationService: MigrationService,
|
||||
private readonly aiPromptsService: AiPromptsService,
|
||||
@InjectRedis() private readonly redis: Redis
|
||||
) {
|
||||
super();
|
||||
@@ -252,28 +257,14 @@ export class AiBatchProcessor extends WorkerHost {
|
||||
);
|
||||
try {
|
||||
const ocrResult = await this.ocrService.detectAndExtract({ pdfPath });
|
||||
const prompt = `You are an expert document extraction system.
|
||||
Analyze the following OCR text extracted from a project document and extract the metadata fields.
|
||||
|
||||
OCR TEXT:
|
||||
${ocrResult.text}
|
||||
|
||||
Extract these fields:
|
||||
1. documentNumber: The official document number or code. If not found, return null.
|
||||
2. subject: The main subject, title, or topic of the document. If not found, return null.
|
||||
3. discipline: Must be exactly one of: "Civil", "Mechanical", "Electrical", "Architectural", or null if not specified.
|
||||
4. date: The issue date in YYYY-MM-DD format. If not found, return null.
|
||||
5. confidence: A float between 0.0 and 1.0 indicating your confidence in this extraction.
|
||||
|
||||
Return ONLY a valid JSON object matching this schema. Do NOT include markdown code blocks, HTML, or any conversational text. Example:
|
||||
{
|
||||
"documentNumber": "LCBP3-CIV-001",
|
||||
"subject": "Foundation Inspection Report",
|
||||
"discipline": "Civil",
|
||||
"date": "2026-05-20",
|
||||
"confidence": 0.95
|
||||
}`;
|
||||
const response = await this.ollamaService.generate(prompt);
|
||||
const { resolvedPrompt, versionNumber } =
|
||||
await this.aiPromptsService.resolveActive(
|
||||
'ocr_extraction',
|
||||
ocrResult.text
|
||||
);
|
||||
const response = await this.ollamaService.generate(resolvedPrompt, {
|
||||
timeoutMs: 120000,
|
||||
});
|
||||
const cleanedResponse = response
|
||||
.replace(/```json/g, '')
|
||||
.replace(/```/g, '')
|
||||
@@ -289,6 +280,11 @@ Return ONLY a valid JSON object matching this schema. Do NOT include markdown co
|
||||
`Failed to parse LLM response as JSON: ${cleanedResponse}`
|
||||
);
|
||||
}
|
||||
await this.aiPromptsService.saveTestResult(
|
||||
'ocr_extraction',
|
||||
versionNumber,
|
||||
extractedMetadata
|
||||
);
|
||||
await this.redis.setex(
|
||||
`ai:rag:result:${idempotencyKey}`,
|
||||
3600,
|
||||
@@ -296,6 +292,7 @@ Return ONLY a valid JSON object matching this schema. Do NOT include markdown co
|
||||
requestPublicId: idempotencyKey,
|
||||
status: 'completed',
|
||||
answer: JSON.stringify(extractedMetadata, null, 2),
|
||||
promptVersionUsed: versionNumber,
|
||||
completedAt: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
@@ -357,33 +354,13 @@ Return ONLY a valid JSON object matching this schema. Do NOT include markdown co
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
const prompt = `You are a professional document intelligence engine.
|
||||
Analyze the following OCR text extracted from a legacy project document and extract the metadata fields.
|
||||
OCR TEXT:
|
||||
${ocrResult.text}
|
||||
Extract these fields:
|
||||
1. documentNumber: The official document number or code. If not found, return null.
|
||||
2. subject: The main subject, title, or topic of the document. If not found, return null.
|
||||
3. discipline: Must be exactly one of: "Civil", "Mechanical", "Electrical", "Architectural", or null if not specified.
|
||||
4. category: Must be exactly one of: "Correspondence", "Transmittal", "Circulation", "RFA", "Shop Drawing", "Contract Drawing", or null if not specified.
|
||||
5. date: The issue/document date in YYYY-MM-DD format. If not found, return null.
|
||||
6. confidence: A float between 0.0 and 1.0 indicating your confidence in this extraction.
|
||||
7. tags: An array of tags/keywords (strings) that describe the document.
|
||||
8. summary: A short 1-2 sentence summary of the document contents.
|
||||
Return ONLY a valid JSON object matching this schema. Do NOT include markdown code blocks, HTML, or any conversational text. Example:
|
||||
{
|
||||
"documentNumber": "LCBP3-CIV-001",
|
||||
"subject": "Foundation Inspection Report",
|
||||
"discipline": "Civil",
|
||||
"category": "Correspondence",
|
||||
"date": "2026-05-20",
|
||||
"confidence": 0.95,
|
||||
"tags": ["foundation", "inspection", "concrete"],
|
||||
"summary": "This document is a foundation inspection report for the LCBP3 project, confirming concrete strength."
|
||||
}`;
|
||||
const { resolvedPrompt } = await this.aiPromptsService.resolveActive(
|
||||
'ocr_extraction',
|
||||
ocrResult.text
|
||||
);
|
||||
let aiResponse: string;
|
||||
try {
|
||||
aiResponse = await this.ollamaService.generate(prompt);
|
||||
aiResponse = await this.ollamaService.generate(resolvedPrompt);
|
||||
} catch (err: unknown) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(`การวิเคราะห์ของ AI ล้มเหลว: ${errMsg}`);
|
||||
@@ -395,7 +372,7 @@ Return ONLY a valid JSON object matching this schema. Do NOT include markdown co
|
||||
});
|
||||
await this.saveAiAuditLog({
|
||||
documentPublicId,
|
||||
aiModel: await this.ollamaService.getMainModelName(),
|
||||
aiModel: this.ollamaService.getMainModelName(),
|
||||
status: AiAuditStatus.FAILED,
|
||||
errorMessage: errMsg,
|
||||
processingTimeMs: Date.now() - startTime,
|
||||
@@ -421,7 +398,7 @@ Return ONLY a valid JSON object matching this schema. Do NOT include markdown co
|
||||
});
|
||||
await this.saveAiAuditLog({
|
||||
documentPublicId,
|
||||
aiModel: await this.ollamaService.getMainModelName(),
|
||||
aiModel: this.ollamaService.getMainModelName(),
|
||||
status: AiAuditStatus.FAILED,
|
||||
errorMessage: errMsg,
|
||||
processingTimeMs: Date.now() - startTime,
|
||||
@@ -463,10 +440,13 @@ Return ONLY a valid JSON object matching this schema. Do NOT include markdown co
|
||||
isValid,
|
||||
confidence,
|
||||
aiJobId: String(job.id),
|
||||
details: {
|
||||
discipline: extractedMetadata.discipline,
|
||||
},
|
||||
});
|
||||
await this.saveAiAuditLog({
|
||||
documentPublicId,
|
||||
aiModel: await this.ollamaService.getMainModelName(),
|
||||
aiModel: this.ollamaService.getMainModelName(),
|
||||
status: AiAuditStatus.SUCCESS,
|
||||
aiSuggestionJson: extractedMetadata,
|
||||
confidenceScore: confidence,
|
||||
|
||||
@@ -114,7 +114,7 @@ export class AiRealtimeProcessor extends WorkerHost {
|
||||
this.aiAuditLogRepo.create({
|
||||
documentPublicId: job.data.documentPublicId,
|
||||
aiModel: 'gemma4',
|
||||
modelName: await this.ollamaService.getMainModelName(),
|
||||
modelName: this.ollamaService.getMainModelName(),
|
||||
aiSuggestionJson: normalizedSuggestion,
|
||||
confidenceScore: this.extractConfidence(normalizedSuggestion),
|
||||
processingTimeMs: Date.now() - startTime,
|
||||
@@ -136,7 +136,7 @@ export class AiRealtimeProcessor extends WorkerHost {
|
||||
this.aiAuditLogRepo.create({
|
||||
documentPublicId: job.data.documentPublicId,
|
||||
aiModel: 'gemma4',
|
||||
modelName: await this.ollamaService.getMainModelName(),
|
||||
modelName: this.ollamaService.getMainModelName(),
|
||||
processingTimeMs: Date.now() - startTime,
|
||||
status: AiAuditStatus.FAILED,
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// File: backend/src/modules/ai/prompts/ai-prompts.controller.ts
|
||||
// Change Log
|
||||
// - 2026-05-25: Created AiPromptsController for dynamic prompt management (ADR-029)
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Delete,
|
||||
Patch,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
ParseIntPipe,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiBearerAuth,
|
||||
ApiParam,
|
||||
} from '@nestjs/swagger';
|
||||
import { AiPromptsService } from './ai-prompts.service';
|
||||
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 { plainToInstance } from 'class-transformer';
|
||||
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
|
||||
import { RbacGuard } from '../../../common/guards/rbac.guard';
|
||||
import { RequirePermission } from '../../../common/decorators/require-permission.decorator';
|
||||
import { Audit } from '../../../common/decorators/audit.decorator';
|
||||
import { CurrentUser } from '../../../common/decorators/current-user.decorator';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
|
||||
/**
|
||||
* Controller สำหรับจัดการ Prompt Versions ของ AI OCR (ADR-029)
|
||||
*/
|
||||
@ApiTags('AI Prompts')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, RbacGuard)
|
||||
@Controller('ai/prompts')
|
||||
export class AiPromptsController {
|
||||
constructor(private readonly promptsService: AiPromptsService) {}
|
||||
|
||||
private mapToDto(prompt: AiPrompt): AiPromptResponseDto {
|
||||
return plainToInstance(AiPromptResponseDto, prompt, {
|
||||
excludeExtraneousValues: true,
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':promptType')
|
||||
@RequirePermission('system.manage_all')
|
||||
@ApiOperation({
|
||||
summary: 'ดึงรายการ Prompt Versions ทั้งหมดสำหรับ prompt_type ที่กำหนด',
|
||||
})
|
||||
@ApiParam({ name: 'promptType', example: 'ocr_extraction' })
|
||||
async listPromptVersions(
|
||||
@Param('promptType') promptType: string
|
||||
): Promise<{ data: AiPromptResponseDto[] }> {
|
||||
const list = await this.promptsService.findAll(promptType);
|
||||
return { data: list.map((p) => this.mapToDto(p)) };
|
||||
}
|
||||
|
||||
@Post(':promptType')
|
||||
@RequirePermission('system.manage_all')
|
||||
@Audit('ai_prompt.create', 'AiPrompt')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({
|
||||
summary: 'สร้าง Prompt Version ใหม่ (เริ่มต้นเป็น inactive)',
|
||||
})
|
||||
@ApiParam({ name: 'promptType', example: 'ocr_extraction' })
|
||||
async createPromptVersion(
|
||||
@Param('promptType') promptType: string,
|
||||
@Body() dto: CreateAiPromptDto,
|
||||
@CurrentUser() user: User
|
||||
): Promise<{ data: AiPromptResponseDto }> {
|
||||
const newPrompt = await this.promptsService.create(
|
||||
promptType,
|
||||
dto,
|
||||
user.user_id
|
||||
);
|
||||
return { data: this.mapToDto(newPrompt) };
|
||||
}
|
||||
|
||||
@Delete(':promptType/:versionNumber')
|
||||
@RequirePermission('system.manage_all')
|
||||
@Audit('ai_prompt.delete', 'AiPrompt')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'ลบ Prompt Version (ห้ามลบ active version)' })
|
||||
@ApiParam({ name: 'promptType', example: 'ocr_extraction' })
|
||||
@ApiParam({ name: 'versionNumber', type: Number })
|
||||
async deletePromptVersion(
|
||||
@Param('promptType') promptType: string,
|
||||
@Param('versionNumber', ParseIntPipe) versionNumber: number,
|
||||
@CurrentUser() user: User
|
||||
): Promise<void> {
|
||||
await this.promptsService.delete(promptType, versionNumber, user.user_id);
|
||||
}
|
||||
|
||||
@Post(':promptType/:versionNumber/activate')
|
||||
@RequirePermission('system.manage_all')
|
||||
@Audit('ai_prompt.activate', 'AiPrompt')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'เปิดใช้งาน Prompt Version' })
|
||||
@ApiParam({ name: 'promptType', example: 'ocr_extraction' })
|
||||
@ApiParam({ name: 'versionNumber', type: Number })
|
||||
async activatePromptVersion(
|
||||
@Param('promptType') promptType: string,
|
||||
@Param('versionNumber', ParseIntPipe) versionNumber: number,
|
||||
@CurrentUser() user: User
|
||||
): Promise<{ data: AiPromptResponseDto }> {
|
||||
const activated = await this.promptsService.activate(
|
||||
promptType,
|
||||
versionNumber,
|
||||
user.user_id
|
||||
);
|
||||
return { data: this.mapToDto(activated) };
|
||||
}
|
||||
|
||||
@Patch(':promptType/:versionNumber/note')
|
||||
@RequirePermission('system.manage_all')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'บันทึก Manual Note สำหรับ Prompt Version' })
|
||||
@ApiParam({ name: 'promptType', example: 'ocr_extraction' })
|
||||
@ApiParam({ name: 'versionNumber', type: Number })
|
||||
async updatePromptNote(
|
||||
@Param('promptType') promptType: string,
|
||||
@Param('versionNumber', ParseIntPipe) versionNumber: number,
|
||||
@Body() dto: UpdatePromptNoteDto
|
||||
): Promise<{ data: AiPromptResponseDto }> {
|
||||
const updated = await this.promptsService.updateNote(
|
||||
promptType,
|
||||
versionNumber,
|
||||
dto.manualNote
|
||||
);
|
||||
return { data: this.mapToDto(updated) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// File: backend/src/modules/ai/prompts/ai-prompts.entity.ts
|
||||
// Change Log
|
||||
// - 2026-05-25: Created TypeORM entity for dynamic prompt management (ADR-029)
|
||||
// - 2026-05-25: Added definite assignment assertion operator (!) to satisfy strictPropertyInitialization
|
||||
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { Exclude } from 'class-transformer';
|
||||
|
||||
/**
|
||||
* Entity สำหรับเก็บข้อมูลประวัติและการตั้งค่า Prompt version ต่างๆ
|
||||
* สำหรับการสกัดข้อมูลเอกสารผ่าน OCR และ LLM
|
||||
*/
|
||||
@Entity('ai_prompts')
|
||||
export class AiPrompt {
|
||||
@PrimaryGeneratedColumn()
|
||||
@Exclude() // ADR-019: INT PK ไม่ expose ใน API
|
||||
id!: number;
|
||||
|
||||
@Column({ name: 'prompt_type', length: 50 })
|
||||
promptType!: string;
|
||||
|
||||
@Column({ name: 'version_number' })
|
||||
versionNumber!: number;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
template!: string;
|
||||
|
||||
@Column({ name: 'field_schema', type: 'json', nullable: true })
|
||||
fieldSchema!: Record<string, unknown> | null;
|
||||
|
||||
@Column({ name: 'is_active', type: 'tinyint', width: 1, default: 0 })
|
||||
isActive!: boolean;
|
||||
|
||||
@Column({ name: 'test_result_json', type: 'json', nullable: true })
|
||||
testResultJson!: Record<string, unknown> | null;
|
||||
|
||||
@Column({ name: 'manual_note', type: 'text', nullable: true })
|
||||
manualNote!: string | null;
|
||||
|
||||
@Column({ name: 'last_tested_at', type: 'timestamp', nullable: true })
|
||||
lastTestedAt!: Date | null;
|
||||
|
||||
@Column({ name: 'activated_at', type: 'timestamp', nullable: true })
|
||||
activatedAt!: Date | null;
|
||||
|
||||
@Column({ name: 'created_by' })
|
||||
@Exclude() // FK ไม่ expose โดยตรง
|
||||
createdBy!: number;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// File: backend/src/modules/ai/prompts/ai-prompts.module.ts
|
||||
// Change Log
|
||||
// - 2026-05-25: Created AiPromptsModule for prompt versioning system (ADR-029)
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AiPrompt } from './ai-prompts.entity';
|
||||
import { AuditLog } from '../../../common/entities/audit-log.entity';
|
||||
import { AiPromptsService } from './ai-prompts.service';
|
||||
import { AiPromptsController } from './ai-prompts.controller';
|
||||
|
||||
/**
|
||||
* Module สำหรับการจัดการเวอร์ชันของ AI Prompts ใน OCR Pipeline
|
||||
*/
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([AiPrompt, AuditLog])],
|
||||
controllers: [AiPromptsController],
|
||||
providers: [AiPromptsService],
|
||||
exports: [AiPromptsService],
|
||||
})
|
||||
export class AiPromptsModule {}
|
||||
@@ -0,0 +1,216 @@
|
||||
// File: backend/src/modules/ai/prompts/ai-prompts.service.spec.ts
|
||||
// Change Log
|
||||
// - 2026-05-25: Created unit tests for AiPromptsService (T028)
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { AiPromptsService } from './ai-prompts.service';
|
||||
import { AiPrompt } from './ai-prompts.entity';
|
||||
import { AuditLog } from '../../../common/entities/audit-log.entity';
|
||||
import {
|
||||
BusinessException,
|
||||
ValidationException,
|
||||
NotFoundException,
|
||||
} from '../../../common/exceptions';
|
||||
|
||||
describe('AiPromptsService', () => {
|
||||
let service: AiPromptsService;
|
||||
const mockAiPromptRepo = {
|
||||
find: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
};
|
||||
const mockAuditLogRepo = {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
};
|
||||
const mockRedis = {
|
||||
get: jest.fn(),
|
||||
setex: jest.fn(),
|
||||
del: jest.fn(),
|
||||
};
|
||||
const mockQueryBuilder = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
setLock: jest.fn().mockReturnThis(),
|
||||
getRawOne: jest.fn(),
|
||||
};
|
||||
const mockQueryRunner = {
|
||||
connect: jest.fn(),
|
||||
startTransaction: jest.fn(),
|
||||
commitTransaction: jest.fn(),
|
||||
rollbackTransaction: jest.fn(),
|
||||
release: jest.fn(),
|
||||
manager: {
|
||||
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder),
|
||||
findOne: jest.fn(),
|
||||
find: jest.fn(),
|
||||
update: jest.fn(),
|
||||
save: jest.fn(),
|
||||
},
|
||||
};
|
||||
const mockDataSource = {
|
||||
createQueryRunner: jest.fn().mockReturnValue(mockQueryRunner),
|
||||
};
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AiPromptsService,
|
||||
{
|
||||
provide: getRepositoryToken(AiPrompt),
|
||||
useValue: mockAiPromptRepo,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(AuditLog),
|
||||
useValue: mockAuditLogRepo,
|
||||
},
|
||||
{
|
||||
provide: 'default_IORedisModuleConnectionToken',
|
||||
useValue: mockRedis,
|
||||
},
|
||||
{
|
||||
provide: DataSource,
|
||||
useValue: mockDataSource,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
service = module.get<AiPromptsService>(AiPromptsService);
|
||||
});
|
||||
describe('create', () => {
|
||||
it('ควรปฏิเสธ template ที่ไม่มี {{ocr_text}} placeholder', async () => {
|
||||
await expect(
|
||||
service.create(
|
||||
'ocr_extraction',
|
||||
{ template: 'Invalid prompt structure' },
|
||||
1
|
||||
)
|
||||
).rejects.toThrow(ValidationException);
|
||||
});
|
||||
it('ควรปฏิเสธ template ที่ตัวอักษรเกิน 4,000 ตัว', async () => {
|
||||
const longTemplate = 'a'.repeat(4005) + '{{ocr_text}}';
|
||||
await expect(
|
||||
service.create('ocr_extraction', { template: longTemplate }, 1)
|
||||
).rejects.toThrow(ValidationException);
|
||||
});
|
||||
it('ควรบันทึกสำเร็จและรัน version number ต่อเนื่อง', async () => {
|
||||
mockQueryBuilder.getRawOne.mockResolvedValue({ max: 5 });
|
||||
mockAiPromptRepo.create.mockReturnValue({
|
||||
id: 12,
|
||||
promptType: 'ocr_extraction',
|
||||
versionNumber: 6,
|
||||
template: 'Test {{ocr_text}}',
|
||||
isActive: false,
|
||||
});
|
||||
mockQueryRunner.manager.save.mockResolvedValue({
|
||||
id: 12,
|
||||
promptType: 'ocr_extraction',
|
||||
versionNumber: 6,
|
||||
template: 'Test {{ocr_text}}',
|
||||
isActive: false,
|
||||
});
|
||||
const result = await service.create(
|
||||
'ocr_extraction',
|
||||
{ template: 'Test {{ocr_text}}' },
|
||||
1
|
||||
);
|
||||
expect(result.versionNumber).toBe(6);
|
||||
expect(mockQueryRunner.manager.save).toHaveBeenCalled();
|
||||
expect(mockAuditLogRepo.save).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
describe('activate', () => {
|
||||
it('ควร activate สำเร็จ ยกเลิกตัวอื่น และลบ cache', async () => {
|
||||
const activePrompt = {
|
||||
id: 1,
|
||||
promptType: 'ocr_extraction',
|
||||
versionNumber: 1,
|
||||
isActive: true,
|
||||
};
|
||||
const targetPrompt = {
|
||||
id: 2,
|
||||
promptType: 'ocr_extraction',
|
||||
versionNumber: 2,
|
||||
isActive: false,
|
||||
};
|
||||
mockQueryRunner.manager.findOne.mockResolvedValue(targetPrompt);
|
||||
mockQueryRunner.manager.find.mockResolvedValue([activePrompt]);
|
||||
mockQueryRunner.manager.save.mockResolvedValue({
|
||||
...targetPrompt,
|
||||
isActive: true,
|
||||
});
|
||||
const result = await service.activate('ocr_extraction', 2, 1);
|
||||
expect(result.isActive).toBe(true);
|
||||
expect(mockQueryRunner.manager.update).toHaveBeenCalledWith(
|
||||
AiPrompt,
|
||||
{ promptType: 'ocr_extraction', isActive: true },
|
||||
{ isActive: false }
|
||||
);
|
||||
expect(mockRedis.del).toHaveBeenCalledWith(
|
||||
'ai:prompt:active:ocr_extraction'
|
||||
);
|
||||
expect(mockAuditLogRepo.save).toHaveBeenCalled();
|
||||
});
|
||||
it('ควร throw error เมื่อไม่พบ prompt version ที่ต้องการ activate', async () => {
|
||||
mockQueryRunner.manager.findOne.mockResolvedValue(null);
|
||||
await expect(service.activate('ocr_extraction', 99, 1)).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('delete', () => {
|
||||
it('ควร throw error เมื่อลบ active version', async () => {
|
||||
mockAiPromptRepo.findOne.mockResolvedValue({
|
||||
id: 1,
|
||||
promptType: 'ocr_extraction',
|
||||
versionNumber: 1,
|
||||
isActive: true,
|
||||
});
|
||||
await expect(service.delete('ocr_extraction', 1, 1)).rejects.toThrow(
|
||||
BusinessException
|
||||
);
|
||||
});
|
||||
it('ควรลบ inactive version สำเร็จและบันทึก audit log', async () => {
|
||||
const inactivePrompt = {
|
||||
id: 2,
|
||||
promptType: 'ocr_extraction',
|
||||
versionNumber: 2,
|
||||
isActive: false,
|
||||
};
|
||||
mockAiPromptRepo.findOne.mockResolvedValue(inactivePrompt);
|
||||
await service.delete('ocr_extraction', 2, 1);
|
||||
expect(mockAiPromptRepo.remove).toHaveBeenCalledWith(inactivePrompt);
|
||||
expect(mockAuditLogRepo.save).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
describe('getActive', () => {
|
||||
it('ควรดึงจาก Redis cache เมื่อมี cache hit', async () => {
|
||||
const cachedPrompt = {
|
||||
id: 1,
|
||||
promptType: 'ocr_extraction',
|
||||
versionNumber: 1,
|
||||
isActive: true,
|
||||
};
|
||||
mockRedis.get.mockResolvedValue(JSON.stringify(cachedPrompt));
|
||||
const result = await service.getActive('ocr_extraction');
|
||||
expect(result).toEqual(cachedPrompt);
|
||||
expect(mockAiPromptRepo.findOne).not.toHaveBeenCalled();
|
||||
});
|
||||
it('ควร fallback ไปหา DB เมื่อ Redis มีปัญหา', async () => {
|
||||
const dbPrompt = {
|
||||
id: 1,
|
||||
promptType: 'ocr_extraction',
|
||||
versionNumber: 1,
|
||||
isActive: true,
|
||||
};
|
||||
mockRedis.get.mockRejectedValue(new Error('Redis connection lost'));
|
||||
mockAiPromptRepo.findOne.mockResolvedValue(dbPrompt);
|
||||
const result = await service.getActive('ocr_extraction');
|
||||
expect(result).toEqual(dbPrompt);
|
||||
expect(mockAiPromptRepo.findOne).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,294 @@
|
||||
// File: backend/src/modules/ai/prompts/ai-prompts.service.ts
|
||||
// Change Log
|
||||
// - 2026-05-25: Created AiPromptsService for dynamic prompt management (ADR-029)
|
||||
// - 2026-05-25: Fixed BusinessException and NotFoundException constructor signatures
|
||||
// - 2026-05-25: Cast getRawOne() to resolve TypeScript type assertion error in ESLint
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
import { InjectRedis } from '@nestjs-modules/ioredis';
|
||||
import Redis from 'ioredis';
|
||||
import { AiPrompt } from './ai-prompts.entity';
|
||||
import { AuditLog } from '../../../common/entities/audit-log.entity';
|
||||
import { CreateAiPromptDto } from './dto/create-ai-prompt.dto';
|
||||
import {
|
||||
BusinessException,
|
||||
ValidationException,
|
||||
NotFoundException,
|
||||
} from '../../../common/exceptions';
|
||||
|
||||
/**
|
||||
* Service สำหรับจัดการ Prompt Versioning และการดึงข้อมูล Prompt ล่าสุดที่พร้อมใช้งาน
|
||||
*/
|
||||
@Injectable()
|
||||
export class AiPromptsService {
|
||||
private readonly logger = new Logger(AiPromptsService.name);
|
||||
private readonly cachePrefix = 'ai:prompt:active:';
|
||||
|
||||
constructor(
|
||||
@InjectRepository(AiPrompt)
|
||||
private readonly aiPromptRepo: Repository<AiPrompt>,
|
||||
@InjectRepository(AuditLog)
|
||||
private readonly auditLogRepo: Repository<AuditLog>,
|
||||
@InjectRedis()
|
||||
private readonly redis: Redis,
|
||||
private readonly dataSource: DataSource
|
||||
) {}
|
||||
|
||||
/**
|
||||
* ดึงรายการเวอร์ชันทั้งหมดของ prompt_type ที่กำหนด
|
||||
*/
|
||||
async findAll(promptType: string): Promise<AiPrompt[]> {
|
||||
return this.aiPromptRepo.find({
|
||||
where: { promptType },
|
||||
order: { versionNumber: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ดึง Active prompt จาก Redis cache หรือ DB fallback
|
||||
*/
|
||||
async getActive(promptType: string): Promise<AiPrompt | null> {
|
||||
const cacheKey = `${this.cachePrefix}${promptType}`;
|
||||
try {
|
||||
const cached = await this.redis.get(cacheKey);
|
||||
if (cached) {
|
||||
return JSON.parse(cached) as AiPrompt;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
this.logger.warn(
|
||||
`Redis unavailable, falling back to DB query: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
const prompt = await this.aiPromptRepo.findOne({
|
||||
where: { promptType, isActive: true },
|
||||
});
|
||||
if (prompt) {
|
||||
try {
|
||||
await this.redis.setex(cacheKey, 60, JSON.stringify(prompt));
|
||||
} catch (err: unknown) {
|
||||
this.logger.warn(
|
||||
`Failed to set Redis cache: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
return prompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* ค้นหา prompt ที่มีผลใช้งานจริง และแทนที่ placeholder {{ocr_text}} ด้วยข้อความ OCR
|
||||
*/
|
||||
async resolveActive(
|
||||
promptType: string,
|
||||
ocrText: string
|
||||
): Promise<{ resolvedPrompt: string; versionNumber: number }> {
|
||||
const prompt = await this.getActive(promptType);
|
||||
if (!prompt) {
|
||||
throw new BusinessException(
|
||||
'NO_ACTIVE_PROMPT',
|
||||
`No active prompt found for type: ${promptType}`,
|
||||
'ไม่พบ Prompt Version ที่เปิดใช้งานในระบบ'
|
||||
);
|
||||
}
|
||||
const resolvedPrompt = prompt.template.replace('{{ocr_text}}', ocrText);
|
||||
return { resolvedPrompt, versionNumber: prompt.versionNumber };
|
||||
}
|
||||
|
||||
/**
|
||||
* สร้าง Prompt Version ใหม่พร้อมการตรวจสอบ placeholder และ character limit
|
||||
*/
|
||||
async create(
|
||||
promptType: string,
|
||||
dto: CreateAiPromptDto,
|
||||
userId: number
|
||||
): Promise<AiPrompt> {
|
||||
if (!dto.template.includes('{{ocr_text}}')) {
|
||||
throw new ValidationException('template ต้องมี {{ocr_text}} placeholder');
|
||||
}
|
||||
if (dto.template.length > 4000) {
|
||||
throw new ValidationException('Template exceeds 4,000 character limit');
|
||||
}
|
||||
const queryRunner = this.dataSource.createQueryRunner();
|
||||
await queryRunner.connect();
|
||||
await queryRunner.startTransaction();
|
||||
try {
|
||||
const maxVersionResult = await queryRunner.manager
|
||||
.createQueryBuilder(AiPrompt, 'prompt')
|
||||
.select('MAX(prompt.versionNumber)', 'max')
|
||||
.where('prompt.promptType = :promptType', { promptType })
|
||||
.setLock('pessimistic_write')
|
||||
.getRawOne<{ max: number | string | null }>();
|
||||
const nextVersion =
|
||||
(maxVersionResult?.max ? Number(maxVersionResult.max) : 0) + 1;
|
||||
const newPrompt = this.aiPromptRepo.create({
|
||||
promptType,
|
||||
versionNumber: nextVersion,
|
||||
template: dto.template,
|
||||
isActive: false,
|
||||
createdBy: userId,
|
||||
});
|
||||
const savedPrompt = await queryRunner.manager.save(newPrompt);
|
||||
await queryRunner.commitTransaction();
|
||||
await this.saveAuditLog(
|
||||
'AI_PROMPT_CREATED',
|
||||
String(savedPrompt.id),
|
||||
{ promptType, versionNumber: nextVersion, userId },
|
||||
userId
|
||||
);
|
||||
return savedPrompt;
|
||||
} catch (err) {
|
||||
await queryRunner.rollbackTransaction();
|
||||
throw err;
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* เปิดใช้งานเวอร์ชันที่กำหนด และยกเลิกการใช้งานเวอร์ชันอื่นทั้งหมดภายใต้ prompt_type เดียวกัน
|
||||
*/
|
||||
async activate(
|
||||
promptType: string,
|
||||
versionNumber: number,
|
||||
userId: number
|
||||
): Promise<AiPrompt> {
|
||||
const queryRunner = this.dataSource.createQueryRunner();
|
||||
await queryRunner.connect();
|
||||
await queryRunner.startTransaction();
|
||||
try {
|
||||
const promptToActivate = await queryRunner.manager.findOne(AiPrompt, {
|
||||
where: { promptType, versionNumber },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
if (!promptToActivate) {
|
||||
throw new NotFoundException('AiPrompt', versionNumber.toString());
|
||||
}
|
||||
await queryRunner.manager.find(AiPrompt, {
|
||||
where: { promptType, isActive: true },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
await queryRunner.manager.update(
|
||||
AiPrompt,
|
||||
{ promptType, isActive: true },
|
||||
{ isActive: false }
|
||||
);
|
||||
promptToActivate.isActive = true;
|
||||
promptToActivate.activatedAt = new Date();
|
||||
const activatedPrompt = await queryRunner.manager.save(promptToActivate);
|
||||
await queryRunner.commitTransaction();
|
||||
try {
|
||||
const cacheKey = `${this.cachePrefix}${promptType}`;
|
||||
await this.redis.del(cacheKey);
|
||||
} catch (err: unknown) {
|
||||
this.logger.warn(
|
||||
`Failed to clear Redis cache after activation: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
await this.saveAuditLog(
|
||||
'AI_PROMPT_ACTIVATED',
|
||||
String(activatedPrompt.id),
|
||||
{ promptType, versionNumber, userId },
|
||||
userId
|
||||
);
|
||||
return activatedPrompt;
|
||||
} catch (err) {
|
||||
await queryRunner.rollbackTransaction();
|
||||
throw err;
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ลบเวอร์ชันที่ไม่ได้ใช้งาน (ห้ามลบเวอร์ชันที่เป็น active)
|
||||
*/
|
||||
async delete(
|
||||
promptType: string,
|
||||
versionNumber: number,
|
||||
userId: number
|
||||
): Promise<void> {
|
||||
const prompt = await this.aiPromptRepo.findOne({
|
||||
where: { promptType, versionNumber },
|
||||
});
|
||||
if (!prompt) {
|
||||
throw new NotFoundException('AiPrompt', versionNumber.toString());
|
||||
}
|
||||
if (prompt.isActive) {
|
||||
throw new BusinessException(
|
||||
'CANNOT_DELETE_ACTIVE_PROMPT',
|
||||
'Cannot delete active prompt version',
|
||||
'ไม่สามารถลบ active version ได้'
|
||||
);
|
||||
}
|
||||
await this.aiPromptRepo.remove(prompt);
|
||||
await this.saveAuditLog(
|
||||
'AI_PROMPT_DELETED',
|
||||
String(prompt.id),
|
||||
{ promptType, versionNumber, userId },
|
||||
userId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* อัปเดต manual note ของเวอร์ชันที่กำหนด
|
||||
*/
|
||||
async updateNote(
|
||||
promptType: string,
|
||||
versionNumber: number,
|
||||
note: string | null
|
||||
): Promise<AiPrompt> {
|
||||
const prompt = await this.aiPromptRepo.findOne({
|
||||
where: { promptType, versionNumber },
|
||||
});
|
||||
if (!prompt) {
|
||||
throw new NotFoundException('AiPrompt', versionNumber.toString());
|
||||
}
|
||||
prompt.manualNote = note;
|
||||
return this.aiPromptRepo.save(prompt);
|
||||
}
|
||||
|
||||
/**
|
||||
* บันทึกผลทดสอบของเวอร์ชันหลังจากรัน OCR Sandbox
|
||||
*/
|
||||
async saveTestResult(
|
||||
promptType: string,
|
||||
versionNumber: number,
|
||||
resultJson: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
const prompt = await this.aiPromptRepo.findOne({
|
||||
where: { promptType, versionNumber },
|
||||
});
|
||||
if (prompt) {
|
||||
prompt.testResultJson = resultJson;
|
||||
prompt.lastTestedAt = new Date();
|
||||
await this.aiPromptRepo.save(prompt);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* บันทึกข้อมูลการปฏิบัติการของผู้ใช้ลงในตารางหลัก audit_logs
|
||||
*/
|
||||
private async saveAuditLog(
|
||||
action: string,
|
||||
entityId: string,
|
||||
detailsJson: Record<string, unknown>,
|
||||
userId?: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
const auditLog = this.auditLogRepo.create({
|
||||
action,
|
||||
severity: 'INFO',
|
||||
entityType: 'AiPrompt',
|
||||
entityId,
|
||||
detailsJson,
|
||||
userId,
|
||||
});
|
||||
await this.auditLogRepo.save(auditLog);
|
||||
} catch (err: unknown) {
|
||||
this.logger.error(
|
||||
`Failed to save audit log: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// File: backend/src/modules/ai/prompts/dto/ai-prompt-response.dto.ts
|
||||
// Change Log
|
||||
// - 2026-05-25: Created AiPromptResponseDto to exclude internal INT PK and expose clean API fields (ADR-029)
|
||||
// - 2026-05-25: Added definite assignment assertion operator (!) to satisfy strictPropertyInitialization
|
||||
|
||||
import { Expose } from 'class-transformer';
|
||||
|
||||
/**
|
||||
* Data Transfer Object สำหรับส่งออกข้อมูล Prompt version ทาง API
|
||||
* โดยคัดกรองเฉพาะข้อมูลภายนอกและปิดบัง PK ดั้งเดิมตามนโยบายความปลอดภัย
|
||||
*/
|
||||
export class AiPromptResponseDto {
|
||||
@Expose()
|
||||
promptType!: string;
|
||||
|
||||
@Expose()
|
||||
versionNumber!: number;
|
||||
|
||||
@Expose()
|
||||
template!: string;
|
||||
|
||||
@Expose()
|
||||
isActive!: boolean;
|
||||
|
||||
@Expose()
|
||||
testResultJson!: Record<string, unknown> | null;
|
||||
|
||||
@Expose()
|
||||
manualNote!: string | null;
|
||||
|
||||
@Expose()
|
||||
lastTestedAt!: Date | null;
|
||||
|
||||
@Expose()
|
||||
activatedAt!: Date | null;
|
||||
|
||||
@Expose()
|
||||
createdAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// File: backend/src/modules/ai/prompts/dto/create-ai-prompt.dto.ts
|
||||
// Change Log
|
||||
// - 2026-05-25: Created CreateAiPromptDto for prompt version creation (ADR-029)
|
||||
// - 2026-05-25: Added definite assignment assertion operator (!) to satisfy strictPropertyInitialization
|
||||
|
||||
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
/**
|
||||
* Data Transfer Object สำหรับการสร้าง prompt version ใหม่
|
||||
*/
|
||||
export class CreateAiPromptDto {
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'Template text must not be empty' })
|
||||
@MaxLength(4000, { message: 'Template exceeds 4,000 character limit' })
|
||||
template!: string;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// File: backend/src/modules/ai/prompts/dto/update-prompt-note.dto.ts
|
||||
// Change Log
|
||||
// - 2026-05-25: Created UpdatePromptNoteDto for annotation updates (ADR-029)
|
||||
// - 2026-05-25: Added definite assignment assertion operator (!) to satisfy strictPropertyInitialization
|
||||
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
|
||||
/**
|
||||
* Data Transfer Object สำหรับอัปเดต manual note ของ prompt version
|
||||
*/
|
||||
export class UpdatePromptNoteDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
manualNote!: string | null;
|
||||
}
|
||||
@@ -1,154 +1,176 @@
|
||||
// File: src/modules/ai/services/ollama.service.ts
|
||||
// Change Log
|
||||
// - 2026-05-15: เพิ่ม Ollama service สำหรับ ADR-023A 2-model stack.
|
||||
// - 2026-05-21: เพิ่ม checkHealth สำหรับตรวจสอบสุขภาพและความเร็ว (Latency) ของ Ollama
|
||||
// - 2026-05-25: เพิ่มการใช้งานโมเดลจาก DB (AiSettingsService) แทน ENV เท่านั้น (ADR-027).
|
||||
|
||||
import { Injectable, Logger, Optional } from '@nestjs/common';
|
||||
// Change Log
|
||||
|
||||
// - 2026-05-15: เพิ่ม Ollama service สำหรับ ADR-023A 2-model stack.
|
||||
|
||||
// - 2026-05-21: เพิ่ม checkHealth สำหรับตรวจสอบสุขภาพและความเร็ว (Latency) ของ Ollama
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
import axios from 'axios';
|
||||
import { AiSettingsService } from '../ai-settings.service';
|
||||
|
||||
export interface OllamaGenerateOptions {
|
||||
timeoutMs?: number;
|
||||
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/** บริการเรียก Ollama local-only บน Admin Desktop ตาม ADR-023A */
|
||||
|
||||
@Injectable()
|
||||
export class OllamaService {
|
||||
private readonly logger = new Logger(OllamaService.name);
|
||||
|
||||
private readonly ollamaUrl: string;
|
||||
private readonly defaultMainModel: string;
|
||||
|
||||
private readonly mainModel: string;
|
||||
|
||||
private readonly embedModel: string;
|
||||
|
||||
private readonly timeoutMs: number;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
@Optional()
|
||||
private readonly aiSettingsService?: AiSettingsService
|
||||
) {
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.ollamaUrl = this.configService.get<string>(
|
||||
'OLLAMA_URL',
|
||||
|
||||
this.configService.get<string>('AI_HOST_URL', 'http://localhost:11434')
|
||||
);
|
||||
// Default fallback model (ADR-023A: gemma4:e2b)
|
||||
this.defaultMainModel = this.configService.get<string>(
|
||||
|
||||
this.mainModel = this.configService.get<string>(
|
||||
'OLLAMA_MODEL_MAIN',
|
||||
'gemma4:e2b'
|
||||
|
||||
'gemma4:e4b'
|
||||
);
|
||||
|
||||
this.embedModel = this.configService.get<string>(
|
||||
'OLLAMA_MODEL_EMBED',
|
||||
|
||||
this.configService.get<string>('OLLAMA_EMBED_MODEL', 'nomic-embed-text')
|
||||
);
|
||||
|
||||
this.timeoutMs = this.configService.get<number>('AI_TIMEOUT_MS', 30000);
|
||||
}
|
||||
|
||||
/** ดึงชื่อโมเดลที่ใช้งานอยู่ (จาก DB หรือ ENV fallback) */
|
||||
private async getActiveModelName(): Promise<string> {
|
||||
if (this.aiSettingsService) {
|
||||
try {
|
||||
return await this.aiSettingsService.getActiveModel();
|
||||
} catch (err: unknown) {
|
||||
this.logger.warn(
|
||||
`Failed to get active model from DB: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
return this.defaultMainModel;
|
||||
}
|
||||
/** สร้างข้อความตอบกลับจาก gemma4:e4b หรือค่า ENV ที่กำหนด */
|
||||
|
||||
/** สร้างข้อความตอบกลับจากโมเดลที่กำหนด (DB หรือ ENV fallback) */
|
||||
async generate(
|
||||
prompt: string,
|
||||
|
||||
options: OllamaGenerateOptions = {}
|
||||
): Promise<string> {
|
||||
const modelName = await this.getActiveModelName();
|
||||
try {
|
||||
const response = await axios.post<{ response: string }>(
|
||||
`${this.ollamaUrl}/api/generate`,
|
||||
|
||||
{
|
||||
model: modelName,
|
||||
model: this.mainModel,
|
||||
|
||||
prompt,
|
||||
|
||||
stream: false,
|
||||
},
|
||||
|
||||
{
|
||||
timeout: options.timeoutMs ?? this.timeoutMs,
|
||||
|
||||
signal: options.signal,
|
||||
}
|
||||
);
|
||||
|
||||
return response.data.response ?? '';
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Ollama generate failed with model ${modelName}`,
|
||||
'Ollama generate failed',
|
||||
|
||||
err instanceof Error ? err.stack : String(err)
|
||||
);
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** สร้าง embedding ด้วย nomic-embed-text หรือค่า ENV ที่กำหนด */
|
||||
|
||||
async generateEmbedding(text: string): Promise<number[]> {
|
||||
try {
|
||||
const response = await axios.post<{ embedding: number[] }>(
|
||||
`${this.ollamaUrl}/api/embeddings`,
|
||||
|
||||
{ model: this.embedModel, prompt: text },
|
||||
|
||||
{ timeout: this.timeoutMs }
|
||||
);
|
||||
|
||||
return response.data.embedding;
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
'Ollama embedding failed',
|
||||
|
||||
err instanceof Error ? err.stack : String(err)
|
||||
);
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** คืนชื่อ main model สำหรับ audit log (async เพราะต้องเช็ค DB) */
|
||||
async getMainModelName(): Promise<string> {
|
||||
return this.getActiveModelName();
|
||||
}
|
||||
/** คืนชื่อ main model สำหรับ audit log */
|
||||
|
||||
/** คืนชื่อ main model แบบ sync (fallback สำหรับกรณีที่ไม่ต้องการ async) */
|
||||
getMainModelNameSync(): string {
|
||||
return this.defaultMainModel;
|
||||
getMainModelName(): string {
|
||||
return this.mainModel;
|
||||
}
|
||||
|
||||
/** คืนชื่อ embedding model สำหรับ audit log */
|
||||
|
||||
getEmbeddingModelName(): string {
|
||||
return this.embedModel;
|
||||
}
|
||||
|
||||
/** ตรวจสอบสุขภาพและความเร็ว (Latency) ของระบบ Ollama */
|
||||
|
||||
async checkHealth(): Promise<{
|
||||
status: 'HEALTHY' | 'DEGRADED' | 'DOWN';
|
||||
|
||||
latencyMs: number;
|
||||
|
||||
models: string[];
|
||||
|
||||
error?: string;
|
||||
}> {
|
||||
const startTime = Date.now();
|
||||
const activeModel = await this.getActiveModelName();
|
||||
|
||||
try {
|
||||
await axios.get(`${this.ollamaUrl}/api/tags`, { timeout: 5000 });
|
||||
|
||||
const latencyMs = Date.now() - startTime;
|
||||
|
||||
return {
|
||||
status: 'HEALTHY',
|
||||
|
||||
latencyMs,
|
||||
models: [activeModel, this.embedModel],
|
||||
|
||||
models: [this.mainModel, this.embedModel],
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
const latencyMs = Date.now() - startTime;
|
||||
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
|
||||
const isTimeout =
|
||||
err instanceof Error &&
|
||||
(err.message.includes('timeout') ||
|
||||
err.message.includes('504') ||
|
||||
err.message.includes('code ECONNABORTED'));
|
||||
|
||||
return {
|
||||
status: isTimeout ? 'DEGRADED' : 'DOWN',
|
||||
|
||||
latencyMs,
|
||||
models: [activeModel, this.embedModel],
|
||||
|
||||
models: [this.mainModel, this.embedModel],
|
||||
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user