690519:1631 224 to 226 AI #01
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
+107
@@ -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 {}
|
||||
+59
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user