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