690525:2327 ADR-023-229 dynamic prompt #01

This commit is contained in:
2026-05-25 23:27:33 +07:00
parent 1139e54086
commit 82a0444013
29 changed files with 2468 additions and 770 deletions
+5
View File
@@ -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,
};
}