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

This commit is contained in:
2026-05-19 16:31:50 +07:00
parent 3e25097470
commit ea5499123e
127 changed files with 12387 additions and 42 deletions
@@ -0,0 +1,215 @@
// File: src/modules/ai/intent-classifier/controllers/intent-admin.controller.spec.ts
// Change Log
// - 2026-05-19: สร้าง Integration test สำหรับ Admin API (T016, US1).
import { Test, TestingModule } from '@nestjs/testing';
import { Reflector } from '@nestjs/core';
import {
IntentAdminController,
IntentPatternAdminController,
} from './intent-admin.controller';
import { IntentDefinitionService } from '../services/intent-definition.service';
import { IntentPatternService } from '../services/intent-pattern.service';
import { IntentCategory } from '../interfaces/intent-category.enum';
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../../../common/guards/rbac.guard';
/** Guard stub ที่ allow ทุก request */
const mockGuard = { canActivate: () => true };
describe('IntentAdminController', () => {
let controller: IntentAdminController;
let definitionService: jest.Mocked<IntentDefinitionService>;
let patternService: jest.Mocked<IntentPatternService>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [IntentAdminController],
providers: [
{
provide: IntentDefinitionService,
useValue: {
findAll: jest.fn().mockResolvedValue([]),
findByCode: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
},
{
provide: IntentPatternService,
useValue: {
findByIntentCode: jest.fn().mockResolvedValue([]),
create: jest.fn(),
},
},
Reflector,
],
})
.overrideGuard(JwtAuthGuard)
.useValue(mockGuard)
.overrideGuard(RbacGuard)
.useValue(mockGuard)
.compile();
controller = module.get<IntentAdminController>(IntentAdminController);
definitionService = module.get(IntentDefinitionService);
patternService = module.get(IntentPatternService);
});
describe('findAll', () => {
it('ควรเรียก service.findAll พร้อม filter', async () => {
await controller.findAll('read', 'true');
expect(definitionService.findAll).toHaveBeenCalledWith({
category: 'read',
isActive: true,
});
});
it('ควรเรียก service.findAll โดยไม่มี filter', async () => {
await controller.findAll();
expect(definitionService.findAll).toHaveBeenCalledWith({
category: undefined,
isActive: undefined,
});
});
});
describe('findOne', () => {
it('ควรเรียก service.findByCode', async () => {
definitionService.findByCode.mockResolvedValue({
intentCode: 'GET_RFA',
} as never);
const result = await controller.findOne('GET_RFA');
expect(definitionService.findByCode).toHaveBeenCalledWith('GET_RFA');
expect(result).toEqual({ intentCode: 'GET_RFA' });
});
});
describe('create', () => {
it('ควรเรียก service.create ด้วย dto', async () => {
const dto = {
intentCode: 'TEST',
descriptionTh: 'ทดสอบ',
descriptionEn: 'Test',
category: IntentCategory.UTILITY,
};
definitionService.create.mockResolvedValue({
...dto,
publicId: 'uuid-1',
} as never);
await controller.create(dto);
expect(definitionService.create).toHaveBeenCalledWith(dto);
});
});
describe('update', () => {
it('ควรเรียก service.update ด้วย intentCode + dto', async () => {
definitionService.update.mockResolvedValue({
intentCode: 'GET_RFA',
descriptionTh: 'อัปเดต',
} as never);
await controller.update('GET_RFA', { descriptionTh: 'อัปเดต' });
expect(definitionService.update).toHaveBeenCalledWith('GET_RFA', {
descriptionTh: 'อัปเดต',
});
});
});
describe('findPatterns', () => {
it('ควรเรียก patternService.findByIntentCode', async () => {
await controller.findPatterns('GET_RFA');
expect(patternService.findByIntentCode).toHaveBeenCalledWith('GET_RFA');
});
});
describe('createPattern', () => {
it('ควร merge intentCode กับ dto', async () => {
const dto = { patternType: 'keyword' as const, patternValue: 'rfa' };
patternService.create.mockResolvedValue({ publicId: 'p-1' } as never);
await controller.createPattern('GET_RFA', dto);
expect(patternService.create).toHaveBeenCalledWith({
intentCode: 'GET_RFA',
...dto,
});
});
});
});
describe('IntentPatternAdminController', () => {
let controller: IntentPatternAdminController;
let patternService: jest.Mocked<IntentPatternService>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [IntentPatternAdminController],
providers: [
{
provide: IntentPatternService,
useValue: {
findByPublicId: jest.fn(),
update: jest.fn(),
remove: jest.fn().mockResolvedValue(undefined),
},
},
Reflector,
],
})
.overrideGuard(JwtAuthGuard)
.useValue(mockGuard)
.overrideGuard(RbacGuard)
.useValue(mockGuard)
.compile();
controller = module.get<IntentPatternAdminController>(
IntentPatternAdminController
);
patternService = module.get(IntentPatternService);
});
describe('findOne', () => {
it('ควรเรียก service.findByPublicId', async () => {
patternService.findByPublicId.mockResolvedValue({
publicId: 'p-1',
} as never);
const result = await controller.findOne('p-1');
expect(patternService.findByPublicId).toHaveBeenCalledWith('p-1');
expect(result).toEqual({ publicId: 'p-1' });
});
});
describe('update', () => {
it('ควรเรียก service.update', async () => {
patternService.update.mockResolvedValue({ publicId: 'p-1' } as never);
await controller.update('p-1', { patternValue: 'new' });
expect(patternService.update).toHaveBeenCalledWith('p-1', {
patternValue: 'new',
});
});
});
describe('remove', () => {
it('ควรเรียก service.remove', async () => {
await controller.remove('p-1');
expect(patternService.remove).toHaveBeenCalledWith('p-1');
});
});
});
@@ -0,0 +1,143 @@
// File: src/modules/ai/intent-classifier/controllers/intent-admin.controller.ts
// Change Log
// - 2026-05-19: สร้าง Admin Controller สำหรับจัดการ Intent Definitions/Patterns (ADR-024).
import {
Controller,
Get,
Post,
Patch,
Delete,
Param,
Body,
Query,
HttpCode,
HttpStatus,
UseGuards,
Logger,
} from '@nestjs/common';
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../../../common/guards/rbac.guard';
import { Audit } from '../../../../common/decorators/audit.decorator';
import { IntentDefinitionService } from '../services/intent-definition.service';
import { IntentPatternService } from '../services/intent-pattern.service';
import { CreateIntentDefinitionDto } from '../dto/create-intent-definition.dto';
import { UpdateIntentDefinitionDto } from '../dto/update-intent-definition.dto';
import { CreateIntentPatternDto } from '../dto/create-intent-pattern.dto';
import { UpdateIntentPatternDto } from '../dto/update-intent-pattern.dto';
import { IntentCategory } from '../interfaces/intent-category.enum';
/**
* Admin Controller สำหรับจัดการ Intent Definitions และ Patterns
* Route prefix: /admin/ai/intent-definitions
* Protected by JwtAuthGuard + RbacGuard (system admin only)
*/
@Controller('admin/ai/intent-definitions')
@UseGuards(JwtAuthGuard, RbacGuard)
export class IntentAdminController {
private readonly logger = new Logger(IntentAdminController.name);
constructor(
private readonly definitionService: IntentDefinitionService,
private readonly patternService: IntentPatternService
) {}
// ===== Intent Definitions =====
/** GET /admin/ai/intent-definitions — ดึงรายการ Intent Definitions */
@Get()
async findAll(
@Query('category') category?: IntentCategory,
@Query('isActive') isActive?: string
) {
const filter = {
category,
isActive: isActive === undefined ? undefined : isActive === 'true',
};
const data = await this.definitionService.findAll(filter);
return { data };
}
/** GET /admin/ai/intent-definitions/:intentCode — ดึงตาม intentCode */
@Get(':intentCode')
async findOne(@Param('intentCode') intentCode: string) {
return this.definitionService.findByCode(intentCode);
}
/** POST /admin/ai/intent-definitions — สร้าง Intent Definition ใหม่ */
@Post()
@HttpCode(HttpStatus.CREATED)
@Audit('intent-definition.create', 'IntentDefinition')
async create(@Body() dto: CreateIntentDefinitionDto) {
return this.definitionService.create(dto);
}
/** PATCH /admin/ai/intent-definitions/:intentCode — อัปเดต */
@Patch(':intentCode')
@Audit('intent-definition.update', 'IntentDefinition')
async update(
@Param('intentCode') intentCode: string,
@Body() dto: UpdateIntentDefinitionDto
) {
return this.definitionService.update(intentCode, dto);
}
// ===== Intent Patterns =====
/** GET /admin/ai/intent-definitions/:intentCode/patterns — ดึง Patterns */
@Get(':intentCode/patterns')
async findPatterns(@Param('intentCode') intentCode: string) {
const data = await this.patternService.findByIntentCode(intentCode);
return { data };
}
/** POST /admin/ai/intent-definitions/:intentCode/patterns — สร้าง Pattern */
@Post(':intentCode/patterns')
@HttpCode(HttpStatus.CREATED)
@Audit('intent-pattern.create', 'IntentPattern')
async createPattern(
@Param('intentCode') intentCode: string,
@Body() dto: CreateIntentPatternDto
) {
return this.patternService.create({
intentCode,
...dto,
});
}
}
/**
* Admin Controller สำหรับจัดการ Pattern โดย publicId
* Route prefix: /admin/ai/intent-patterns
*/
@Controller('admin/ai/intent-patterns')
@UseGuards(JwtAuthGuard, RbacGuard)
export class IntentPatternAdminController {
private readonly logger = new Logger(IntentPatternAdminController.name);
constructor(private readonly patternService: IntentPatternService) {}
/** GET /admin/ai/intent-patterns/:publicId — ดึง Pattern ตาม publicId */
@Get(':publicId')
async findOne(@Param('publicId') publicId: string) {
return this.patternService.findByPublicId(publicId);
}
/** PATCH /admin/ai/intent-patterns/:publicId — อัปเดต Pattern */
@Patch(':publicId')
@Audit('intent-pattern.update', 'IntentPattern')
async update(
@Param('publicId') publicId: string,
@Body() dto: UpdateIntentPatternDto
) {
return this.patternService.update(publicId, dto);
}
/** DELETE /admin/ai/intent-patterns/:publicId — Soft delete Pattern */
@Delete(':publicId')
@HttpCode(HttpStatus.NO_CONTENT)
@Audit('intent-pattern.delete', 'IntentPattern')
async remove(@Param('publicId') publicId: string) {
await this.patternService.remove(publicId);
}
}
@@ -0,0 +1,36 @@
// File: src/modules/ai/intent-classifier/controllers/intent-analytics.controller.ts
// Change Log
// - 2026-05-19: สร้าง Analytics Controller สำหรับ Intent Classification (T035, US3).
import { Controller, Get, Query, UseGuards, Logger } from '@nestjs/common';
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../../../common/guards/rbac.guard';
import { IntentAnalyticsService } from '../services/intent-analytics.service';
/**
* Analytics Controller สำหรับ Intent Classification
* Route prefix: /admin/ai/intent-analytics
* Protected by JwtAuthGuard + RbacGuard (system admin only)
*/
@Controller('admin/ai/intent-analytics')
@UseGuards(JwtAuthGuard, RbacGuard)
export class IntentAnalyticsController {
private readonly logger = new Logger(IntentAnalyticsController.name);
constructor(private readonly analyticsService: IntentAnalyticsService) {}
/**
* GET /admin/ai/intent-analytics
* ดึงสถิติ Classification ทั้งหมด
* @param from ISO date string (optional, default: 30 วันก่อน)
* @param to ISO date string (optional, default: ปัจจุบัน)
*/
@Get()
async getAnalytics(@Query('from') from?: string, @Query('to') to?: string) {
const fromDate = from ? new Date(from) : undefined;
const toDate = to ? new Date(to) : undefined;
const data = await this.analyticsService.getAnalytics(fromDate, toDate);
return { data };
}
}
@@ -0,0 +1,107 @@
// File: src/modules/ai/intent-classifier/controllers/intent-classify.controller.spec.ts
// Change Log
// - 2026-05-19: สร้าง Integration test สำหรับ Classification API (T026, US2).
import { Test, TestingModule } from '@nestjs/testing';
import { Reflector } from '@nestjs/core';
import { IntentClassifyController } from './intent-classify.controller';
import { IntentClassifierService } from '../services/intent-classifier.service';
import { ClassificationResult } from '../interfaces/classification-result.interface';
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
/** Guard stub ที่ allow ทุก request */
const mockGuard = { canActivate: () => true };
describe('IntentClassifyController', () => {
let controller: IntentClassifyController;
let classifierService: jest.Mocked<IntentClassifierService>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [IntentClassifyController],
providers: [
{
provide: IntentClassifierService,
useValue: {
classify: jest.fn(),
},
},
Reflector,
],
})
.overrideGuard(JwtAuthGuard)
.useValue(mockGuard)
.compile();
controller = module.get<IntentClassifyController>(IntentClassifyController);
classifierService = module.get(IntentClassifierService);
});
describe('classify', () => {
it('ควรเรียก service.classify ด้วย trimmed query', async () => {
const mockResult: ClassificationResult = {
intentCode: 'SUMMARIZE_DOCUMENT',
confidence: 1.0,
method: 'pattern',
latencyMs: 3,
};
classifierService.classify.mockResolvedValue(mockResult);
const result = await controller.classify({
query: ' สรุปเอกสาร ',
projectPublicId: undefined,
userPublicId: undefined,
currentDocumentId: undefined,
});
expect(classifierService.classify).toHaveBeenCalledWith({
query: 'สรุปเอกสาร',
projectPublicId: undefined,
userPublicId: undefined,
currentDocumentId: undefined,
});
expect(result.intentCode).toBe('SUMMARIZE_DOCUMENT');
expect(result.method).toBe('pattern');
});
it('ควรส่ง context parameters ไปด้วย', async () => {
const mockResult: ClassificationResult = {
intentCode: 'GET_RFA',
confidence: 0.9,
method: 'llm_fallback',
latencyMs: 500,
};
classifierService.classify.mockResolvedValue(mockResult);
await controller.classify({
query: 'show rfa',
projectPublicId: 'proj-uuid-123',
userPublicId: 'user-uuid-456',
currentDocumentId: 'doc-uuid-789',
});
expect(classifierService.classify).toHaveBeenCalledWith({
query: 'show rfa',
projectPublicId: 'proj-uuid-123',
userPublicId: 'user-uuid-456',
currentDocumentId: 'doc-uuid-789',
});
});
it('ควร return ClassificationResult', async () => {
const mockResult: ClassificationResult = {
intentCode: 'FALLBACK',
confidence: 0,
method: 'semaphore_overflow',
latencyMs: 1,
};
classifierService.classify.mockResolvedValue(mockResult);
const result = await controller.classify({
query: 'test',
});
expect(result).toEqual(mockResult);
});
});
});
@@ -0,0 +1,36 @@
// File: src/modules/ai/intent-classifier/controllers/intent-classify.controller.ts
// Change Log
// - 2026-05-19: สร้าง Classification Controller (POST /ai/intent/classify) (ADR-024).
import { Controller, Post, Body, UseGuards, Logger } from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
import { IntentClassifierService } from '../services/intent-classifier.service';
import { ClassifyQueryDto } from '../dto/classify-query.dto';
import { ClassificationResult } from '../interfaces/classification-result.interface';
/**
* Controller สำหรับ Intent Classification API
* Route: POST /ai/intent/classify
* Protected by JWT (ทุก authenticated user ใช้ได้)
*/
@Controller('ai/intent')
@UseGuards(JwtAuthGuard)
export class IntentClassifyController {
private readonly logger = new Logger(IntentClassifyController.name);
constructor(private readonly classifierService: IntentClassifierService) {}
/** POST /ai/intent/classify — Classify user query → intent */
@Throttle({ default: { limit: 30, ttl: 60000 } })
@Post('classify')
async classify(@Body() dto: ClassifyQueryDto): Promise<ClassificationResult> {
this.logger.debug(`Classifying: "${dto.query}"`);
return this.classifierService.classify({
query: dto.query.trim(),
projectPublicId: dto.projectPublicId,
userPublicId: dto.userPublicId,
currentDocumentId: dto.currentDocumentId,
});
}
}