690519:1631 224 to 226 AI #01
CI / CD Pipeline / build (push) Failing after 3m57s
CI / CD Pipeline / deploy (push) Has been skipped

This commit is contained in:
2026-05-19 16:31:50 +07:00
parent 3e25097470
commit ea5499123e
127 changed files with 12387 additions and 42 deletions
+39 -1
View File
@@ -2,6 +2,7 @@
// Change Log
// - 2026-05-14: เพิ่ม Legacy Migration staging endpoints ตาม ADR-023.
// - 2026-05-14: ย้าย DeleteAuditLogsQueryDto ไป dto/ folder; ลบ authHeader passthrough (🟢 LOW-1/LOW-2).
// - 2026-05-19: เพิ่ม POST /ai/intent endpoint สำหรับ AI Tool Layer (ADR-025).
// Controller สำหรับ AI Gateway Endpoints (ADR-023)
import {
@@ -59,6 +60,8 @@ import { User } from '../user/entities/user.entity';
import { ServiceAccountGuard } from './guards/service-account.guard';
import { v7 as uuidv7 } from 'uuid';
import { DeleteAuditLogsQueryDto } from './dto/delete-audit-logs.dto';
import { AiToolRegistryService } from './tool/ai-tool-registry.service';
import { AiIntentRequestDto } from './dto/ai-intent-request.dto';
@ApiTags('AI Gateway')
@Controller('ai')
@@ -67,11 +70,46 @@ export class AiController {
private readonly aiService: AiService,
private readonly aiIngestService: AiIngestService,
private readonly aiRagService: AiRagService,
private readonly aiQueueService: AiQueueService
private readonly aiQueueService: AiQueueService,
private readonly aiToolRegistryService: AiToolRegistryService
) {}
// --- Real-time Extraction (User Upload) ---
// ─── AI Tool Layer Endpoint (ADR-025) ──────────────────────────────────────
@Post('intent')
@UseGuards(JwtAuthGuard, RbacGuard)
@ApiBearerAuth()
@RequirePermission('ai.suggest')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'AI Intent Dispatch — ส่ง Intent ไปยัง Tool Registry (ADR-025)',
description:
'รับ intent code + projectPublicId แล้ว dispatch ไปยัง Tool Handler ที่ตรงกัน พร้อม CASL enforcement',
})
async dispatchIntent(
@Body() dto: AiIntentRequestDto,
@CurrentUser() user: User
): Promise<{
ok: boolean;
data?: unknown;
reason?: string;
message?: string;
}> {
const result = await this.aiToolRegistryService.dispatch(dto.intent, {
requestUser: user,
projectPublicId: dto.projectPublicId,
params: dto.params,
});
if (result.ok) {
return { ok: true, data: result.data };
}
return { ok: false, reason: result.reason, message: result.message };
}
// ---------------------------------------------------------------------------
@Post('suggest')
@UseGuards(JwtAuthGuard, RbacGuard)
@ApiBearerAuth()
+9
View File
@@ -2,6 +2,8 @@
// Change Log
// - 2026-05-14: เพิ่ม BullMQ/Qdrant/Service Account foundation สำหรับ ADR-023.
// - 2026-05-15: เพิ่ม ai-realtime/ai-batch foundation และ stale paused recovery ตาม ADR-023A.
// - 2026-05-19: เพิ่ม IntentClassifierModule (ADR-024 Intent Classification).
// - 2026-05-19: เพิ่ม AiToolModule (ADR-025 AI Tool Layer).
// Module สำหรับ AI Gateway — ลงทะเบียน Services และ Controllers (ADR-023)
import { Logger, Module, OnModuleInit } from '@nestjs/common';
@@ -37,6 +39,8 @@ import { Project } from '../project/entities/project.entity';
import { Organization } from '../organization/entities/organization.entity';
import { CorrespondenceType } from '../correspondence/entities/correspondence-type.entity';
import { RbacGuard } from '../../common/guards/rbac.guard';
import { IntentClassifierModule } from './intent-classifier/intent-classifier.module';
import { AiToolModule } from './tool/ai-tool.module';
import {
QUEUE_AI_BATCH,
QUEUE_AI_INGEST,
@@ -97,6 +101,11 @@ import {
MigrationModule,
FileStorageModule,
AuditLogModule,
// ADR-024: Intent Classification (Hybrid Pattern → LLM Fallback)
IntentClassifierModule,
// ADR-025: AI Tool Layer (Tool Registry + CASL-enforced Tool Services)
AiToolModule,
],
controllers: [AiController],
providers: [
@@ -0,0 +1,36 @@
// File: src/modules/ai/dto/ai-intent-request.dto.ts
// Change Log
// - 2026-05-19: สร้าง DTO สำหรับ POST /ai/intent endpoint (ADR-025).
import { IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
/**
* Request body สำหรับ POST /ai/intent
* ส่ง intent code + project context ไปยัง AiToolRegistryService
*/
export class AiIntentRequestDto {
@ApiProperty({
description:
'Intent code เช่น GET_RFA, GET_DRAWING, GET_TRANSMITTAL (ADR-025)',
example: 'GET_RFA',
})
@IsNotEmpty()
@IsString()
intent!: string;
@ApiProperty({
description: 'UUID ของ Project (ADR-019) — จำเป็นสำหรับ CASL scope',
example: '0195a1b2-c3d4-7000-8000-abc123def456',
})
@IsNotEmpty()
@IsUUID()
projectPublicId!: string;
@ApiPropertyOptional({
description: 'Parameters เพิ่มเติม เช่น { statusCode: "DFT" }',
example: { statusCode: 'FAP' },
})
@IsOptional()
params?: Record<string, unknown>;
}
@@ -0,0 +1,215 @@
// File: src/modules/ai/intent-classifier/controllers/intent-admin.controller.spec.ts
// Change Log
// - 2026-05-19: สร้าง Integration test สำหรับ Admin API (T016, US1).
import { Test, TestingModule } from '@nestjs/testing';
import { Reflector } from '@nestjs/core';
import {
IntentAdminController,
IntentPatternAdminController,
} from './intent-admin.controller';
import { IntentDefinitionService } from '../services/intent-definition.service';
import { IntentPatternService } from '../services/intent-pattern.service';
import { IntentCategory } from '../interfaces/intent-category.enum';
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../../../common/guards/rbac.guard';
/** Guard stub ที่ allow ทุก request */
const mockGuard = { canActivate: () => true };
describe('IntentAdminController', () => {
let controller: IntentAdminController;
let definitionService: jest.Mocked<IntentDefinitionService>;
let patternService: jest.Mocked<IntentPatternService>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [IntentAdminController],
providers: [
{
provide: IntentDefinitionService,
useValue: {
findAll: jest.fn().mockResolvedValue([]),
findByCode: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
},
{
provide: IntentPatternService,
useValue: {
findByIntentCode: jest.fn().mockResolvedValue([]),
create: jest.fn(),
},
},
Reflector,
],
})
.overrideGuard(JwtAuthGuard)
.useValue(mockGuard)
.overrideGuard(RbacGuard)
.useValue(mockGuard)
.compile();
controller = module.get<IntentAdminController>(IntentAdminController);
definitionService = module.get(IntentDefinitionService);
patternService = module.get(IntentPatternService);
});
describe('findAll', () => {
it('ควรเรียก service.findAll พร้อม filter', async () => {
await controller.findAll('read', 'true');
expect(definitionService.findAll).toHaveBeenCalledWith({
category: 'read',
isActive: true,
});
});
it('ควรเรียก service.findAll โดยไม่มี filter', async () => {
await controller.findAll();
expect(definitionService.findAll).toHaveBeenCalledWith({
category: undefined,
isActive: undefined,
});
});
});
describe('findOne', () => {
it('ควรเรียก service.findByCode', async () => {
definitionService.findByCode.mockResolvedValue({
intentCode: 'GET_RFA',
} as never);
const result = await controller.findOne('GET_RFA');
expect(definitionService.findByCode).toHaveBeenCalledWith('GET_RFA');
expect(result).toEqual({ intentCode: 'GET_RFA' });
});
});
describe('create', () => {
it('ควรเรียก service.create ด้วย dto', async () => {
const dto = {
intentCode: 'TEST',
descriptionTh: 'ทดสอบ',
descriptionEn: 'Test',
category: IntentCategory.UTILITY,
};
definitionService.create.mockResolvedValue({
...dto,
publicId: 'uuid-1',
} as never);
await controller.create(dto);
expect(definitionService.create).toHaveBeenCalledWith(dto);
});
});
describe('update', () => {
it('ควรเรียก service.update ด้วย intentCode + dto', async () => {
definitionService.update.mockResolvedValue({
intentCode: 'GET_RFA',
descriptionTh: 'อัปเดต',
} as never);
await controller.update('GET_RFA', { descriptionTh: 'อัปเดต' });
expect(definitionService.update).toHaveBeenCalledWith('GET_RFA', {
descriptionTh: 'อัปเดต',
});
});
});
describe('findPatterns', () => {
it('ควรเรียก patternService.findByIntentCode', async () => {
await controller.findPatterns('GET_RFA');
expect(patternService.findByIntentCode).toHaveBeenCalledWith('GET_RFA');
});
});
describe('createPattern', () => {
it('ควร merge intentCode กับ dto', async () => {
const dto = { patternType: 'keyword' as const, patternValue: 'rfa' };
patternService.create.mockResolvedValue({ publicId: 'p-1' } as never);
await controller.createPattern('GET_RFA', dto);
expect(patternService.create).toHaveBeenCalledWith({
intentCode: 'GET_RFA',
...dto,
});
});
});
});
describe('IntentPatternAdminController', () => {
let controller: IntentPatternAdminController;
let patternService: jest.Mocked<IntentPatternService>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [IntentPatternAdminController],
providers: [
{
provide: IntentPatternService,
useValue: {
findByPublicId: jest.fn(),
update: jest.fn(),
remove: jest.fn().mockResolvedValue(undefined),
},
},
Reflector,
],
})
.overrideGuard(JwtAuthGuard)
.useValue(mockGuard)
.overrideGuard(RbacGuard)
.useValue(mockGuard)
.compile();
controller = module.get<IntentPatternAdminController>(
IntentPatternAdminController
);
patternService = module.get(IntentPatternService);
});
describe('findOne', () => {
it('ควรเรียก service.findByPublicId', async () => {
patternService.findByPublicId.mockResolvedValue({
publicId: 'p-1',
} as never);
const result = await controller.findOne('p-1');
expect(patternService.findByPublicId).toHaveBeenCalledWith('p-1');
expect(result).toEqual({ publicId: 'p-1' });
});
});
describe('update', () => {
it('ควรเรียก service.update', async () => {
patternService.update.mockResolvedValue({ publicId: 'p-1' } as never);
await controller.update('p-1', { patternValue: 'new' });
expect(patternService.update).toHaveBeenCalledWith('p-1', {
patternValue: 'new',
});
});
});
describe('remove', () => {
it('ควรเรียก service.remove', async () => {
await controller.remove('p-1');
expect(patternService.remove).toHaveBeenCalledWith('p-1');
});
});
});
@@ -0,0 +1,143 @@
// File: src/modules/ai/intent-classifier/controllers/intent-admin.controller.ts
// Change Log
// - 2026-05-19: สร้าง Admin Controller สำหรับจัดการ Intent Definitions/Patterns (ADR-024).
import {
Controller,
Get,
Post,
Patch,
Delete,
Param,
Body,
Query,
HttpCode,
HttpStatus,
UseGuards,
Logger,
} from '@nestjs/common';
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../../../common/guards/rbac.guard';
import { Audit } from '../../../../common/decorators/audit.decorator';
import { IntentDefinitionService } from '../services/intent-definition.service';
import { IntentPatternService } from '../services/intent-pattern.service';
import { CreateIntentDefinitionDto } from '../dto/create-intent-definition.dto';
import { UpdateIntentDefinitionDto } from '../dto/update-intent-definition.dto';
import { CreateIntentPatternDto } from '../dto/create-intent-pattern.dto';
import { UpdateIntentPatternDto } from '../dto/update-intent-pattern.dto';
import { IntentCategory } from '../interfaces/intent-category.enum';
/**
* Admin Controller สำหรับจัดการ Intent Definitions และ Patterns
* Route prefix: /admin/ai/intent-definitions
* Protected by JwtAuthGuard + RbacGuard (system admin only)
*/
@Controller('admin/ai/intent-definitions')
@UseGuards(JwtAuthGuard, RbacGuard)
export class IntentAdminController {
private readonly logger = new Logger(IntentAdminController.name);
constructor(
private readonly definitionService: IntentDefinitionService,
private readonly patternService: IntentPatternService
) {}
// ===== Intent Definitions =====
/** GET /admin/ai/intent-definitions — ดึงรายการ Intent Definitions */
@Get()
async findAll(
@Query('category') category?: IntentCategory,
@Query('isActive') isActive?: string
) {
const filter = {
category,
isActive: isActive === undefined ? undefined : isActive === 'true',
};
const data = await this.definitionService.findAll(filter);
return { data };
}
/** GET /admin/ai/intent-definitions/:intentCode — ดึงตาม intentCode */
@Get(':intentCode')
async findOne(@Param('intentCode') intentCode: string) {
return this.definitionService.findByCode(intentCode);
}
/** POST /admin/ai/intent-definitions — สร้าง Intent Definition ใหม่ */
@Post()
@HttpCode(HttpStatus.CREATED)
@Audit('intent-definition.create', 'IntentDefinition')
async create(@Body() dto: CreateIntentDefinitionDto) {
return this.definitionService.create(dto);
}
/** PATCH /admin/ai/intent-definitions/:intentCode — อัปเดต */
@Patch(':intentCode')
@Audit('intent-definition.update', 'IntentDefinition')
async update(
@Param('intentCode') intentCode: string,
@Body() dto: UpdateIntentDefinitionDto
) {
return this.definitionService.update(intentCode, dto);
}
// ===== Intent Patterns =====
/** GET /admin/ai/intent-definitions/:intentCode/patterns — ดึง Patterns */
@Get(':intentCode/patterns')
async findPatterns(@Param('intentCode') intentCode: string) {
const data = await this.patternService.findByIntentCode(intentCode);
return { data };
}
/** POST /admin/ai/intent-definitions/:intentCode/patterns — สร้าง Pattern */
@Post(':intentCode/patterns')
@HttpCode(HttpStatus.CREATED)
@Audit('intent-pattern.create', 'IntentPattern')
async createPattern(
@Param('intentCode') intentCode: string,
@Body() dto: CreateIntentPatternDto
) {
return this.patternService.create({
intentCode,
...dto,
});
}
}
/**
* Admin Controller สำหรับจัดการ Pattern โดย publicId
* Route prefix: /admin/ai/intent-patterns
*/
@Controller('admin/ai/intent-patterns')
@UseGuards(JwtAuthGuard, RbacGuard)
export class IntentPatternAdminController {
private readonly logger = new Logger(IntentPatternAdminController.name);
constructor(private readonly patternService: IntentPatternService) {}
/** GET /admin/ai/intent-patterns/:publicId — ดึง Pattern ตาม publicId */
@Get(':publicId')
async findOne(@Param('publicId') publicId: string) {
return this.patternService.findByPublicId(publicId);
}
/** PATCH /admin/ai/intent-patterns/:publicId — อัปเดต Pattern */
@Patch(':publicId')
@Audit('intent-pattern.update', 'IntentPattern')
async update(
@Param('publicId') publicId: string,
@Body() dto: UpdateIntentPatternDto
) {
return this.patternService.update(publicId, dto);
}
/** DELETE /admin/ai/intent-patterns/:publicId — Soft delete Pattern */
@Delete(':publicId')
@HttpCode(HttpStatus.NO_CONTENT)
@Audit('intent-pattern.delete', 'IntentPattern')
async remove(@Param('publicId') publicId: string) {
await this.patternService.remove(publicId);
}
}
@@ -0,0 +1,36 @@
// File: src/modules/ai/intent-classifier/controllers/intent-analytics.controller.ts
// Change Log
// - 2026-05-19: สร้าง Analytics Controller สำหรับ Intent Classification (T035, US3).
import { Controller, Get, Query, UseGuards, Logger } from '@nestjs/common';
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../../../common/guards/rbac.guard';
import { IntentAnalyticsService } from '../services/intent-analytics.service';
/**
* Analytics Controller สำหรับ Intent Classification
* Route prefix: /admin/ai/intent-analytics
* Protected by JwtAuthGuard + RbacGuard (system admin only)
*/
@Controller('admin/ai/intent-analytics')
@UseGuards(JwtAuthGuard, RbacGuard)
export class IntentAnalyticsController {
private readonly logger = new Logger(IntentAnalyticsController.name);
constructor(private readonly analyticsService: IntentAnalyticsService) {}
/**
* GET /admin/ai/intent-analytics
* ดึงสถิติ Classification ทั้งหมด
* @param from ISO date string (optional, default: 30 วันก่อน)
* @param to ISO date string (optional, default: ปัจจุบัน)
*/
@Get()
async getAnalytics(@Query('from') from?: string, @Query('to') to?: string) {
const fromDate = from ? new Date(from) : undefined;
const toDate = to ? new Date(to) : undefined;
const data = await this.analyticsService.getAnalytics(fromDate, toDate);
return { data };
}
}
@@ -0,0 +1,107 @@
// File: src/modules/ai/intent-classifier/controllers/intent-classify.controller.spec.ts
// Change Log
// - 2026-05-19: สร้าง Integration test สำหรับ Classification API (T026, US2).
import { Test, TestingModule } from '@nestjs/testing';
import { Reflector } from '@nestjs/core';
import { IntentClassifyController } from './intent-classify.controller';
import { IntentClassifierService } from '../services/intent-classifier.service';
import { ClassificationResult } from '../interfaces/classification-result.interface';
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
/** Guard stub ที่ allow ทุก request */
const mockGuard = { canActivate: () => true };
describe('IntentClassifyController', () => {
let controller: IntentClassifyController;
let classifierService: jest.Mocked<IntentClassifierService>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [IntentClassifyController],
providers: [
{
provide: IntentClassifierService,
useValue: {
classify: jest.fn(),
},
},
Reflector,
],
})
.overrideGuard(JwtAuthGuard)
.useValue(mockGuard)
.compile();
controller = module.get<IntentClassifyController>(IntentClassifyController);
classifierService = module.get(IntentClassifierService);
});
describe('classify', () => {
it('ควรเรียก service.classify ด้วย trimmed query', async () => {
const mockResult: ClassificationResult = {
intentCode: 'SUMMARIZE_DOCUMENT',
confidence: 1.0,
method: 'pattern',
latencyMs: 3,
};
classifierService.classify.mockResolvedValue(mockResult);
const result = await controller.classify({
query: ' สรุปเอกสาร ',
projectPublicId: undefined,
userPublicId: undefined,
currentDocumentId: undefined,
});
expect(classifierService.classify).toHaveBeenCalledWith({
query: 'สรุปเอกสาร',
projectPublicId: undefined,
userPublicId: undefined,
currentDocumentId: undefined,
});
expect(result.intentCode).toBe('SUMMARIZE_DOCUMENT');
expect(result.method).toBe('pattern');
});
it('ควรส่ง context parameters ไปด้วย', async () => {
const mockResult: ClassificationResult = {
intentCode: 'GET_RFA',
confidence: 0.9,
method: 'llm_fallback',
latencyMs: 500,
};
classifierService.classify.mockResolvedValue(mockResult);
await controller.classify({
query: 'show rfa',
projectPublicId: 'proj-uuid-123',
userPublicId: 'user-uuid-456',
currentDocumentId: 'doc-uuid-789',
});
expect(classifierService.classify).toHaveBeenCalledWith({
query: 'show rfa',
projectPublicId: 'proj-uuid-123',
userPublicId: 'user-uuid-456',
currentDocumentId: 'doc-uuid-789',
});
});
it('ควร return ClassificationResult', async () => {
const mockResult: ClassificationResult = {
intentCode: 'FALLBACK',
confidence: 0,
method: 'semaphore_overflow',
latencyMs: 1,
};
classifierService.classify.mockResolvedValue(mockResult);
const result = await controller.classify({
query: 'test',
});
expect(result).toEqual(mockResult);
});
});
});
@@ -0,0 +1,36 @@
// File: src/modules/ai/intent-classifier/controllers/intent-classify.controller.ts
// Change Log
// - 2026-05-19: สร้าง Classification Controller (POST /ai/intent/classify) (ADR-024).
import { Controller, Post, Body, UseGuards, Logger } from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
import { IntentClassifierService } from '../services/intent-classifier.service';
import { ClassifyQueryDto } from '../dto/classify-query.dto';
import { ClassificationResult } from '../interfaces/classification-result.interface';
/**
* Controller สำหรับ Intent Classification API
* Route: POST /ai/intent/classify
* Protected by JWT (ทุก authenticated user ใช้ได้)
*/
@Controller('ai/intent')
@UseGuards(JwtAuthGuard)
export class IntentClassifyController {
private readonly logger = new Logger(IntentClassifyController.name);
constructor(private readonly classifierService: IntentClassifierService) {}
/** POST /ai/intent/classify — Classify user query → intent */
@Throttle({ default: { limit: 30, ttl: 60000 } })
@Post('classify')
async classify(@Body() dto: ClassifyQueryDto): Promise<ClassificationResult> {
this.logger.debug(`Classifying: "${dto.query}"`);
return this.classifierService.classify({
query: dto.query.trim(),
projectPublicId: dto.projectPublicId,
userPublicId: dto.userPublicId,
currentDocumentId: dto.currentDocumentId,
});
}
}
@@ -0,0 +1,31 @@
// File: src/modules/ai/intent-classifier/dto/classify-query.dto.ts
// Change Log
// - 2026-05-19: สร้าง DTO สำหรับ Classify Query (ADR-024).
import { IsString, IsOptional, MaxLength, IsUUID } from 'class-validator';
/**
* DTO สำหรับ classify intent จาก user query
* ใช้กับ POST /ai/intent/classify
*/
export class ClassifyQueryDto {
/** คำถามจาก user (trim แล้ว, max 200 chars) */
@IsString()
@MaxLength(200)
query!: string;
/** Context project UUID (optional) */
@IsOptional()
@IsUUID()
projectPublicId?: string;
/** Context user UUID (optional) */
@IsOptional()
@IsUUID()
userPublicId?: string;
/** Document ที่เปิดอยู่ UUID (optional) */
@IsOptional()
@IsUUID()
currentDocumentId?: string;
}
@@ -0,0 +1,34 @@
// File: src/modules/ai/intent-classifier/dto/create-intent-definition.dto.ts
// Change Log
// - 2026-05-19: สร้าง DTO สำหรับสร้าง Intent Definition (ADR-024).
import { IsString, IsEnum, MaxLength, Matches } from 'class-validator';
import { IntentCategory } from '../interfaces/intent-category.enum';
/**
* DTO สำหรับสร้าง Intent Definition
* ใช้กับ POST /admin/ai/intent-definitions
*/
export class CreateIntentDefinitionDto {
/** Intent code — UPPERCASE_SNAKE_CASE เท่านั้น */
@IsString()
@MaxLength(50)
@Matches(/^[A-Z][A-Z0-9_]*$/, {
message: 'intentCode must be UPPERCASE_SNAKE_CASE (e.g. GET_RFA)',
})
intentCode!: string;
/** คำอธิบายภาษาไทย */
@IsString()
@MaxLength(255)
descriptionTh!: string;
/** คำอธิบายภาษาอังกฤษ */
@IsString()
@MaxLength(255)
descriptionEn!: string;
/** หมวดหมู่ */
@IsEnum(IntentCategory)
category!: IntentCategory;
}
@@ -0,0 +1,44 @@
// File: src/modules/ai/intent-classifier/dto/create-intent-pattern.dto.ts
// Change Log
// - 2026-05-19: สร้าง DTO สำหรับสร้าง Intent Pattern (ADR-024).
import {
IsString,
IsEnum,
IsInt,
IsOptional,
MaxLength,
Min,
Max,
} from 'class-validator';
import {
PatternType,
PatternLanguage,
} from '../interfaces/intent-category.enum';
/**
* DTO สำหรับสร้าง Intent Pattern
* ใช้กับ POST /admin/ai/intent-definitions/:intentCode/patterns
*/
export class CreateIntentPatternDto {
/** ภาษาที่ Pattern รองรับ */
@IsOptional()
@IsEnum(PatternLanguage)
language?: PatternLanguage;
/** ชนิด Pattern */
@IsEnum(PatternType)
patternType!: PatternType;
/** ค่า Pattern (keyword หรือ regex string) */
@IsString()
@MaxLength(255)
patternValue!: string;
/** ลำดับความสำคัญ (ต่ำ = สำคัญกว่า) */
@IsOptional()
@IsInt()
@Min(1)
@Max(9999)
priority?: number;
}
@@ -0,0 +1,25 @@
// File: src/modules/ai/intent-classifier/dto/update-intent-definition.dto.ts
// Change Log
// - 2026-05-19: สร้าง DTO สำหรับ update Intent Definition (ADR-024).
import { IsString, IsBoolean, IsOptional, MaxLength } from 'class-validator';
/**
* DTO สำหรับ update Intent Definition
* ใช้กับ PATCH /admin/ai/intent-definitions/:intentCode
*/
export class UpdateIntentDefinitionDto {
@IsOptional()
@IsString()
@MaxLength(255)
descriptionTh?: string;
@IsOptional()
@IsString()
@MaxLength(255)
descriptionEn?: string;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
@@ -0,0 +1,47 @@
// File: src/modules/ai/intent-classifier/dto/update-intent-pattern.dto.ts
// Change Log
// - 2026-05-19: สร้าง DTO สำหรับ update Intent Pattern (ADR-024).
import {
IsString,
IsEnum,
IsInt,
IsBoolean,
IsOptional,
MaxLength,
Min,
Max,
} from 'class-validator';
import {
PatternType,
PatternLanguage,
} from '../interfaces/intent-category.enum';
/**
* DTO สำหรับ update Intent Pattern
* ใช้กับ PATCH /admin/ai/intent-patterns/:publicId
*/
export class UpdateIntentPatternDto {
@IsOptional()
@IsEnum(PatternLanguage)
language?: PatternLanguage;
@IsOptional()
@IsEnum(PatternType)
patternType?: PatternType;
@IsOptional()
@IsString()
@MaxLength(255)
patternValue?: string;
@IsOptional()
@IsInt()
@Min(1)
@Max(9999)
priority?: number;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
@@ -0,0 +1,77 @@
// File: src/modules/ai/intent-classifier/entities/intent-definition.entity.ts
// Change Log
// - 2026-05-19: สร้าง Entity สำหรับตาราง ai_intent_definitions (ADR-024).
import {
Entity,
PrimaryGeneratedColumn,
Column,
OneToMany,
CreateDateColumn,
UpdateDateColumn,
BeforeInsert,
Index,
} from 'typeorm';
import { Exclude } from 'class-transformer';
import { v7 as uuidv7 } from 'uuid';
import { IntentCategory } from '../interfaces/intent-category.enum';
import { IntentPattern } from './intent-pattern.entity';
/**
* Entity สำหรับ Intent Definitions
* ตาราง: ai_intent_definitions
* ADR-019: publicId (UUIDv7) expose ผ่าน API, id (INT) ไม่ expose
*/
@Entity('ai_intent_definitions')
export class IntentDefinition {
@PrimaryGeneratedColumn()
@Exclude()
id!: number;
/** UUID สาธารณะ (ADR-019) — ใช้ public_id เป็น column ไม่ใช่ uuid */
@Column({ name: 'public_id', type: 'uuid', unique: true, nullable: false })
publicId!: string;
/** รหัส Intent เช่น 'RAG_QUERY', 'GET_RFA' — Unique */
@Index('idx_intent_definition_code')
@Column({ name: 'intent_code', type: 'varchar', length: 50, unique: true })
intentCode!: string;
/** คำอธิบายภาษาไทย */
@Column({ name: 'description_th', type: 'varchar', length: 255 })
descriptionTh!: string;
/** คำอธิบายภาษาอังกฤษ */
@Column({ name: 'description_en', type: 'varchar', length: 255 })
descriptionEn!: string;
/** หมวดหมู่: read, suggest, utility */
@Column({
name: 'category',
type: 'enum',
enum: IntentCategory,
})
category!: IntentCategory;
/** สถานะการใช้งาน */
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date;
/** Patterns ที่เป็นของ Intent นี้ */
@OneToMany(() => IntentPattern, (pattern) => pattern.intentDefinition)
patterns!: IntentPattern[];
/** สร้าง UUIDv7 ก่อน insert (ADR-019) */
@BeforeInsert()
generatePublicId(): void {
if (!this.publicId) {
this.publicId = uuidv7();
}
}
}
@@ -0,0 +1,96 @@
// File: src/modules/ai/intent-classifier/entities/intent-pattern.entity.ts
// Change Log
// - 2026-05-19: สร้าง Entity สำหรับตาราง ai_intent_patterns (ADR-024).
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
CreateDateColumn,
UpdateDateColumn,
BeforeInsert,
Index,
} from 'typeorm';
import { Exclude } from 'class-transformer';
import { v7 as uuidv7 } from 'uuid';
import {
PatternType,
PatternLanguage,
} from '../interfaces/intent-category.enum';
import { IntentDefinition } from './intent-definition.entity';
/**
* Entity สำหรับ Intent Patterns (keyword/regex)
* ตาราง: ai_intent_patterns
* ADR-019: publicId (UUIDv7) expose ผ่าน API, id (INT) ไม่ expose
*/
@Entity('ai_intent_patterns')
export class IntentPattern {
@PrimaryGeneratedColumn()
@Exclude()
id!: number;
/** UUID สาธารณะ (ADR-019) */
@Column({ name: 'public_id', type: 'uuid', unique: true, nullable: false })
publicId!: string;
/** intentCode FK อ้างอิง ai_intent_definitions */
@Index('idx_pattern_intent_code')
@Column({ name: 'intent_code', type: 'varchar', length: 50 })
intentCode!: string;
/** ภาษาที่ Pattern รองรับ */
@Column({
name: 'language',
type: 'enum',
enum: PatternLanguage,
default: PatternLanguage.ANY,
})
language!: PatternLanguage;
/** ชนิดของ Pattern */
@Column({
name: 'pattern_type',
type: 'enum',
enum: PatternType,
default: PatternType.KEYWORD,
})
patternType!: PatternType;
/** ค่า Pattern (keyword string หรือ regex string) */
@Column({ name: 'pattern_value', type: 'varchar', length: 255 })
patternValue!: string;
/** ลำดับการตรวจสอบ (ต่ำ = ตรวจก่อน) */
@Index('idx_pattern_active_priority')
@Column({ name: 'priority', type: 'int', default: 100 })
priority!: number;
/** สถานะการใช้งาน */
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date;
/** Relation กลับไป IntentDefinition */
@ManyToOne(() => IntentDefinition, (def: IntentDefinition) => def.patterns, {
onUpdate: 'CASCADE',
onDelete: 'RESTRICT',
})
@JoinColumn({ name: 'intent_code', referencedColumnName: 'intentCode' })
intentDefinition!: IntentDefinition;
/** สร้าง UUIDv7 ก่อน insert (ADR-019) */
@BeforeInsert()
generatePublicId(): void {
if (!this.publicId) {
this.publicId = uuidv7();
}
}
}
@@ -0,0 +1,20 @@
// File: src/modules/ai/intent-classifier/index.ts
// Change Log
// - 2026-05-19: สร้าง barrel export สำหรับ Intent Classification Module (ADR-024).
export { IntentClassifierModule } from './intent-classifier.module';
export { IntentClassifierService } from './services/intent-classifier.service';
export { IntentDefinitionService } from './services/intent-definition.service';
export { IntentPatternService } from './services/intent-pattern.service';
export { IntentDefinition } from './entities/intent-definition.entity';
export { IntentPattern } from './entities/intent-pattern.entity';
export type {
ClassificationResult,
ClassificationInput,
CachedPattern,
} from './interfaces/classification-result.interface';
export {
IntentCategory,
PatternType,
PatternLanguage,
} from './interfaces/intent-category.enum';
@@ -0,0 +1,59 @@
// File: src/modules/ai/intent-classifier/intent-classifier.module.ts
// Change Log
// - 2026-05-19: สร้าง NestJS Module สำหรับ Intent Classification System (ADR-024).
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule } from '@nestjs/config';
import { IntentDefinition } from './entities/intent-definition.entity';
import { IntentPattern } from './entities/intent-pattern.entity';
import { AiAuditLog } from '../entities/ai-audit-log.entity';
import { IntentPatternCacheService } from './services/intent-pattern-cache.service';
import { PatternMatcherService } from './services/pattern-matcher.service';
import { OllamaClientService } from './services/ollama-client.service';
import { LlmSemaphoreService } from './services/llm-semaphore.service';
import { IntentClassifierService } from './services/intent-classifier.service';
import { IntentDefinitionService } from './services/intent-definition.service';
import { IntentPatternService } from './services/intent-pattern.service';
import { ClassificationAuditService } from './services/classification-audit.service';
import { IntentAnalyticsService } from './services/intent-analytics.service';
import {
IntentAdminController,
IntentPatternAdminController,
} from './controllers/intent-admin.controller';
import { IntentClassifyController } from './controllers/intent-classify.controller';
import { IntentAnalyticsController } from './controllers/intent-analytics.controller';
/**
* Module สำหรับ Intent Classification System
* จัดการ entities, services, และ exports สำหรับ module อื่น
*/
@Module({
imports: [
TypeOrmModule.forFeature([IntentDefinition, IntentPattern, AiAuditLog]),
ConfigModule,
],
controllers: [
IntentAdminController,
IntentPatternAdminController,
IntentClassifyController,
IntentAnalyticsController,
],
providers: [
IntentPatternCacheService,
PatternMatcherService,
OllamaClientService,
LlmSemaphoreService,
IntentClassifierService,
IntentDefinitionService,
IntentPatternService,
ClassificationAuditService,
IntentAnalyticsService,
],
exports: [
IntentClassifierService,
IntentDefinitionService,
IntentPatternService,
],
})
export class IntentClassifierModule {}
@@ -0,0 +1,59 @@
// File: src/modules/ai/intent-classifier/interfaces/classification-result.interface.ts
// Change Log
// - 2026-05-19: สร้าง interfaces สำหรับ Intent Classification System (ADR-024).
/** วิธีที่ใช้ในการจำแนก Intent */
export type ClassificationMethod =
| 'pattern'
| 'llm_fallback'
| 'semaphore_overflow'
| 'llm_error';
/**
* ผลลัพธ์การจำแนก Intent
* method: วิธีที่ใช้จำแนก (pattern match หรือ LLM fallback)
*/
export interface ClassificationResult {
/** Intent code ที่จำแนกได้ เช่น 'SUMMARIZE_DOCUMENT', 'GET_RFA' */
intentCode: string;
/** ความมั่นใจ 0.0-1.0 (1.0 = pattern match, < 1.0 = LLM) */
confidence: number;
/** วิธีที่ใช้จำแนก */
method: ClassificationMethod;
/** Parameters ที่สกัดได้จาก query (optional) */
params?: Record<string, unknown>;
/** เวลาที่ใช้ทั้งหมด (milliseconds) */
latencyMs: number;
}
/**
* Input สำหรับการจำแนก Intent
*/
export interface ClassificationInput {
/** คำถามจาก user (trim แล้ว, max 200 chars) */
query: string;
/** Context project UUID (optional) */
projectPublicId?: string;
/** Context user UUID (optional) */
userPublicId?: string;
/** Document ที่เปิดอยู่ UUID (optional) */
currentDocumentId?: string;
}
/**
* ข้อมูล Pattern ที่ใช้ใน matching (flatten จาก DB สำหรับ cache)
*/
export interface CachedPattern {
/** Public UUID ของ pattern */
publicId: string;
/** Intent code ที่ pattern นี้เป็นของ */
intentCode: string;
/** ภาษา: th, en, any */
language: 'th' | 'en' | 'any';
/** ชนิด pattern */
patternType: 'keyword' | 'regex';
/** ค่า pattern (keyword string หรือ regex string) */
patternValue: string;
/** ลำดับการตรวจสอบ (ต่ำ = สำคัญกว่า) */
priority: number;
}
@@ -0,0 +1,23 @@
// File: src/modules/ai/intent-classifier/interfaces/intent-category.enum.ts
// Change Log
// - 2026-05-19: สร้าง Enum สำหรับ Intent Category, Pattern Type, Pattern Language (ADR-024).
/** หมวดหมู่ของ Intent */
export enum IntentCategory {
READ = 'read', // ดึงข้อมูล: RAG_QUERY, GET_RFA, etc.
SUGGEST = 'suggest', // แนะนำ: SUGGEST_METADATA, SUGGEST_ACTION
UTILITY = 'utility', // อื่น ๆ: FALLBACK
}
/** ชนิดของ Pattern ที่ใช้ในการ match */
export enum PatternType {
KEYWORD = 'keyword', // case-insensitive string includes()
REGEX = 'regex', // RegExp.test()
}
/** ภาษาที่ Pattern รองรับ */
export enum PatternLanguage {
TH = 'th', // ภาษาไทย
EN = 'en', // ภาษาอังกฤษ
ANY = 'any', // ทุกภาษา
}
@@ -0,0 +1,94 @@
// File: src/modules/ai/intent-classifier/services/classification-audit.service.ts
// Change Log
// - 2026-05-19: สร้าง Audit Service สำหรับบันทึก Classification request ลง ai_audit_logs (FR-010, ADR-024).
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { createHash } from 'crypto';
import { AiAuditLog, AiAuditStatus } from '../../entities/ai-audit-log.entity';
import {
ClassificationInput,
ClassificationResult,
} from '../interfaces/classification-result.interface';
/** ข้อมูลที่ต้องบันทึก Audit */
export interface ClassificationAuditData {
input: ClassificationInput;
result: ClassificationResult;
}
/**
* Service สำหรับบันทึก Audit Log ของ Classification Requests
* บันทึก input, output, method, latency, projectPublicId, userPublicId
* ตาม FR-010 และ SC-006
*/
@Injectable()
export class ClassificationAuditService {
private readonly logger = new Logger(ClassificationAuditService.name);
constructor(
@InjectRepository(AiAuditLog)
private readonly auditRepo: Repository<AiAuditLog>
) {}
/**
* บันทึก Classification audit log (fire-and-forget)
* ไม่ block classification response — ใช้ catch เพื่อป้องกัน error propagation
*/
async log(data: ClassificationAuditData): Promise<void> {
try {
const inputJson = JSON.stringify({
query: data.input.query,
projectPublicId: data.input.projectPublicId,
userPublicId: data.input.userPublicId,
currentDocumentId: data.input.currentDocumentId,
});
const outputJson = JSON.stringify(data.result);
const audit = this.auditRepo.create({
aiModel: 'intent-classifier',
modelName:
data.result.method === 'llm_fallback'
? 'gemma4:e4b'
: 'pattern-match',
aiSuggestionJson: {
intentCode: data.result.intentCode,
confidence: data.result.confidence,
method: data.result.method,
latencyMs: data.result.latencyMs,
},
processingTimeMs: data.result.latencyMs,
confidenceScore: data.result.confidence,
inputHash: this.sha256(inputJson),
outputHash: this.sha256(outputJson),
status: this.mapStatus(data.result),
});
await this.auditRepo.save(audit);
} catch (err) {
// Fire-and-forget — ไม่ให้ audit failure block classification
this.logger.error(
'Failed to save classification audit log',
err instanceof Error ? err.stack : String(err)
);
}
}
/** สร้าง SHA-256 hash */
private sha256(input: string): string {
return createHash('sha256').update(input).digest('hex');
}
/** แปลง classification result เป็น AiAuditStatus */
private mapStatus(result: ClassificationResult): AiAuditStatus {
if (
result.method === 'llm_error' ||
result.method === 'semaphore_overflow'
) {
return AiAuditStatus.FAILED;
}
return AiAuditStatus.SUCCESS;
}
}
@@ -0,0 +1,222 @@
// File: src/modules/ai/intent-classifier/services/intent-analytics.service.spec.ts
// Change Log
// - 2026-05-19: สร้าง Unit tests สำหรับ IntentAnalyticsService (T033, US3).
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { IntentAnalyticsService } from './intent-analytics.service';
import { AiAuditLog, AiAuditStatus } from '../../entities/ai-audit-log.entity';
/** สร้าง mock audit log */
function mockLog(
overrides: Partial<{
method: string;
intentCode: string;
confidence: number;
latencyMs: number;
status: AiAuditStatus;
}> = {}
): AiAuditLog {
const method = overrides.method ?? 'pattern';
const intentCode = overrides.intentCode ?? 'GET_RFA';
return {
id: Math.floor(Math.random() * 1000),
aiModel: 'intent-classifier',
modelName: method === 'llm_fallback' ? 'gemma4:e4b' : 'pattern-match',
aiSuggestionJson: {
intentCode,
confidence: overrides.confidence ?? 1.0,
method,
latencyMs: overrides.latencyMs ?? 3,
},
processingTimeMs: overrides.latencyMs ?? 3,
confidenceScore: overrides.confidence ?? 1.0,
status: overrides.status ?? AiAuditStatus.SUCCESS,
createdAt: new Date(),
} as AiAuditLog;
}
describe('IntentAnalyticsService', () => {
let service: IntentAnalyticsService;
let mockQueryBuilder: Record<string, jest.Mock>;
beforeEach(async () => {
mockQueryBuilder = {
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([]),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
IntentAnalyticsService,
{
provide: getRepositoryToken(AiAuditLog),
useValue: {
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder),
},
},
],
}).compile();
service = module.get<IntentAnalyticsService>(IntentAnalyticsService);
});
describe('getAnalytics', () => {
it('ควร return empty analytics เมื่อไม่มี data', async () => {
mockQueryBuilder.getMany.mockResolvedValue([]);
const result = await service.getAnalytics();
expect(result.totalRequests).toBe(0);
expect(result.patternHitRate).toBe(0);
expect(result.byMethod).toHaveLength(0);
expect(result.byIntent).toHaveLength(0);
expect(result.recalibration).toHaveLength(0);
});
it('ควรคำนวณ patternHitRate ถูกต้อง', async () => {
const logs = [
mockLog({ method: 'pattern', intentCode: 'GET_RFA' }),
mockLog({ method: 'pattern', intentCode: 'SUMMARIZE_DOCUMENT' }),
mockLog({ method: 'pattern', intentCode: 'GET_DRAWING' }),
mockLog({
method: 'llm_fallback',
intentCode: 'GET_RFA',
confidence: 0.85,
latencyMs: 500,
}),
];
mockQueryBuilder.getMany.mockResolvedValue(logs);
const result = await service.getAnalytics();
expect(result.totalRequests).toBe(4);
expect(result.patternHitRate).toBe(75); // 3/4 = 75%
});
it('ควรนับ success/failed ถูกต้อง', async () => {
const logs = [
mockLog({ method: 'pattern', status: AiAuditStatus.SUCCESS }),
mockLog({ method: 'pattern', status: AiAuditStatus.SUCCESS }),
mockLog({ method: 'llm_error', status: AiAuditStatus.FAILED }),
];
mockQueryBuilder.getMany.mockResolvedValue(logs);
const result = await service.getAnalytics();
expect(result.successCount).toBe(2);
expect(result.failedCount).toBe(1);
});
it('ควร group by method ถูกต้อง', async () => {
const logs = [
mockLog({ method: 'pattern', latencyMs: 2, confidence: 1.0 }),
mockLog({ method: 'pattern', latencyMs: 4, confidence: 1.0 }),
mockLog({ method: 'llm_fallback', latencyMs: 500, confidence: 0.8 }),
];
mockQueryBuilder.getMany.mockResolvedValue(logs);
const result = await service.getAnalytics();
expect(result.byMethod).toHaveLength(2);
const pattern = result.byMethod.find((m) => m.method === 'pattern');
expect(pattern?.count).toBe(2);
expect(pattern?.avgLatencyMs).toBe(3); // (2+4)/2
expect(pattern?.avgConfidence).toBe(1.0);
const llm = result.byMethod.find((m) => m.method === 'llm_fallback');
expect(llm?.count).toBe(1);
expect(llm?.avgLatencyMs).toBe(500);
});
it('ควร group by intent ถูกต้อง', async () => {
const logs = [
mockLog({ method: 'pattern', intentCode: 'GET_RFA' }),
mockLog({
method: 'llm_fallback',
intentCode: 'GET_RFA',
confidence: 0.9,
latencyMs: 400,
}),
mockLog({ method: 'pattern', intentCode: 'SUMMARIZE_DOCUMENT' }),
];
mockQueryBuilder.getMany.mockResolvedValue(logs);
const result = await service.getAnalytics();
expect(result.byIntent).toHaveLength(2);
const rfa = result.byIntent.find((i) => i.intentCode === 'GET_RFA');
expect(rfa?.count).toBe(2);
expect(rfa?.patternHits).toBe(1);
expect(rfa?.llmHits).toBe(1);
});
it('ควรสร้าง recalibration recommendations สำหรับ LLM-heavy intents', async () => {
const logs = [
mockLog({
method: 'llm_fallback',
intentCode: 'GET_DRAWING',
confidence: 0.85,
}),
mockLog({
method: 'llm_fallback',
intentCode: 'GET_DRAWING',
confidence: 0.78,
}),
mockLog({
method: 'llm_fallback',
intentCode: 'GET_DRAWING',
confidence: 0.82,
}),
mockLog({
method: 'llm_fallback',
intentCode: 'LIST_OVERDUE',
confidence: 0.7,
}),
mockLog({ method: 'pattern', intentCode: 'GET_RFA' }),
];
mockQueryBuilder.getMany.mockResolvedValue(logs);
const result = await service.getAnalytics();
// GET_DRAWING ถูก LLM classify 3 ครั้ง → ควรอยู่อันดับ 1
expect(result.recalibration.length).toBeGreaterThan(0);
expect(result.recalibration[0].intentCode).toBe('GET_DRAWING');
expect(result.recalibration[0].llmCallCount).toBe(3);
});
it('ควรไม่ include FALLBACK ใน recalibration', async () => {
const logs = [
mockLog({
method: 'llm_fallback',
intentCode: 'FALLBACK',
confidence: 0.2,
}),
mockLog({
method: 'llm_fallback',
intentCode: 'FALLBACK',
confidence: 0.15,
}),
];
mockQueryBuilder.getMany.mockResolvedValue(logs);
const result = await service.getAnalytics();
expect(result.recalibration).toHaveLength(0);
});
it('ควรรับ date range parameters', async () => {
mockQueryBuilder.getMany.mockResolvedValue([]);
const from = new Date('2026-01-01');
const to = new Date('2026-01-31');
await service.getAnalytics(from, to);
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
'a.createdAt BETWEEN :from AND :to',
{ from, to }
);
});
});
});
@@ -0,0 +1,242 @@
// File: src/modules/ai/intent-classifier/services/intent-analytics.service.ts
// Change Log
// - 2026-05-19: สร้าง AnalyticsService สำหรับสรุปสถิติ Intent Classification (T034, US3).
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AiAuditLog, AiAuditStatus } from '../../entities/ai-audit-log.entity';
/** สถิติแยกตาม method (pattern / llm_fallback / etc.) */
export interface MethodStats {
method: string;
count: number;
avgConfidence: number;
avgLatencyMs: number;
}
/** สถิติแยกตาม intent code */
export interface IntentStats {
intentCode: string;
count: number;
avgConfidence: number;
patternHits: number;
llmHits: number;
}
/** คำแนะนำ Recalibration — intent ที่ควรเพิ่ม pattern */
export interface RecalibrationRecommendation {
intentCode: string;
llmCallCount: number;
avgConfidence: number;
/** ยิ่งสูง = ควรเพิ่ม pattern มากที่สุด */
priority: number;
}
/** ผลลัพธ์สรุปรวม Analytics */
export interface ClassificationAnalytics {
/** จำนวน request ทั้งหมดในช่วง */
totalRequests: number;
/** จำนวน request สำเร็จ */
successCount: number;
/** จำนวน request ล้มเหลว */
failedCount: number;
/** อัตราการ hit ด้วย pattern (ไม่ต้องเรียก LLM) */
patternHitRate: number;
/** ค่าเฉลี่ย confidence ทั้งหมด */
avgConfidence: number;
/** ค่าเฉลี่ย latency (ms) */
avgLatencyMs: number;
/** สถิติแยกตาม method */
byMethod: MethodStats[];
/** สถิติแยกตาม intent */
byIntent: IntentStats[];
/** คำแนะนำ intent ที่ควรเพิ่ม pattern */
recalibration: RecalibrationRecommendation[];
}
/**
* Service สำหรับ Analytics ของ Intent Classification
* Query จาก ai_audit_logs ที่ aiModel = 'intent-classifier'
*/
@Injectable()
export class IntentAnalyticsService {
private readonly logger = new Logger(IntentAnalyticsService.name);
constructor(
@InjectRepository(AiAuditLog)
private readonly auditRepo: Repository<AiAuditLog>
) {}
/**
* ดึงสถิติ Classification ในช่วงเวลาที่กำหนด
* @param fromDate เริ่มต้น (default: 30 วันก่อน)
* @param toDate สิ้นสุด (default: ปัจจุบัน)
*/
async getAnalytics(
fromDate?: Date,
toDate?: Date
): Promise<ClassificationAnalytics> {
const from = fromDate ?? new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const to = toDate ?? new Date();
const qb = this.auditRepo
.createQueryBuilder('a')
.where('a.aiModel = :model', { model: 'intent-classifier' })
.andWhere('a.createdAt BETWEEN :from AND :to', { from, to });
// ดึง raw records เพื่อคำนวณ
const logs = await qb.getMany();
if (logs.length === 0) {
return this.emptyAnalytics();
}
const totalRequests = logs.length;
const successLogs = logs.filter((l) => l.status === AiAuditStatus.SUCCESS);
const failedLogs = logs.filter((l) => l.status !== AiAuditStatus.SUCCESS);
// แยก method จาก aiSuggestionJson
const withMethod = logs.map((l) => ({
...l,
method: this.extractMethod(l),
intentCode: this.extractIntentCode(l),
}));
const patternHits = withMethod.filter((l) => l.method === 'pattern').length;
const patternHitRate = totalRequests > 0 ? patternHits / totalRequests : 0;
const avgConfidence = this.avg(
logs.map((l) => Number(l.confidenceScore ?? 0))
);
const avgLatencyMs = this.avg(logs.map((l) => l.processingTimeMs ?? 0));
const byMethod = this.groupByMethod(withMethod);
const byIntent = this.groupByIntent(withMethod);
const recalibration = this.buildRecalibration(withMethod);
return {
totalRequests,
successCount: successLogs.length,
failedCount: failedLogs.length,
patternHitRate: Math.round(patternHitRate * 10000) / 100, // % with 2 decimals
avgConfidence: Math.round(avgConfidence * 100) / 100,
avgLatencyMs: Math.round(avgLatencyMs * 100) / 100,
byMethod,
byIntent,
recalibration,
};
}
/** สร้าง empty result */
private emptyAnalytics(): ClassificationAnalytics {
return {
totalRequests: 0,
successCount: 0,
failedCount: 0,
patternHitRate: 0,
avgConfidence: 0,
avgLatencyMs: 0,
byMethod: [],
byIntent: [],
recalibration: [],
};
}
/** ดึง method จาก aiSuggestionJson */
private extractMethod(log: AiAuditLog): string {
const json = log.aiSuggestionJson;
return (json?.method as string) ?? 'unknown';
}
/** ดึง intentCode จาก aiSuggestionJson */
private extractIntentCode(log: AiAuditLog): string {
const json = log.aiSuggestionJson;
return (json?.intentCode as string) ?? 'UNKNOWN';
}
/** สรุปสถิติแยกตาม method */
private groupByMethod(
logs: Array<AiAuditLog & { method: string }>
): MethodStats[] {
const groups = new Map<string, AiAuditLog[]>();
for (const log of logs) {
const key = log.method;
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(log);
}
return Array.from(groups.entries()).map(([method, items]) => ({
method,
count: items.length,
avgConfidence:
Math.round(
this.avg(items.map((l) => Number(l.confidenceScore ?? 0))) * 100
) / 100,
avgLatencyMs:
Math.round(this.avg(items.map((l) => l.processingTimeMs ?? 0)) * 100) /
100,
}));
}
/** สรุปสถิติแยกตาม intent code */
private groupByIntent(
logs: Array<AiAuditLog & { method: string; intentCode: string }>
): IntentStats[] {
const groups = new Map<string, Array<AiAuditLog & { method: string }>>();
for (const log of logs) {
const key = log.intentCode;
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(log);
}
return Array.from(groups.entries())
.map(([intentCode, items]) => ({
intentCode,
count: items.length,
avgConfidence:
Math.round(
this.avg(items.map((l) => Number(l.confidenceScore ?? 0))) * 100
) / 100,
patternHits: items.filter((l) => l.method === 'pattern').length,
llmHits: items.filter((l) => l.method === 'llm_fallback').length,
}))
.sort((a, b) => b.count - a.count);
}
/**
* สร้างคำแนะนำ Recalibration
* Intent ที่ถูก classify ด้วย LLM บ่อย ควรเพิ่ม pattern
*/
private buildRecalibration(
logs: Array<AiAuditLog & { method: string; intentCode: string }>
): RecalibrationRecommendation[] {
const llmLogs = logs.filter((l) => l.method === 'llm_fallback');
const groups = new Map<string, AiAuditLog[]>();
for (const log of llmLogs) {
const key = log.intentCode;
if (key === 'FALLBACK' || key === 'UNKNOWN') continue;
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(log);
}
return Array.from(groups.entries())
.map(([intentCode, items]) => ({
intentCode,
llmCallCount: items.length,
avgConfidence:
Math.round(
this.avg(items.map((l) => Number(l.confidenceScore ?? 0))) * 100
) / 100,
priority: items.length, // ยิ่งเรียก LLM บ่อย = priority สูง
}))
.sort((a, b) => b.priority - a.priority)
.slice(0, 10); // แสดง top 10
}
/** คำนวณค่าเฉลี่ย */
private avg(values: number[]): number {
if (values.length === 0) return 0;
return values.reduce((sum, v) => sum + v, 0) / values.length;
}
}
@@ -0,0 +1,144 @@
// File: src/modules/ai/intent-classifier/services/intent-classifier.service.spec.ts
// Change Log
// - 2026-05-19: สร้าง Unit Tests สำหรับ IntentClassifierService (ADR-024).
import { IntentClassifierService } from './intent-classifier.service';
import { IntentPatternCacheService } from './intent-pattern-cache.service';
import { PatternMatcherService } from './pattern-matcher.service';
import { OllamaClientService } from './ollama-client.service';
import { LlmSemaphoreService } from './llm-semaphore.service';
import { ClassificationAuditService } from './classification-audit.service';
import { CachedPattern } from '../interfaces/classification-result.interface';
describe('IntentClassifierService', () => {
let service: IntentClassifierService;
let cacheService: jest.Mocked<IntentPatternCacheService>;
let patternMatcher: jest.Mocked<PatternMatcherService>;
let ollamaClient: jest.Mocked<OllamaClientService>;
let semaphore: jest.Mocked<LlmSemaphoreService>;
let auditService: jest.Mocked<ClassificationAuditService>;
const mockPatterns: CachedPattern[] = [
{
publicId: 'uuid-1',
intentCode: 'SUMMARIZE_DOCUMENT',
language: 'th',
patternType: 'keyword',
patternValue: 'สรุป',
priority: 10,
},
];
beforeEach(() => {
cacheService = {
getActivePatterns: jest.fn().mockResolvedValue(mockPatterns),
invalidate: jest.fn(),
} as unknown as jest.Mocked<IntentPatternCacheService>;
patternMatcher = {
match: jest.fn(),
} as unknown as jest.Mocked<PatternMatcherService>;
ollamaClient = {
classifyIntent: jest.fn(),
} as unknown as jest.Mocked<OllamaClientService>;
semaphore = {
tryAcquire: jest.fn(),
activeCount: 0,
pendingCount: 0,
isFull: false,
} as unknown as jest.Mocked<LlmSemaphoreService>;
auditService = {
log: jest.fn().mockResolvedValue(undefined),
} as unknown as jest.Mocked<ClassificationAuditService>;
service = new IntentClassifierService(
cacheService,
patternMatcher,
ollamaClient,
semaphore,
auditService
);
});
describe('classify', () => {
it('ควร return pattern match result เมื่อ pattern ตรง', async () => {
patternMatcher.match.mockReturnValue({
intentCode: 'SUMMARIZE_DOCUMENT',
confidence: 1.0,
method: 'pattern',
latencyMs: 5,
});
const result = await service.classify({ query: 'สรุปเอกสาร' });
expect(result.intentCode).toBe('SUMMARIZE_DOCUMENT');
expect(result.method).toBe('pattern');
expect(result.confidence).toBe(1.0);
expect(ollamaClient.classifyIntent).not.toHaveBeenCalled();
});
it('ควร fallback ไป LLM เมื่อ pattern ไม่ match', async () => {
patternMatcher.match.mockReturnValue(null);
semaphore.tryAcquire.mockReturnValue(jest.fn());
ollamaClient.classifyIntent.mockResolvedValue({
intent: 'GET_RFA',
confidence: 0.85,
});
const result = await service.classify({ query: 'show me RFA' });
expect(result.intentCode).toBe('GET_RFA');
expect(result.method).toBe('llm_fallback');
expect(result.confidence).toBe(0.85);
});
it('ควร return FALLBACK เมื่อ semaphore เต็ม (overflow)', async () => {
patternMatcher.match.mockReturnValue(null);
semaphore.tryAcquire.mockReturnValue(null);
const result = await service.classify({ query: 'unknown' });
expect(result.intentCode).toBe('FALLBACK');
expect(result.method).toBe('semaphore_overflow');
expect(result.confidence).toBe(0);
});
it('ควร return FALLBACK เมื่อ LLM error', async () => {
patternMatcher.match.mockReturnValue(null);
semaphore.tryAcquire.mockReturnValue(jest.fn());
ollamaClient.classifyIntent.mockResolvedValue(null);
const result = await service.classify({ query: 'random query' });
expect(result.intentCode).toBe('FALLBACK');
expect(result.method).toBe('llm_error');
});
it('ควร release semaphore หลังจาก LLM call เสร็จ', async () => {
patternMatcher.match.mockReturnValue(null);
const releaseFn = jest.fn();
semaphore.tryAcquire.mockReturnValue(releaseFn);
ollamaClient.classifyIntent.mockResolvedValue({
intent: 'GET_RFA',
confidence: 0.9,
});
await service.classify({ query: 'test' });
expect(releaseFn).toHaveBeenCalledTimes(1);
});
it('ควร release semaphore แม้ LLM throw error', async () => {
patternMatcher.match.mockReturnValue(null);
const releaseFn = jest.fn();
semaphore.tryAcquire.mockReturnValue(releaseFn);
ollamaClient.classifyIntent.mockRejectedValue(new Error('timeout'));
await expect(service.classify({ query: 'test' })).rejects.toThrow();
expect(releaseFn).toHaveBeenCalledTimes(1);
});
});
});
@@ -0,0 +1,111 @@
// File: src/modules/ai/intent-classifier/services/intent-classifier.service.ts
// Change Log
// - 2026-05-19: สร้าง Core Orchestrator — Hybrid Strategy: Pattern First → LLM Fallback (ADR-024).
import { Injectable, Logger } from '@nestjs/common';
import {
ClassificationInput,
ClassificationResult,
} from '../interfaces/classification-result.interface';
import { IntentPatternCacheService } from './intent-pattern-cache.service';
import { PatternMatcherService } from './pattern-matcher.service';
import { OllamaClientService } from './ollama-client.service';
import { LlmSemaphoreService } from './llm-semaphore.service';
import { ClassificationAuditService } from './classification-audit.service';
/** FALLBACK intent เมื่อไม่สามารถจำแนกได้ */
const FALLBACK_INTENT = 'FALLBACK';
/**
* Core Intent Classifier Service
* Hybrid Strategy:
* 1. Pattern Match (cache-first, < 50ms)
* 2. LLM Fallback (Ollama, semaphore-guarded)
* 3. Fallback: FALLBACK intent
*/
@Injectable()
export class IntentClassifierService {
private readonly logger = new Logger(IntentClassifierService.name);
constructor(
private readonly cacheService: IntentPatternCacheService,
private readonly patternMatcher: PatternMatcherService,
private readonly ollamaClient: OllamaClientService,
private readonly semaphore: LlmSemaphoreService,
private readonly auditService: ClassificationAuditService
) {}
/**
* จำแนก Intent จาก user query
* Flow: Cache patterns → Pattern match → LLM fallback → FALLBACK
*/
async classify(input: ClassificationInput): Promise<ClassificationResult> {
const startTime = Date.now();
// Step 1: ดึง cached patterns
const patterns = await this.cacheService.getActivePatterns();
// Step 2: Pattern matching
const patternResult = this.patternMatcher.match(input.query, patterns);
if (patternResult) {
this.logger.debug(
`Pattern match: "${input.query}" → ${patternResult.intentCode}`
);
// Audit log (fire-and-forget)
void this.auditService.log({ input, result: patternResult });
return patternResult;
}
// Step 3: LLM Fallback (semaphore-guarded)
const llmResult = await this.llmFallback(input.query, startTime);
// Audit log (fire-and-forget)
void this.auditService.log({ input, result: llmResult });
return llmResult;
}
/** LLM Fallback — ใช้ semaphore ควบคุม concurrency */
private async llmFallback(
query: string,
startTime: number
): Promise<ClassificationResult> {
// Try acquire — ถ้าเต็มจะ return FALLBACK ทันที (semaphore_overflow)
const release = this.semaphore.tryAcquire();
if (!release) {
this.logger.warn(
`Semaphore overflow: active=${this.semaphore.activeCount}, pending=${this.semaphore.pendingCount}`
);
return {
intentCode: FALLBACK_INTENT,
confidence: 0,
method: 'semaphore_overflow',
latencyMs: Date.now() - startTime,
};
}
try {
const result = await this.ollamaClient.classifyIntent(query);
if (result) {
this.logger.debug(
`LLM fallback: "${query}" → ${result.intent} (${result.confidence})`
);
return {
intentCode: result.intent,
confidence: result.confidence,
method: 'llm_fallback',
latencyMs: Date.now() - startTime,
};
}
// LLM ไม่สามารถ parse ได้ → FALLBACK
return {
intentCode: FALLBACK_INTENT,
confidence: 0,
method: 'llm_error',
latencyMs: Date.now() - startTime,
};
} finally {
release();
}
}
}
@@ -0,0 +1,156 @@
// File: src/modules/ai/intent-classifier/services/intent-definition.service.spec.ts
// Change Log
// - 2026-05-19: สร้าง Unit tests สำหรับ IntentDefinitionService (T014).
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { NotFoundException, ConflictException } from '@nestjs/common';
import { Repository } from 'typeorm';
import { IntentDefinitionService } from './intent-definition.service';
import { IntentDefinition } from '../entities/intent-definition.entity';
import { IntentCategory } from '../interfaces/intent-category.enum';
describe('IntentDefinitionService', () => {
let service: IntentDefinitionService;
let repo: jest.Mocked<Repository<IntentDefinition>>;
const mockDefinition: Partial<IntentDefinition> = {
id: 1,
publicId: 'uuid-1',
intentCode: 'GET_RFA',
descriptionTh: 'ดึง RFA',
descriptionEn: 'Get RFA',
category: IntentCategory.READ,
isActive: true,
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
IntentDefinitionService,
{
provide: getRepositoryToken(IntentDefinition),
useValue: {
find: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
},
},
],
}).compile();
service = module.get<IntentDefinitionService>(IntentDefinitionService);
repo = module.get(getRepositoryToken(IntentDefinition));
});
describe('findAll', () => {
it('ควรดึง definitions ทั้งหมด', async () => {
repo.find.mockResolvedValue([mockDefinition as IntentDefinition]);
const result = await service.findAll();
expect(result).toHaveLength(1);
expect(repo.find).toHaveBeenCalledWith({
where: {},
order: { intentCode: 'ASC' },
relations: ['patterns'],
});
});
it('ควร filter ตาม category', async () => {
repo.find.mockResolvedValue([]);
await service.findAll({ category: IntentCategory.SUGGEST });
expect(repo.find).toHaveBeenCalledWith({
where: { category: IntentCategory.SUGGEST },
order: { intentCode: 'ASC' },
relations: ['patterns'],
});
});
it('ควร filter ตาม isActive', async () => {
repo.find.mockResolvedValue([]);
await service.findAll({ isActive: true });
expect(repo.find).toHaveBeenCalledWith({
where: { isActive: true },
order: { intentCode: 'ASC' },
relations: ['patterns'],
});
});
});
describe('findByCode', () => {
it('ควร return definition เมื่อเจอ', async () => {
repo.findOne.mockResolvedValue(mockDefinition as IntentDefinition);
const result = await service.findByCode('GET_RFA');
expect(result.intentCode).toBe('GET_RFA');
});
it('ควร throw NotFoundException เมื่อไม่เจอ', async () => {
repo.findOne.mockResolvedValue(null);
await expect(service.findByCode('NOT_EXISTS')).rejects.toThrow(
NotFoundException
);
});
});
describe('create', () => {
it('ควรสร้าง definition ใหม่สำเร็จ', async () => {
repo.findOne.mockResolvedValue(null); // ไม่มี duplicate
repo.create.mockReturnValue(mockDefinition as IntentDefinition);
repo.save.mockResolvedValue(mockDefinition as IntentDefinition);
const result = await service.create({
intentCode: 'GET_RFA',
descriptionTh: 'ดึง RFA',
descriptionEn: 'Get RFA',
category: IntentCategory.READ,
});
expect(result.intentCode).toBe('GET_RFA');
expect(repo.save).toHaveBeenCalled();
});
it('ควร throw ConflictException เมื่อ intentCode ซ้ำ', async () => {
repo.findOne.mockResolvedValue(mockDefinition as IntentDefinition);
await expect(
service.create({
intentCode: 'GET_RFA',
descriptionTh: 'ดึง RFA',
descriptionEn: 'Get RFA',
category: IntentCategory.READ,
})
).rejects.toThrow(ConflictException);
});
});
describe('update', () => {
it('ควร update definition สำเร็จ', async () => {
const updated = { ...mockDefinition, descriptionTh: 'อัปเดต' };
repo.findOne.mockResolvedValue(mockDefinition as IntentDefinition);
repo.save.mockResolvedValue(updated as IntentDefinition);
const result = await service.update('GET_RFA', {
descriptionTh: 'อัปเดต',
});
expect(result.descriptionTh).toBe('อัปเดต');
});
it('ควร throw NotFoundException เมื่อ intentCode ไม่มี', async () => {
repo.findOne.mockResolvedValue(null);
await expect(
service.update('NOT_EXISTS', { descriptionTh: 'test' })
).rejects.toThrow(NotFoundException);
});
});
});
@@ -0,0 +1,103 @@
// File: src/modules/ai/intent-classifier/services/intent-definition.service.ts
// Change Log
// - 2026-05-19: สร้าง CRUD service สำหรับ Intent Definitions (Admin, ADR-024).
import {
Injectable,
Logger,
NotFoundException,
ConflictException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { IntentDefinition } from '../entities/intent-definition.entity';
import { IntentCategory } from '../interfaces/intent-category.enum';
/** Filter options สำหรับ list */
export interface IntentDefinitionFilter {
category?: IntentCategory;
isActive?: boolean;
}
/** DTO สำหรับสร้าง Intent Definition */
export interface CreateIntentDefinitionData {
intentCode: string;
descriptionTh: string;
descriptionEn: string;
category: IntentCategory;
}
/** DTO สำหรับ update Intent Definition */
export interface UpdateIntentDefinitionData {
descriptionTh?: string;
descriptionEn?: string;
isActive?: boolean;
}
/**
* Service สำหรับจัดการ Intent Definitions (Admin CRUD)
*/
@Injectable()
export class IntentDefinitionService {
private readonly logger = new Logger(IntentDefinitionService.name);
constructor(
@InjectRepository(IntentDefinition)
private readonly repo: Repository<IntentDefinition>
) {}
/** ดึงรายการ Intent Definitions ทั้งหมด (filter ได้) */
async findAll(filter?: IntentDefinitionFilter): Promise<IntentDefinition[]> {
const where: Record<string, unknown> = {};
if (filter?.category) where.category = filter.category;
if (filter?.isActive !== undefined) where.isActive = filter.isActive;
return this.repo.find({
where,
order: { intentCode: 'ASC' },
relations: ['patterns'],
});
}
/** ดึง Intent Definition ตาม intentCode */
async findByCode(intentCode: string): Promise<IntentDefinition> {
const entity = await this.repo.findOne({
where: { intentCode },
relations: ['patterns'],
});
if (!entity) {
throw new NotFoundException(`Intent "${intentCode}" not found`);
}
return entity;
}
/** สร้าง Intent Definition ใหม่ */
async create(data: CreateIntentDefinitionData): Promise<IntentDefinition> {
// ตรวจสอบ intentCode ซ้ำ
const exists = await this.repo.findOne({
where: { intentCode: data.intentCode },
});
if (exists) {
throw new ConflictException(
`Intent code "${data.intentCode}" already exists`
);
}
const entity = this.repo.create(data);
const saved = await this.repo.save(entity);
this.logger.log(`Created intent definition: ${saved.intentCode}`);
return saved;
}
/** อัปเดต Intent Definition */
async update(
intentCode: string,
data: UpdateIntentDefinitionData
): Promise<IntentDefinition> {
const entity = await this.findByCode(intentCode);
Object.assign(entity, data);
const saved = await this.repo.save(entity);
this.logger.log(`Updated intent definition: ${saved.intentCode}`);
return saved;
}
}
@@ -0,0 +1,102 @@
// File: src/modules/ai/intent-classifier/services/intent-pattern-cache.service.ts
// Change Log
// - 2026-05-19: สร้าง Redis cache service สำหรับ Intent Patterns (ADR-024).
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRedis } from '@nestjs-modules/ioredis';
import Redis from 'ioredis';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { IntentPattern } from '../entities/intent-pattern.entity';
import { CachedPattern } from '../interfaces/classification-result.interface';
/** Redis cache key สำหรับ active patterns ทั้งหมด */
const CACHE_KEY = 'ai:intent:patterns:active';
/**
* Service สำหรับ cache Intent Patterns ใน Redis
* Strategy: Single Key JSON Array, TTL 5 นาที (ปรับได้ผ่าน ENV)
*/
@Injectable()
export class IntentPatternCacheService {
private readonly logger = new Logger(IntentPatternCacheService.name);
private readonly ttlSeconds: number;
constructor(
@InjectRedis() private readonly redis: Redis,
@InjectRepository(IntentPattern)
private readonly patternRepo: Repository<IntentPattern>,
private readonly configService: ConfigService
) {
this.ttlSeconds = this.configService.get<number>(
'INTENT_PATTERN_CACHE_TTL',
300
);
}
/**
* ดึง Active Patterns จาก Cache หรือ DB (cache-aside pattern)
* เรียงตาม priority ASC — ต่ำ = ตรวจก่อน
*/
async getActivePatterns(): Promise<CachedPattern[]> {
try {
const cached = await this.redis.get(CACHE_KEY);
if (cached) {
return JSON.parse(cached) as CachedPattern[];
}
} catch (err) {
this.logger.warn(
'Redis get failed, falling back to DB',
err instanceof Error ? err.message : String(err)
);
}
return this.loadAndCache();
}
/** Invalidate cache เมื่อ Admin แก้ไข Pattern */
async invalidate(): Promise<void> {
try {
await this.redis.del(CACHE_KEY);
this.logger.log('Intent pattern cache invalidated');
} catch (err) {
this.logger.error(
'Redis del failed',
err instanceof Error ? err.stack : String(err)
);
}
}
/** โหลด patterns จาก DB แล้ว set ใน Redis */
private async loadAndCache(): Promise<CachedPattern[]> {
const patterns = await this.patternRepo.find({
where: { isActive: true },
order: { priority: 'ASC' },
});
const cached: CachedPattern[] = patterns.map((p) => ({
publicId: p.publicId,
intentCode: p.intentCode,
language: p.language,
patternType: p.patternType,
patternValue: p.patternValue,
priority: p.priority,
}));
try {
await this.redis.setex(
CACHE_KEY,
this.ttlSeconds,
JSON.stringify(cached)
);
} catch (err) {
this.logger.warn(
'Redis setex failed, patterns loaded from DB only',
err instanceof Error ? err.message : String(err)
);
}
return cached;
}
}
@@ -0,0 +1,228 @@
// File: src/modules/ai/intent-classifier/services/intent-pattern.service.spec.ts
// Change Log
// - 2026-05-19: สร้าง Unit tests สำหรับ IntentPatternService (T015-T016).
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { NotFoundException, BadRequestException } from '@nestjs/common';
import { Repository } from 'typeorm';
import { IntentPatternService } from './intent-pattern.service';
import { IntentPattern } from '../entities/intent-pattern.entity';
import { IntentDefinition } from '../entities/intent-definition.entity';
import { IntentPatternCacheService } from './intent-pattern-cache.service';
import {
PatternLanguage,
PatternType,
} from '../interfaces/intent-category.enum';
describe('IntentPatternService', () => {
let service: IntentPatternService;
let patternRepo: jest.Mocked<Repository<IntentPattern>>;
let definitionRepo: jest.Mocked<Repository<IntentDefinition>>;
let cacheService: jest.Mocked<IntentPatternCacheService>;
const mockPattern: Partial<IntentPattern> = {
id: 1,
publicId: 'p-uuid-1',
intentCode: 'GET_RFA',
language: PatternLanguage.TH,
patternType: PatternType.KEYWORD,
patternValue: 'rfa',
priority: 10,
isActive: true,
};
const mockDefinition: Partial<IntentDefinition> = {
id: 1,
publicId: 'def-uuid-1',
intentCode: 'GET_RFA',
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
IntentPatternService,
{
provide: getRepositoryToken(IntentPattern),
useValue: {
find: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
},
},
{
provide: getRepositoryToken(IntentDefinition),
useValue: {
findOne: jest.fn(),
},
},
{
provide: IntentPatternCacheService,
useValue: {
invalidate: jest.fn().mockResolvedValue(undefined),
},
},
],
}).compile();
service = module.get<IntentPatternService>(IntentPatternService);
patternRepo = module.get(getRepositoryToken(IntentPattern));
definitionRepo = module.get(getRepositoryToken(IntentDefinition));
cacheService = module.get(IntentPatternCacheService);
});
describe('findByIntentCode', () => {
it('ควรดึง patterns ตาม intentCode', async () => {
patternRepo.find.mockResolvedValue([mockPattern as IntentPattern]);
const result = await service.findByIntentCode('GET_RFA');
expect(result).toHaveLength(1);
expect(patternRepo.find).toHaveBeenCalledWith({
where: { intentCode: 'GET_RFA' },
order: { priority: 'ASC' },
});
});
});
describe('findByPublicId', () => {
it('ควร return pattern เมื่อเจอ', async () => {
patternRepo.findOne.mockResolvedValue(mockPattern as IntentPattern);
const result = await service.findByPublicId('p-uuid-1');
expect(result.publicId).toBe('p-uuid-1');
});
it('ควร throw NotFoundException เมื่อไม่เจอ', async () => {
patternRepo.findOne.mockResolvedValue(null);
await expect(service.findByPublicId('not-exists')).rejects.toThrow(
NotFoundException
);
});
});
describe('create', () => {
it('ควรสร้าง pattern ใหม่สำเร็จ', async () => {
definitionRepo.findOne.mockResolvedValue(
mockDefinition as IntentDefinition
);
patternRepo.create.mockReturnValue(mockPattern as IntentPattern);
patternRepo.save.mockResolvedValue(mockPattern as IntentPattern);
const result = await service.create({
intentCode: 'GET_RFA',
patternType: PatternType.KEYWORD,
patternValue: 'rfa',
});
expect(result.patternValue).toBe('rfa');
expect(cacheService.invalidate).toHaveBeenCalled();
});
it('ควร throw NotFoundException เมื่อ intentCode ไม่มี', async () => {
definitionRepo.findOne.mockResolvedValue(null);
await expect(
service.create({
intentCode: 'NOT_EXISTS',
patternType: PatternType.KEYWORD,
patternValue: 'test',
})
).rejects.toThrow(NotFoundException);
});
it('ควร throw BadRequestException เมื่อ regex ไม่ถูกต้อง', async () => {
definitionRepo.findOne.mockResolvedValue(
mockDefinition as IntentDefinition
);
await expect(
service.create({
intentCode: 'GET_RFA',
patternType: PatternType.REGEX,
patternValue: '(?P<invalid',
})
).rejects.toThrow(BadRequestException);
});
it('ควร validate regex ที่ถูกต้อง สำเร็จ', async () => {
definitionRepo.findOne.mockResolvedValue(
mockDefinition as IntentDefinition
);
patternRepo.create.mockReturnValue(mockPattern as IntentPattern);
patternRepo.save.mockResolvedValue(mockPattern as IntentPattern);
await expect(
service.create({
intentCode: 'GET_RFA',
patternType: PatternType.REGEX,
patternValue: 'rfa[- ]?\\d+',
})
).resolves.not.toThrow();
});
});
describe('update', () => {
it('ควร update pattern สำเร็จ + invalidate cache', async () => {
const updated = { ...mockPattern, patternValue: 'new-value' };
patternRepo.findOne.mockResolvedValue(mockPattern as IntentPattern);
patternRepo.save.mockResolvedValue(updated as IntentPattern);
const result = await service.update('p-uuid-1', {
patternValue: 'new-value',
});
expect(result.patternValue).toBe('new-value');
expect(cacheService.invalidate).toHaveBeenCalled();
});
it('ควร throw NotFoundException เมื่อ publicId ไม่มี', async () => {
patternRepo.findOne.mockResolvedValue(null);
await expect(
service.update('not-exists', { patternValue: 'test' })
).rejects.toThrow(NotFoundException);
});
it('ควร validate regex เมื่อเปลี่ยน patternValue เป็น regex', async () => {
const regexPattern = {
...mockPattern,
patternType: PatternType.REGEX,
patternValue: 'old.*regex',
};
patternRepo.findOne.mockResolvedValue(regexPattern as IntentPattern);
await expect(
service.update('p-uuid-1', { patternValue: '(?P<bad' })
).rejects.toThrow(BadRequestException);
});
});
describe('remove', () => {
it('ควร soft delete (isActive=false) + invalidate cache', async () => {
patternRepo.findOne.mockResolvedValue(mockPattern as IntentPattern);
patternRepo.save.mockResolvedValue({
...mockPattern,
isActive: false,
} as IntentPattern);
await service.remove('p-uuid-1');
expect(patternRepo.save).toHaveBeenCalledWith(
expect.objectContaining({ isActive: false })
);
expect(cacheService.invalidate).toHaveBeenCalled();
});
it('ควร throw NotFoundException เมื่อ publicId ไม่มี', async () => {
patternRepo.findOne.mockResolvedValue(null);
await expect(service.remove('not-exists')).rejects.toThrow(
NotFoundException
);
});
});
});
@@ -0,0 +1,150 @@
// File: src/modules/ai/intent-classifier/services/intent-pattern.service.ts
// Change Log
// - 2026-05-19: สร้าง CRUD service สำหรับ Intent Patterns (Admin, ADR-024).
import {
Injectable,
Logger,
NotFoundException,
BadRequestException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { IntentPattern } from '../entities/intent-pattern.entity';
import { IntentDefinition } from '../entities/intent-definition.entity';
import { IntentPatternCacheService } from './intent-pattern-cache.service';
import {
PatternLanguage,
PatternType,
} from '../interfaces/intent-category.enum';
/** DTO สำหรับสร้าง Pattern */
export interface CreateIntentPatternData {
intentCode: string;
language?: PatternLanguage;
patternType: PatternType;
patternValue: string;
priority?: number;
}
/** DTO สำหรับ update Pattern */
export interface UpdateIntentPatternData {
language?: PatternLanguage;
patternType?: PatternType;
patternValue?: string;
priority?: number;
isActive?: boolean;
}
/**
* Service สำหรับจัดการ Intent Patterns (Admin CRUD)
* Invalidate cache ทุกครั้งที่มีการเปลี่ยนแปลง
*/
@Injectable()
export class IntentPatternService {
private readonly logger = new Logger(IntentPatternService.name);
constructor(
@InjectRepository(IntentPattern)
private readonly repo: Repository<IntentPattern>,
@InjectRepository(IntentDefinition)
private readonly definitionRepo: Repository<IntentDefinition>,
private readonly cacheService: IntentPatternCacheService
) {}
/** ดึง Patterns ตาม intentCode */
async findByIntentCode(intentCode: string): Promise<IntentPattern[]> {
return this.repo.find({
where: { intentCode },
order: { priority: 'ASC' },
});
}
/** ดึง Pattern ตาม publicId */
async findByPublicId(publicId: string): Promise<IntentPattern> {
const entity = await this.repo.findOne({ where: { publicId } });
if (!entity) {
throw new NotFoundException(`Pattern "${publicId}" not found`);
}
return entity;
}
/** สร้าง Pattern ใหม่ + invalidate cache */
async create(data: CreateIntentPatternData): Promise<IntentPattern> {
// ตรวจสอบว่า intentCode มีอยู่จริง
const definition = await this.definitionRepo.findOne({
where: { intentCode: data.intentCode },
});
if (!definition) {
throw new NotFoundException(
`Intent "${data.intentCode}" not found — ต้องสร้าง Intent Definition ก่อน`
);
}
// Validate regex ถ้าเป็น regex type
if (data.patternType === PatternType.REGEX) {
this.validateRegex(data.patternValue);
}
const entity = this.repo.create({
intentCode: data.intentCode,
language: data.language ?? PatternLanguage.ANY,
patternType: data.patternType,
patternValue: data.patternValue,
priority: data.priority ?? 100,
});
const saved = await this.repo.save(entity);
await this.cacheService.invalidate();
this.logger.log(
`Created pattern for ${saved.intentCode}: "${saved.patternValue}"`
);
return saved;
}
/** อัปเดต Pattern + invalidate cache */
async update(
publicId: string,
data: UpdateIntentPatternData
): Promise<IntentPattern> {
const entity = await this.findByPublicId(publicId);
// Validate regex ถ้ามีการเปลี่ยน patternValue เป็น regex
const newType = data.patternType ?? entity.patternType;
const newValue = data.patternValue ?? entity.patternValue;
if (newType === PatternType.REGEX && data.patternValue) {
this.validateRegex(newValue);
}
Object.assign(entity, data);
const saved = await this.repo.save(entity);
await this.cacheService.invalidate();
this.logger.log(`Updated pattern ${publicId}`);
return saved;
}
/** Soft delete Pattern + invalidate cache */
async remove(publicId: string): Promise<void> {
const entity = await this.findByPublicId(publicId);
entity.isActive = false;
await this.repo.save(entity);
await this.cacheService.invalidate();
this.logger.log(`Soft-deleted pattern ${publicId}`);
}
/**
* Validate regex pattern (research decision: try-catch ที่ service layer)
* @throws BadRequestException ถ้า regex ไม่ถูกต้อง
*/
private validateRegex(pattern: string): void {
try {
new RegExp(pattern);
} catch (err) {
throw new BadRequestException(
`Invalid regex pattern: "${pattern}" — ${
err instanceof Error ? err.message : String(err)
}`
);
}
}
}
@@ -0,0 +1,95 @@
// File: src/modules/ai/intent-classifier/services/llm-semaphore.service.spec.ts
// Change Log
// - 2026-05-19: สร้าง Unit Tests สำหรับ LlmSemaphoreService (ADR-024).
import { LlmSemaphoreService } from './llm-semaphore.service';
import { ConfigService } from '@nestjs/config';
describe('LlmSemaphoreService', () => {
let service: LlmSemaphoreService;
beforeEach(() => {
const configService = {
get: jest.fn().mockReturnValue(2), // max concurrent = 2
} as unknown as ConfigService;
service = new LlmSemaphoreService(configService);
});
describe('tryAcquire', () => {
it('ควร acquire สำเร็จเมื่อยังมี slot ว่าง', () => {
const release = service.tryAcquire();
expect(release).not.toBeNull();
expect(service.activeCount).toBe(1);
});
it('ควร return null เมื่อเต็ม', () => {
service.tryAcquire();
service.tryAcquire();
const release = service.tryAcquire();
expect(release).toBeNull();
expect(service.activeCount).toBe(2);
});
it('ควร release slot ได้', () => {
const release = service.tryAcquire()!;
expect(service.activeCount).toBe(1);
release();
expect(service.activeCount).toBe(0);
});
it('ควร release ได้แค่ครั้งเดียว (idempotent)', () => {
const release = service.tryAcquire()!;
release();
release();
expect(service.activeCount).toBe(0);
});
});
describe('acquire (async)', () => {
it('ควร acquire ทันทีเมื่อมี slot ว่าง', async () => {
const release = await service.acquire();
expect(service.activeCount).toBe(1);
release();
});
it('ควร queue และรอเมื่อเต็ม', async () => {
const r1 = await service.acquire();
const r2 = await service.acquire();
expect(service.activeCount).toBe(2);
// r3 จะ queue
let r3Resolved = false;
const r3Promise = service.acquire().then((r) => {
r3Resolved = true;
return r;
});
// ยังไม่ resolve
await Promise.resolve();
expect(r3Resolved).toBe(false);
expect(service.pendingCount).toBe(1);
// release 1 slot → r3 ควร resolve
r1();
const r3 = await r3Promise;
expect(r3Resolved).toBe(true);
expect(service.activeCount).toBe(2);
r2();
r3();
});
});
describe('isFull', () => {
it('ควร return false เมื่อยังมี slot', () => {
expect(service.isFull).toBe(false);
});
it('ควร return true เมื่อเต็ม', () => {
service.tryAcquire();
service.tryAcquire();
expect(service.isFull).toBe(true);
});
});
});
@@ -0,0 +1,91 @@
// File: src/modules/ai/intent-classifier/services/llm-semaphore.service.ts
// Change Log
// - 2026-05-19: สร้าง Semaphore สำหรับควบคุม concurrent LLM calls (ADR-024).
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
/**
* Semaphore Pattern สำหรับจำกัด concurrent LLM calls
* ป้องกัน GPU overload บน Admin Desktop (ADR-023A)
* ใช้ Promise-based queue แทน p-limit เพื่อลด dependency
*/
@Injectable()
export class LlmSemaphoreService {
private readonly logger = new Logger(LlmSemaphoreService.name);
private readonly maxConcurrent: number;
private currentCount = 0;
private readonly queue: Array<() => void> = [];
constructor(private readonly configService: ConfigService) {
this.maxConcurrent = this.configService.get<number>(
'INTENT_CLASSIFIER_LLM_SEMAPHORE',
3
);
this.logger.log(
`LLM Semaphore initialized: max ${this.maxConcurrent} concurrent`
);
}
/** จำนวน requests ที่กำลังประมวลผลอยู่ */
get activeCount(): number {
return this.currentCount;
}
/** จำนวน requests ที่รอใน queue */
get pendingCount(): number {
return this.queue.length;
}
/** ตรวจสอบว่า semaphore เต็มหรือไม่ */
get isFull(): boolean {
return this.currentCount >= this.maxConcurrent;
}
/**
* Acquire semaphore slot — รอถ้าเต็ม
* @returns release function ที่ต้องเรียกเมื่อเสร็จ
*/
async acquire(): Promise<() => void> {
if (this.currentCount < this.maxConcurrent) {
this.currentCount++;
return this.createRelease();
}
// รอจนกว่าจะมี slot ว่าง
return new Promise<() => void>((resolve) => {
this.queue.push(() => {
this.currentCount++;
resolve(this.createRelease());
});
});
}
/**
* Try acquire — ไม่รอ ถ้าเต็มจะ return null ทันที
* ใช้สำหรับ semaphore_overflow fallback
*/
tryAcquire(): (() => void) | null {
if (this.currentCount < this.maxConcurrent) {
this.currentCount++;
return this.createRelease();
}
return null;
}
/** สร้าง release function (เรียกได้ครั้งเดียว) */
private createRelease(): () => void {
let released = false;
return () => {
if (released) return;
released = true;
this.currentCount--;
// ปล่อย request ถัดไปใน queue
if (this.queue.length > 0) {
const next = this.queue.shift();
if (next) next();
}
};
}
}
@@ -0,0 +1,132 @@
// File: src/modules/ai/intent-classifier/services/ollama-client.service.ts
// Change Log
// - 2026-05-19: สร้าง Ollama Client สำหรับ Intent Classification LLM Fallback (ADR-024, ADR-023A).
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios, { AxiosError } from 'axios';
/** โครงสร้าง response จาก Ollama /api/generate */
interface OllamaGenerateResponse {
response: string;
done: boolean;
}
/** ผลลัพธ์จาก LLM ที่ parse แล้ว */
export interface LlmIntentResult {
intent: string;
confidence: number;
}
/** System prompt สำหรับ Intent Classification */
const SYSTEM_PROMPT = `คุณเป็นตัวจำแนกคำสั่ง (Intent Classifier) สำหรับระบบจัดการเอกสาร DMS
จงวิเคราะห์คำถามของผู้ใช้และตอบในรูปแบบ JSON เท่านั้น โดยไม่มีข้อความอื่นใด
Intent ที่รองรับ:
- RAG_QUERY: ถามคำถามธรรมชาติ ต้องการคำตอบจากเอกสาร
- GET_RFA: ต้องการดู/ค้นหา RFA (Request for Approval)
- GET_DRAWING: ต้องการดู Drawing หรือแบบ
- GET_TRANSMITTAL: ต้องการดู Transmittal
- GET_CORRESPONDENCE: ต้องการดู Correspondence หรือจดหมาย
- GET_CIRCULATION: ต้องการดู Circulation
- GET_RFA_DRAWINGS: ต้องการ Drawings ที่ผูกกับ RFA
- SUMMARIZE_DOCUMENT: ต้องการสรุปเอกสาร
- LIST_OVERDUE: ต้องการรายการที่เกินกำหนด
- SUGGEST_METADATA: ต้องการคำแนะนำ metadata
- SUGGEST_ACTION: ต้องการคำแนะนำว่าควรทำอะไรต่อ
- FALLBACK: ไม่เกี่ยวกับระบบ หรือไม่เข้า intent ไหน
ตอบในรูปแบบ JSON: {"intent":"INTENT_CODE","confidence":0.95}`;
/**
* Service สำหรับเรียก Ollama LLM เพื่อ Classify Intent
* ใช้เฉพาะเมื่อ Pattern Match ล้มเหลว (LLM Fallback)
* ADR-023A: Ollama บน Admin Desktop เท่านั้น
*/
@Injectable()
export class OllamaClientService {
private readonly logger = new Logger(OllamaClientService.name);
private readonly baseUrl: string;
private readonly model: string;
private readonly timeoutMs: number;
constructor(private readonly configService: ConfigService) {
this.baseUrl = this.configService.get<string>(
'OLLAMA_BASE_URL',
this.configService.get<string>('AI_HOST_URL', 'http://localhost:11434')
);
this.model = this.configService.get<string>(
'OLLAMA_INTENT_MODEL',
this.configService.get<string>('OLLAMA_MODEL_MAIN', 'gemma4:e4b')
);
this.timeoutMs = this.configService.get<number>(
'OLLAMA_INTENT_TIMEOUT_MS',
5000
);
}
/**
* ส่ง query ไปยัง Ollama เพื่อ Classify Intent
* @returns LlmIntentResult หรือ null หากเกิด error / timeout
*/
async classifyIntent(query: string): Promise<LlmIntentResult | null> {
try {
const response = await axios.post<OllamaGenerateResponse>(
`${this.baseUrl}/api/generate`,
{
model: this.model,
system: SYSTEM_PROMPT,
prompt: query,
stream: false,
options: {
temperature: 0.1,
num_predict: 50,
},
},
{ timeout: this.timeoutMs }
);
return this.parseResponse(response.data.response);
} catch (err) {
if (err instanceof AxiosError) {
this.logger.warn(
`Ollama intent classification failed: ${err.code ?? 'UNKNOWN'}${err.message}`
);
} else {
this.logger.error(
'Unexpected error calling Ollama',
err instanceof Error ? err.stack : String(err)
);
}
return null;
}
}
/** Parse JSON response จาก Ollama */
private parseResponse(raw: string): LlmIntentResult | null {
try {
// Ollama อาจ wrap ด้วย markdown code block
const cleaned = raw
.replace(/```json\s*/g, '')
.replace(/```\s*/g, '')
.trim();
const parsed = JSON.parse(cleaned) as Record<string, unknown>;
if (
typeof parsed.intent !== 'string' ||
typeof parsed.confidence !== 'number'
) {
this.logger.warn(`Invalid LLM response format: ${raw}`);
return null;
}
return {
intent: parsed.intent,
confidence: Math.min(1, Math.max(0, parsed.confidence)),
};
} catch {
this.logger.warn(`Failed to parse Ollama response: ${raw}`);
return null;
}
}
}
@@ -0,0 +1,96 @@
// File: src/modules/ai/intent-classifier/services/pattern-matcher.service.spec.ts
// Change Log
// - 2026-05-19: สร้าง Unit Tests สำหรับ PatternMatcherService (ADR-024).
import { PatternMatcherService } from './pattern-matcher.service';
import { CachedPattern } from '../interfaces/classification-result.interface';
describe('PatternMatcherService', () => {
let service: PatternMatcherService;
beforeEach(() => {
service = new PatternMatcherService();
});
const mockPatterns: CachedPattern[] = [
{
publicId: 'uuid-1',
intentCode: 'SUMMARIZE_DOCUMENT',
language: 'th',
patternType: 'keyword',
patternValue: 'สรุป',
priority: 10,
},
{
publicId: 'uuid-2',
intentCode: 'GET_RFA',
language: 'en',
patternType: 'regex',
patternValue: '\\brfa\\b',
priority: 20,
},
{
publicId: 'uuid-3',
intentCode: 'GET_DRAWING',
language: 'any',
patternType: 'keyword',
patternValue: 'drawing',
priority: 30,
},
];
describe('match', () => {
it('ควร match keyword pattern (case-insensitive)', () => {
const result = service.match('สรุปเอกสารนี้', mockPatterns);
expect(result).not.toBeNull();
expect(result!.intentCode).toBe('SUMMARIZE_DOCUMENT');
expect(result!.confidence).toBe(1.0);
expect(result!.method).toBe('pattern');
});
it('ควร match regex pattern', () => {
const result = service.match('show me the RFA list', mockPatterns);
expect(result).not.toBeNull();
expect(result!.intentCode).toBe('GET_RFA');
expect(result!.confidence).toBe(1.0);
expect(result!.method).toBe('pattern');
});
it('ควร return null เมื่อไม่มี pattern ที่ match', () => {
const result = service.match('hello world', mockPatterns);
expect(result).toBeNull();
});
it('ควร match ตาม priority (ต่ำสุดก่อน)', () => {
const result = service.match('สรุป drawing', mockPatterns);
expect(result).not.toBeNull();
// priority 10 (สรุป) ก่อน priority 30 (drawing)
expect(result!.intentCode).toBe('SUMMARIZE_DOCUMENT');
});
it('ควรไม่ crash เมื่อ regex pattern ไม่ถูกต้อง', () => {
const badPatterns: CachedPattern[] = [
{
publicId: 'uuid-bad',
intentCode: 'BAD',
language: 'any',
patternType: 'regex',
patternValue: '[invalid(regex',
priority: 1,
},
];
const result = service.match('test', badPatterns);
expect(result).toBeNull();
});
it('ควร return latencyMs >= 0', () => {
const result = service.match('สรุป', mockPatterns);
expect(result!.latencyMs).toBeGreaterThanOrEqual(0);
});
it('ควรทำงานกับ patterns ว่าง', () => {
const result = service.match('test', []);
expect(result).toBeNull();
});
});
});
@@ -0,0 +1,68 @@
// File: src/modules/ai/intent-classifier/services/pattern-matcher.service.ts
// Change Log
// - 2026-05-19: สร้าง Pattern Matcher Service — จับคู่ query กับ cached patterns (ADR-024).
import { Injectable, Logger } from '@nestjs/common';
import {
CachedPattern,
ClassificationResult,
} from '../interfaces/classification-result.interface';
/**
* Service สำหรับจับคู่ query กับ Intent Patterns
* Strategy: iterate ตาม priority (ASC) — keyword ใช้ includes, regex ใช้ RegExp.test
* ผลลัพธ์แรกที่ match จะ return ทันที (confidence = 1.0)
*/
@Injectable()
export class PatternMatcherService {
private readonly logger = new Logger(PatternMatcherService.name);
/**
* จับคู่ query กับ patterns ที่ cache ไว้
* @returns ClassificationResult ถ้า match, null ถ้าไม่ match
*/
match(query: string, patterns: CachedPattern[]): ClassificationResult | null {
const normalizedQuery = query.toLowerCase().trim();
const startTime = Date.now();
for (const pattern of patterns) {
if (this.isPatternMatch(normalizedQuery, pattern)) {
return {
intentCode: pattern.intentCode,
confidence: 1.0,
method: 'pattern',
latencyMs: Date.now() - startTime,
};
}
}
return null;
}
/** ตรวจสอบว่า query match กับ pattern หรือไม่ */
private isPatternMatch(
normalizedQuery: string,
pattern: CachedPattern
): boolean {
try {
if (pattern.patternType === 'keyword') {
return normalizedQuery.includes(pattern.patternValue.toLowerCase());
}
if (pattern.patternType === 'regex') {
const regex = new RegExp(pattern.patternValue, 'i');
return regex.test(normalizedQuery);
}
return false;
} catch (err) {
// Invalid regex จะไม่ crash — log แล้วข้ามไป
this.logger.warn(
`Invalid pattern "${pattern.patternValue}" (${pattern.publicId}): ${
err instanceof Error ? err.message : String(err)
}`
);
return false;
}
}
}
@@ -0,0 +1,152 @@
// File: src/modules/ai/tool/ai-tool-registry.service.spec.ts
// Change Log
// - 2026-05-19: สร้าง Unit Test สำหรับ AiToolRegistryService (ADR-025).
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { AiToolRegistryService } from './ai-tool-registry.service';
import { RfaToolService } from './rfa-tool.service';
import { DrawingToolService } from './drawing-tool.service';
import { TransmittalToolService } from './transmittal-tool.service';
import { AiAuditLog, AiAuditStatus } from '../entities/ai-audit-log.entity';
import { ServerIntent } from './types/server-intent.enum';
import { ToolHandlerContext } from './types/tool-handler-context.type';
import { User } from '../../user/entities/user.entity';
/**
* Mock User สำหรับ Unit Test
* ไม่มี assignments → CASL deny ทุก action (ทดสอบ FORBIDDEN case)
*/
const mockUser = {
user_id: 1,
publicId: 'test-uuid-user',
assignments: [],
} as unknown as User;
/** Context มาตรฐานสำหรับ test */
const mockContext: ToolHandlerContext = {
requestUser: mockUser,
projectPublicId: '0195a1b2-c3d4-7000-8000-abc123def456',
};
const mockAuditLogRepo = {
create: jest.fn().mockReturnValue({}),
save: jest.fn().mockResolvedValue({}),
};
const mockRfaToolService = {
getRfa: jest
.fn()
.mockResolvedValue({ ok: true, data: [{ publicId: 'rfa-uuid' }] }),
};
const mockDrawingToolService = {
getDrawing: jest
.fn()
.mockResolvedValue({ ok: true, data: [{ publicId: 'drawing-uuid' }] }),
};
const mockTransmittalToolService = {
getTransmittal: jest
.fn()
.mockResolvedValue({ ok: true, data: [{ publicId: 'transmittal-uuid' }] }),
};
describe('AiToolRegistryService', () => {
let service: AiToolRegistryService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AiToolRegistryService,
{ provide: RfaToolService, useValue: mockRfaToolService },
{ provide: DrawingToolService, useValue: mockDrawingToolService },
{
provide: TransmittalToolService,
useValue: mockTransmittalToolService,
},
{
provide: getRepositoryToken(AiAuditLog),
useValue: mockAuditLogRepo,
},
],
}).compile();
service = module.get<AiToolRegistryService>(AiToolRegistryService);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('getHandler()', () => {
it('ควรคืน handler สำหรับ GET_RFA', () => {
const handler = service.getHandler(ServerIntent.GET_RFA);
expect(handler).toBeDefined();
});
it('ควรคืน handler สำหรับ GET_DRAWING', () => {
const handler = service.getHandler(ServerIntent.GET_DRAWING);
expect(handler).toBeDefined();
});
it('ควรคืน handler สำหรับ GET_TRANSMITTAL', () => {
const handler = service.getHandler(ServerIntent.GET_TRANSMITTAL);
expect(handler).toBeDefined();
});
it('ควรคืน undefined สำหรับ intent ที่ไม่มีใน registry', () => {
const handler = service.getHandler('UNKNOWN_INTENT' as ServerIntent);
expect(handler).toBeUndefined();
});
});
describe('dispatch()', () => {
it('ควร dispatch GET_RFA และคืนผลลัพธ์ถูกต้อง', async () => {
const result = await service.dispatch(ServerIntent.GET_RFA, mockContext);
expect(result.ok).toBe(true);
if (result.ok) {
expect(Array.isArray(result.data)).toBe(true);
}
expect(mockRfaToolService.getRfa).toHaveBeenCalledWith(mockContext);
});
it('ควรคืน INVALID_PARAMS เมื่อ intent ไม่มีใน registry', async () => {
const result = await service.dispatch('UNKNOWN_INTENT', mockContext);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe('INVALID_PARAMS');
}
});
it('ควรบันทึก AuditLog ทุก dispatch', async () => {
await service.dispatch(ServerIntent.GET_RFA, mockContext);
expect(mockAuditLogRepo.create).toHaveBeenCalled();
expect(mockAuditLogRepo.save).toHaveBeenCalled();
});
it('ควรคืน SERVICE_ERROR เมื่อ handler โยน exception', async () => {
mockRfaToolService.getRfa.mockRejectedValueOnce(
new Error('DB connection failed')
);
const result = await service.dispatch(ServerIntent.GET_RFA, mockContext);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe('SERVICE_ERROR');
}
});
it('ควรบันทึก AuditLog status=FAILED เมื่อ handler คืน ok: false', async () => {
mockRfaToolService.getRfa.mockResolvedValueOnce({
ok: false,
reason: 'FORBIDDEN',
message: 'No permission',
});
await service.dispatch(ServerIntent.GET_RFA, mockContext);
expect(mockAuditLogRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
status: AiAuditStatus.FAILED,
})
);
});
});
});
@@ -0,0 +1,131 @@
// File: src/modules/ai/tool/ai-tool-registry.service.ts
// Change Log
// - 2026-05-19: สร้าง AiToolRegistryService — Static Map จาก ServerIntent ไปยัง Tool Handlers (ADR-025).
// - 2026-05-19: เพิ่ม Audit Logging สำหรับทุก Tool Execution (ADR-023, FR-005).
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { v7 as uuidv7 } from 'uuid';
import { ServerIntent } from './types/server-intent.enum';
import { ToolCallResult } from './types/tool-call-result.type';
import { ToolHandlerContext } from './types/tool-handler-context.type';
import { RfaToolService } from './rfa-tool.service';
import { DrawingToolService } from './drawing-tool.service';
import { TransmittalToolService } from './transmittal-tool.service';
import { AiAuditLog, AiAuditStatus } from '../entities/ai-audit-log.entity';
/** ชนิดของ Tool Handler function */
type ToolHandler = (
context: ToolHandlerContext
) => Promise<ToolCallResult<unknown>>;
@Injectable()
export class AiToolRegistryService {
private readonly logger = new Logger(AiToolRegistryService.name);
/** Static Map จาก ServerIntent ไปยัง Tool Handler */
private readonly handlerMap: Map<ServerIntent, ToolHandler>;
constructor(
private readonly rfaToolService: RfaToolService,
private readonly drawingToolService: DrawingToolService,
private readonly transmittalToolService: TransmittalToolService,
@InjectRepository(AiAuditLog)
private readonly auditLogRepo: Repository<AiAuditLog>
) {
// ลงทะเบียน handlers ใน Static Map ตาม ADR-025
this.handlerMap = new Map<ServerIntent, ToolHandler>([
[ServerIntent.GET_RFA, (ctx) => this.rfaToolService.getRfa(ctx)],
[
ServerIntent.GET_DRAWING,
(ctx) => this.drawingToolService.getDrawing(ctx),
],
[
ServerIntent.GET_TRANSMITTAL,
(ctx) => this.transmittalToolService.getTransmittal(ctx),
],
]);
}
/**
* ส่ง Intent ไปยัง Tool Handler ที่ตรงกัน
* พร้อม Audit Logging ทุก Execution (FR-005)
*/
async dispatch(
intent: string,
context: ToolHandlerContext
): Promise<ToolCallResult<unknown>> {
const startMs = Date.now();
const handler = this.handlerMap.get(intent as ServerIntent);
if (!handler) {
this.logger.warn(`ไม่พบ Handler สำหรับ Intent: ${intent}`);
const result: ToolCallResult<unknown> = {
ok: false,
reason: 'INVALID_PARAMS',
message: `ไม่รองรับ Intent '${intent}'`,
};
await this.writeAuditLog(intent, context, result, Date.now() - startMs);
return result;
}
let result: ToolCallResult<unknown>;
try {
result = await handler(context);
} catch (error: unknown) {
const errMsg = error instanceof Error ? error.message : 'Unknown error';
this.logger.error(
`Tool Handler สำหรับ Intent '${intent}' เกิด exception: ${errMsg}`
);
result = {
ok: false,
reason: 'SERVICE_ERROR',
message: 'เกิดข้อผิดพลาดภายในระบบ กรุณาลองใหม่อีกครั้ง',
};
}
const latencyMs = Date.now() - startMs;
await this.writeAuditLog(intent, context, result, latencyMs);
return result;
}
/**
* คืน handler function สำหรับ Unit Test (ตรวจสอบว่ามี intent นั้นอยู่หรือไม่)
*/
getHandler(intent: ServerIntent): ToolHandler | undefined {
return this.handlerMap.get(intent);
}
/**
* บันทึก Audit Log ทุก Tool Execution (ADR-023 FR-005)
* ทำแบบ fire-and-forget เพื่อไม่บล็อก response
*/
private async writeAuditLog(
intent: string,
context: ToolHandlerContext,
result: ToolCallResult<unknown>,
latencyMs: number
): Promise<void> {
try {
const log = this.auditLogRepo.create({
publicId: uuidv7(),
aiModel: 'tool-layer', // ระบุ layer ใน model field
modelName: intent,
processingTimeMs: latencyMs,
status: result.ok ? AiAuditStatus.SUCCESS : AiAuditStatus.FAILED,
errorMessage: result.ok ? undefined : result.reason,
aiSuggestionJson: {
intent,
projectPublicId: context.projectPublicId,
userPublicId: context.requestUser.publicId,
params: context.params ?? {},
ok: result.ok,
reason: result.ok ? undefined : result.reason,
},
});
await this.auditLogRepo.save(log);
} catch (auditError: unknown) {
// Audit log ล้มเหลวต้องไม่กระทบ response หลัก (ข้อผิดพลาดเป็น non-critical)
this.logger.error(
`เขียน Audit Log ล้มเหลว: ${(auditError as Error).message}`
);
}
}
}
@@ -0,0 +1,255 @@
// File: src/modules/ai/tool/ai-tool-services.spec.ts
// Change Log
// - 2026-05-19: สร้าง Unit Test สำหรับ RfaToolService, DrawingToolService และ TransmittalToolService (ADR-025, ADR-016, ADR-019)
import { Test, TestingModule } from '@nestjs/testing';
import { RfaToolService } from './rfa-tool.service';
import { DrawingToolService } from './drawing-tool.service';
import { TransmittalToolService } from './transmittal-tool.service';
import { RfaService } from '../../rfa/rfa.service';
import { ShopDrawingService } from '../../drawing/shop-drawing.service';
import { TransmittalService } from '../../transmittal/transmittal.service';
import { AbilityFactory } from '../../../common/auth/casl/ability.factory';
import { UuidResolverService } from '../../../common/services/uuid-resolver.service';
import { ToolHandlerContext } from './types/tool-handler-context.type';
import { User } from '../../user/entities/user.entity';
describe('AI Tool Services (RFA, Drawing, Transmittal)', () => {
let rfaToolService: RfaToolService;
let drawingToolService: DrawingToolService;
let transmittalToolService: TransmittalToolService;
const mockUser = {
user_id: 1,
publicId: 'test-user-uuid',
} as unknown as User;
const mockContext: ToolHandlerContext = {
requestUser: mockUser,
projectPublicId: '0195a1b2-c3d4-7000-8000-abc123def456',
};
const mockAbility = {
can: jest.fn().mockReturnValue(true),
};
const mockAbilityFactory = {
createForUser: jest.fn().mockReturnValue(mockAbility),
};
const mockUuidResolver = {
resolveProjectId: jest.fn().mockResolvedValue(42),
};
const mockRfaService = {
findAll: jest.fn().mockResolvedValue({
data: [
{
publicId: 'rfa-uuid-1',
correspondence: {
correspondenceNumber: 'RFA-001',
},
revisions: [
{
revisionLabel: 'A',
issuedDate: new Date('2026-01-01T00:00:00Z'),
rfaRevision: {
statusCode: {
statusCode: 'APPROVED',
},
items: [{}, {}],
respondedAt: new Date('2026-01-02T00:00:00Z'),
},
},
],
},
],
}),
};
const mockShopDrawingService = {
findAll: jest.fn().mockResolvedValue({
data: [
{
publicId: 'drawing-uuid-1',
drawingNumber: 'DRW-001',
title: 'Shop Drawing 1',
status: 'APPROVED',
currentRevision: {
revisionLabel: 'B',
},
},
],
}),
};
const mockTransmittalService = {
findAll: jest.fn().mockResolvedValue({
data: [
{
correspondence: {
publicId: 'transmittal-uuid-1',
correspondenceNumber: 'TRN-001',
revisions: [
{
status: {
statusCode: 'ISSUED',
},
subject: 'Transmittal Subject 1',
issuedDate: new Date('2026-02-01T00:00:00Z'),
},
],
},
},
],
}),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
RfaToolService,
DrawingToolService,
TransmittalToolService,
{ provide: AbilityFactory, useValue: mockAbilityFactory },
{ provide: UuidResolverService, useValue: mockUuidResolver },
{ provide: RfaService, useValue: mockRfaService },
{ provide: ShopDrawingService, useValue: mockShopDrawingService },
{ provide: TransmittalService, useValue: mockTransmittalService },
],
}).compile();
rfaToolService = module.get<RfaToolService>(RfaToolService);
drawingToolService = module.get<DrawingToolService>(DrawingToolService);
transmittalToolService = module.get<TransmittalToolService>(
TransmittalToolService
);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('RfaToolService', () => {
it('ควรดึงและแปลงข้อมูล RFA สำเร็จ (Happy Path)', async () => {
mockAbility.can.mockReturnValue(true);
const result = await rfaToolService.getRfa(mockContext);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toHaveLength(1);
expect(result.data[0]).toEqual({
publicId: 'rfa-uuid-1',
rfaNumber: 'RFA-001',
revisionCode: 'A',
statusCode: 'APPROVED',
drawingCount: 2,
submittedAt: '2026-01-01T00:00:00.000Z',
respondedAt: '2026-01-02T00:00:00.000Z',
contractPublicId: '',
});
}
});
it('ควรปฏิเสธการเข้าถึงเมื่อไม่มีสิทธิ์ (CASL FORBIDDEN)', async () => {
mockAbility.can.mockReturnValue(false);
const result = await rfaToolService.getRfa(mockContext);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe('FORBIDDEN');
}
});
it('ควรจัดการข้อผิดพลาดระบบได้อย่างสง่างาม (SERVICE_ERROR)', async () => {
mockAbility.can.mockReturnValue(true);
mockRfaService.findAll.mockRejectedValueOnce(
new Error('Database Timeout')
);
const result = await rfaToolService.getRfa(mockContext);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe('SERVICE_ERROR');
}
});
});
describe('DrawingToolService', () => {
it('ควรดึงและแปลงข้อมูล Shop Drawing สำเร็จ (Happy Path)', async () => {
mockAbility.can.mockReturnValue(true);
const result = await drawingToolService.getDrawing(mockContext);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toHaveLength(1);
expect(result.data[0]).toEqual({
publicId: 'drawing-uuid-1',
drawingNumber: 'DRW-001',
title: 'Shop Drawing 1',
statusCode: 'APPROVED',
drawingType: 'SHOP',
latestRevision: 'B',
contractPublicId: '',
});
}
});
it('ควรปฏิเสธการเข้าถึงเมื่อไม่มีสิทธิ์ (CASL FORBIDDEN)', async () => {
mockAbility.can.mockReturnValue(false);
const result = await drawingToolService.getDrawing(mockContext);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe('FORBIDDEN');
}
});
it('ควรจัดการข้อผิดพลาดระบบได้อย่างสง่างาม (SERVICE_ERROR)', async () => {
mockAbility.can.mockReturnValue(true);
mockShopDrawingService.findAll.mockRejectedValueOnce(
new Error('DB Error')
);
const result = await drawingToolService.getDrawing(mockContext);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe('SERVICE_ERROR');
}
});
});
describe('TransmittalToolService', () => {
it('ควรดึงและแปลงข้อมูล Transmittal สำเร็จ (Happy Path)', async () => {
mockAbility.can.mockReturnValue(true);
const result = await transmittalToolService.getTransmittal(mockContext);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toHaveLength(1);
expect(result.data[0]).toEqual({
publicId: 'transmittal-uuid-1',
transmittalNumber: 'TRN-001',
statusCode: 'ISSUED',
subject: 'Transmittal Subject 1',
issuedAt: '2026-02-01T00:00:00.000Z',
projectPublicId: mockContext.projectPublicId,
});
}
});
it('ควรปฏิเสธการเข้าถึงเมื่อไม่มีสิทธิ์ (CASL FORBIDDEN)', async () => {
mockAbility.can.mockReturnValue(false);
const result = await transmittalToolService.getTransmittal(mockContext);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe('FORBIDDEN');
}
});
it('ควรจัดการข้อผิดพลาดระบบได้อย่างสง่างาม (SERVICE_ERROR)', async () => {
mockAbility.can.mockReturnValue(true);
mockTransmittalService.findAll.mockRejectedValueOnce(
new Error('Elastic Error')
);
const result = await transmittalToolService.getTransmittal(mockContext);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe('SERVICE_ERROR');
}
});
});
});
@@ -0,0 +1,43 @@
// File: src/modules/ai/tool/ai-tool.module.ts
// Change Log
// - 2026-05-19: สร้าง AiToolModule — submodule สำหรับ AI Tool Layer (ADR-025).
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AiToolRegistryService } from './ai-tool-registry.service';
import { RfaToolService } from './rfa-tool.service';
import { DrawingToolService } from './drawing-tool.service';
import { TransmittalToolService } from './transmittal-tool.service';
import { AiAuditLog } from '../entities/ai-audit-log.entity';
import { RfaModule } from '../../rfa/rfa.module';
import { DrawingModule } from '../../drawing/drawing.module';
import { TransmittalModule } from '../../transmittal/transmittal.module';
import { CaslModule } from '../../../common/auth/casl/casl.module';
import { CommonModule } from '../../../common/common.module';
/**
* AiToolModule — จัดการ Tool Registry และ Tool Service Handlers
* import โดย AiModule เพื่อใช้ AiToolRegistryService ใน AI Gateway (ADR-025)
*/
@Module({
imports: [
// Entity สำหรับ Audit Logging (FR-005)
TypeOrmModule.forFeature([AiAuditLog]),
// Domain Modules สำหรับ Tool Services
RfaModule,
DrawingModule,
TransmittalModule,
// CASL สำหรับ Authorization enforcement ใน Tool Handlers
CaslModule,
// CommonModule สำหรับ UuidResolverService
CommonModule,
],
providers: [
AiToolRegistryService,
RfaToolService,
DrawingToolService,
TransmittalToolService,
],
exports: [AiToolRegistryService],
})
export class AiToolModule {}
@@ -0,0 +1,93 @@
// File: src/modules/ai/tool/drawing-tool.service.ts
// Change Log
// - 2026-05-19: สร้าง DrawingToolService — Tool Handler สำหรับ Intent GET_DRAWING (ADR-025, ADR-016, ADR-019).
import { Injectable, Logger } from '@nestjs/common';
import { AbilityFactory } from '../../../common/auth/casl/ability.factory';
import { ShopDrawingService } from '../../drawing/shop-drawing.service';
import { UuidResolverService } from '../../../common/services/uuid-resolver.service';
import { ToolCallResult } from './types/tool-call-result.type';
import { ToolHandlerContext } from './types/tool-handler-context.type';
import { DrawingToolResult } from './types/drawing-tool-result.type';
interface ShopDrawingTransformed {
publicId: string;
drawingNumber?: string;
title?: string;
status?: string;
currentRevision?: {
revisionLabel?: string;
};
}
@Injectable()
export class DrawingToolService {
private readonly logger = new Logger(DrawingToolService.name);
constructor(
private readonly shopDrawingService: ShopDrawingService,
private readonly abilityFactory: AbilityFactory,
private readonly uuidResolver: UuidResolverService
) {}
/**
* ดึงข้อมูล Drawing (Shop Drawing) สำหรับ LLM context
* - ตรวจสอบสิทธิ์ด้วย CASL (ADR-016)
* - คืนเฉพาะ publicId + metadata ตาม ADR-019
* - จัดการ error แบบ Graceful Degradation (ADR-007)
*/
async getDrawing(
context: ToolHandlerContext
): Promise<ToolCallResult<DrawingToolResult[]>> {
// ตรวจสอบสิทธิ์ด้วย CASL ก่อนดึงข้อมูล
const ability = this.abilityFactory.createForUser(context.requestUser, {});
if (!ability.can('read', 'drawing')) {
this.logger.warn(
`ผู้ใช้ ${context.requestUser.publicId} ไม่มีสิทธิ์อ่าน Drawing`
);
return {
ok: false,
reason: 'FORBIDDEN',
message: 'คุณไม่มีสิทธิ์อ่านข้อมูล Drawing ในโครงการนี้',
};
}
try {
// แปลง projectPublicId → internal project id (ADR-019)
const internalProjectId = await this.uuidResolver.resolveProjectId(
context.projectPublicId
);
// ดึงข้อมูล Shop Drawing
const result = await this.shopDrawingService.findAll({
projectId: internalProjectId,
page: 1,
limit: 20,
});
// Map ผลลัพธ์ไปยัง DrawingToolResult — ห้าม expose integer id (ADR-019)
const data = result.data as unknown as ShopDrawingTransformed[];
const toolResults: DrawingToolResult[] = data
.filter((drawing) => drawing.publicId)
.map((drawing) => {
const latestRev = drawing.currentRevision;
return {
publicId: drawing.publicId,
drawingNumber: drawing.drawingNumber ?? '',
title: drawing.title ?? '',
statusCode: drawing.status ?? 'UNKNOWN',
drawingType: 'SHOP' as const,
latestRevision: latestRev?.revisionLabel ?? null,
contractPublicId: '', // เพิ่มภายหลังเมื่อ contract มี publicId
};
});
return { ok: true, data: toolResults };
} catch (error: unknown) {
this.logger.error(
`DrawingToolService.getDrawing เกิดข้อผิดพลาด: ${(error as Error).message}`
);
return {
ok: false,
reason: 'SERVICE_ERROR',
message: 'เกิดข้อผิดพลาดในการดึงข้อมูล Drawing กรุณาลองใหม่',
};
}
}
}
@@ -0,0 +1,94 @@
// File: src/modules/ai/tool/rfa-tool.service.ts
// Change Log
// - 2026-05-19: สร้าง RfaToolService — Tool Handler สำหรับ Intent GET_RFA (ADR-025, ADR-016, ADR-019).
import { Injectable, Logger } from '@nestjs/common';
import { AbilityFactory } from '../../../common/auth/casl/ability.factory';
import { RfaService } from '../../rfa/rfa.service';
import { UuidResolverService } from '../../../common/services/uuid-resolver.service';
import { ToolCallResult } from './types/tool-call-result.type';
import { ToolHandlerContext } from './types/tool-handler-context.type';
import { RfaToolResult } from './types/rfa-tool-result.type';
@Injectable()
export class RfaToolService {
private readonly logger = new Logger(RfaToolService.name);
constructor(
private readonly rfaService: RfaService,
private readonly abilityFactory: AbilityFactory,
private readonly uuidResolver: UuidResolverService
) {}
/**
* ดึงข้อมูล RFA สำหรับ LLM context
* - ตรวจสอบสิทธิ์ด้วย CASL (ADR-016)
* - คืนเฉพาะ publicId + business codes ตาม ADR-019
* - จัดการ error แบบ Graceful Degradation (ADR-007)
*/
async getRfa(
context: ToolHandlerContext
): Promise<ToolCallResult<RfaToolResult[]>> {
// ตรวจสอบสิทธิ์ด้วย CASL ก่อนดึงข้อมูล
const ability = this.abilityFactory.createForUser(context.requestUser, {});
if (!ability.can('read', 'rfa')) {
this.logger.warn(
`ผู้ใช้ ${context.requestUser.publicId} ไม่มีสิทธิ์อ่าน RFA`
);
return {
ok: false,
reason: 'FORBIDDEN',
message: 'คุณไม่มีสิทธิ์อ่านข้อมูล RFA ในโครงการนี้',
};
}
try {
// แปลง projectPublicId → internal project id (ADR-019)
const internalProjectId = await this.uuidResolver.resolveProjectId(
context.projectPublicId
);
// ดึงข้อมูล RFA จาก RfaService
const result = await this.rfaService.findAll(
{
projectId: internalProjectId,
revisionStatus: 'CURRENT',
limit: 20,
page: 1,
},
context.requestUser
);
// Map ผลลัพธ์ไปยัง RfaToolResult — ห้าม expose integer id (ADR-019)
const toolResults: RfaToolResult[] = result.data
.filter((rfa) => rfa.publicId)
.map((rfa) => {
const currentRevision = rfa.revisions?.[0];
const rfaRevision = currentRevision?.rfaRevision;
return {
publicId: rfa.publicId as string,
rfaNumber: rfa.correspondence?.correspondenceNumber ?? '',
revisionCode: currentRevision?.revisionLabel ?? '0',
statusCode: rfaRevision?.statusCode?.statusCode ?? 'UNKNOWN',
drawingCount: rfaRevision?.items?.length ?? 0,
submittedAt: currentRevision?.issuedDate
? currentRevision.issuedDate.toISOString()
: null,
respondedAt: rfaRevision?.respondedAt
? new Date(
rfaRevision.respondedAt as string | number | Date
).toISOString()
: null,
contractPublicId: '', // Contract publicId — ถ้า contract entity มี publicId ให้เพิ่มทีหลัง
};
});
return { ok: true, data: toolResults };
} catch (error: unknown) {
this.logger.error(
`RfaToolService.getRfa เกิดข้อผิดพลาด: ${(error as Error).message}`
);
return {
ok: false,
reason: 'SERVICE_ERROR',
message: 'เกิดข้อผิดพลาดในการดึงข้อมูล RFA กรุณาลองใหม่',
};
}
}
}
@@ -0,0 +1,83 @@
// File: src/modules/ai/tool/transmittal-tool.service.ts
// Change Log
// - 2026-05-19: สร้าง TransmittalToolService — Tool Handler สำหรับ Intent GET_TRANSMITTAL (ADR-025, ADR-016, ADR-019).
import { Injectable, Logger } from '@nestjs/common';
import { AbilityFactory } from '../../../common/auth/casl/ability.factory';
import { TransmittalService } from '../../transmittal/transmittal.service';
import { UuidResolverService } from '../../../common/services/uuid-resolver.service';
import { ToolCallResult } from './types/tool-call-result.type';
import { ToolHandlerContext } from './types/tool-handler-context.type';
import { TransmittalToolResult } from './types/transmittal-tool-result.type';
@Injectable()
export class TransmittalToolService {
private readonly logger = new Logger(TransmittalToolService.name);
constructor(
private readonly transmittalService: TransmittalService,
private readonly abilityFactory: AbilityFactory,
private readonly uuidResolver: UuidResolverService
) {}
/**
* ดึงข้อมูล Transmittal สำหรับ LLM context
* - ตรวจสอบสิทธิ์ด้วย CASL (ADR-016)
* - คืนเฉพาะ publicId + business codes ตาม ADR-019
* - จัดการ error แบบ Graceful Degradation (ADR-007)
*/
async getTransmittal(
context: ToolHandlerContext
): Promise<ToolCallResult<TransmittalToolResult[]>> {
// ตรวจสอบสิทธิ์ด้วย CASL ก่อนดึงข้อมูล
const ability = this.abilityFactory.createForUser(context.requestUser, {});
if (!ability.can('read', 'transmittal')) {
this.logger.warn(
`ผู้ใช้ ${context.requestUser.publicId} ไม่มีสิทธิ์อ่าน Transmittal`
);
return {
ok: false,
reason: 'FORBIDDEN',
message: 'คุณไม่มีสิทธิ์อ่านข้อมูล Transmittal ในโครงการนี้',
};
}
try {
// แปลง projectPublicId → internal project id (ADR-019)
const internalProjectId = await this.uuidResolver.resolveProjectId(
context.projectPublicId
);
// ดึงข้อมูล Transmittal
const result = await this.transmittalService.findAll({
projectId: internalProjectId,
page: 1,
limit: 20,
});
// Map ผลลัพธ์ไปยัง TransmittalToolResult — ห้าม expose integer id (ADR-019)
const toolResults: TransmittalToolResult[] = result.data
.filter((t) => t.correspondence?.publicId)
.map((t) => {
const currentRevision = t.correspondence?.revisions?.[0];
return {
publicId: t.correspondence.publicId,
transmittalNumber: t.correspondence?.correspondenceNumber ?? '',
statusCode: currentRevision?.status?.statusCode ?? 'UNKNOWN',
subject: currentRevision?.subject ?? '',
issuedAt: currentRevision?.issuedDate
? currentRevision.issuedDate.toISOString()
: null,
projectPublicId: context.projectPublicId,
};
});
return { ok: true, data: toolResults };
} catch (error: unknown) {
this.logger.error(
`TransmittalToolService.getTransmittal เกิดข้อผิดพลาด: ${(error as Error).message}`
);
return {
ok: false,
reason: 'SERVICE_ERROR',
message: 'เกิดข้อผิดพลาดในการดึงข้อมูล Transmittal กรุณาลองใหม่',
};
}
}
}
@@ -0,0 +1,24 @@
// File: src/modules/ai/tool/types/drawing-tool-result.type.ts
// Change Log
// - 2026-05-19: สร้าง DrawingToolResult DTO สำหรับ AI Tool Layer (ADR-019, ADR-025).
/**
* ผลลัพธ์ Drawing สำหรับ LLM Context
* ปฏิบัติตาม ADR-019: ไม่มี Integer Primary Key (`id`)
*/
export interface DrawingToolResult {
/** UUID ของ Drawing (ADR-019) */
publicId: string;
/** เลขที่ Drawing */
drawingNumber: string;
/** ชื่อ Drawing */
title: string;
/** รหัสสถานะ เช่น ACTIVE, SUPERSEDED */
statusCode: string;
/** ประเภท Drawing: SHOP หรือ AS_BUILT */
drawingType: 'SHOP' | 'AS_BUILT';
/** Revision ล่าสุด */
latestRevision: string | null;
/** UUID ของ Contract (ADR-019) */
contractPublicId: string;
}
@@ -0,0 +1,27 @@
// File: src/modules/ai/tool/types/rfa-tool-result.type.ts
// Change Log
// - 2026-05-19: สร้าง RfaToolResult DTO สำหรับ AI Tool Layer (ADR-019, ADR-025).
/**
* ผลลัพธ์ RFA สำหรับ LLM Context
* ปฏิบัติตาม ADR-019: ไม่มี Integer Primary Key (`id`),
* ใช้เฉพาะ publicId และ Business Codes
*/
export interface RfaToolResult {
/** UUID ของ RFA (ADR-019) */
publicId: string;
/** เลขที่เอกสาร RFA */
rfaNumber: string;
/** รหัส Revision */
revisionCode: string;
/** รหัสสถานะ เช่น DFT, FAP, APP */
statusCode: string;
/** จำนวน Drawing ที่อ้างอิง */
drawingCount: number;
/** วันที่ส่ง (ISO 8601 หรือ null) */
submittedAt: string | null;
/** วันที่ตอบกลับ (ISO 8601 หรือ null) */
respondedAt: string | null;
/** UUID ของ Contract ที่เกี่ยวข้อง (ADR-019) */
contractPublicId: string;
}
@@ -0,0 +1,16 @@
// File: src/modules/ai/tool/types/server-intent.enum.ts
// Change Log
// - 2026-05-19: สร้าง ServerIntent enum สำหรับ AI Tool Layer (ADR-024, ADR-025).
/**
* Server-side Intent codes ที่ AI Gateway รองรับ
* ทุก Intent จะถูก map ไปยัง Tool Handler ใน AiToolRegistryService
*/
export enum ServerIntent {
/** ดึงข้อมูล RFA สำหรับ LLM context */
GET_RFA = 'GET_RFA',
/** ดึงข้อมูล Drawing (Shop/As-Built) สำหรับ LLM context */
GET_DRAWING = 'GET_DRAWING',
/** ดึงข้อมูล Transmittal สำหรับ LLM context */
GET_TRANSMITTAL = 'GET_TRANSMITTAL',
}
@@ -0,0 +1,22 @@
// File: src/modules/ai/tool/types/tool-call-result.type.ts
// Change Log
// - 2026-05-19: สร้าง ToolCallReason และ ToolCallResult<T> สำหรับ AI Tool Layer (ADR-025, ADR-007, ADR-019).
/**
* ประเภทของ Reason เมื่อ Tool ทำงานไม่สำเร็จ
* ตาม ADR-007 Layered Error Classification
*/
export type ToolCallReason =
| 'FORBIDDEN' // ไม่มีสิทธิ์ (CASL fail)
| 'NOT_FOUND' // ไม่พบข้อมูล
| 'INVALID_PARAMS' // พารามิเตอร์ไม่ถูกต้อง
| 'SERVICE_ERROR'; // ข้อผิดพลาดจาก Service layer
/**
* ผลลัพธ์จากการเรียก Tool — Discriminated Union
* ok: true → data พร้อมใช้งาน
* ok: false → reason บอกสาเหตุ, message สำหรับ LLM context
*/
export type ToolCallResult<T> =
| { ok: true; data: T }
| { ok: false; reason: ToolCallReason; message: string };
@@ -0,0 +1,18 @@
// File: src/modules/ai/tool/types/tool-handler-context.type.ts
// Change Log
// - 2026-05-19: สร้าง ToolHandlerContext สำหรับส่ง context ไปยัง Tool Handlers (ADR-025).
import { User } from '../../../user/entities/user.entity';
/**
* Context ที่ส่งไปยัง Tool Handler ทุกตัว
* ใช้สำหรับ CASL authorization และ query filtering
*/
export interface ToolHandlerContext {
/** User ที่ร้องขอ — ใช้สำหรับ CASL check */
requestUser: User;
/** UUID ของ Project ที่ต้องการดึงข้อมูล (ADR-023A: mandatory for Qdrant isolation) */
projectPublicId: string;
/** Parameters เพิ่มเติม (เช่น statusCode, limit, search) */
params?: Record<string, unknown>;
}
@@ -0,0 +1,22 @@
// File: src/modules/ai/tool/types/transmittal-tool-result.type.ts
// Change Log
// - 2026-05-19: สร้าง TransmittalToolResult DTO สำหรับ AI Tool Layer (ADR-019, ADR-025).
/**
* ผลลัพธ์ Transmittal สำหรับ LLM Context
* ปฏิบัติตาม ADR-019: ไม่มี Integer Primary Key (`id`)
*/
export interface TransmittalToolResult {
/** UUID ของ Transmittal (ADR-019) */
publicId: string;
/** เลขที่เอกสาร Transmittal */
transmittalNumber: string;
/** รหัสสถานะ */
statusCode: string;
/** หัวข้อ */
subject: string;
/** วันที่ออก */
issuedAt: string | null;
/** UUID ของ Project (ADR-019) */
projectPublicId: string;
}