690519:1631 224 to 226 AI #01
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
// File: src/modules/ai/tool/ai-tool-registry.service.spec.ts
|
||||
// Change Log
|
||||
// - 2026-05-19: สร้าง Unit Test สำหรับ AiToolRegistryService (ADR-025).
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { AiToolRegistryService } from './ai-tool-registry.service';
|
||||
import { RfaToolService } from './rfa-tool.service';
|
||||
import { DrawingToolService } from './drawing-tool.service';
|
||||
import { TransmittalToolService } from './transmittal-tool.service';
|
||||
import { AiAuditLog, AiAuditStatus } from '../entities/ai-audit-log.entity';
|
||||
import { ServerIntent } from './types/server-intent.enum';
|
||||
import { ToolHandlerContext } from './types/tool-handler-context.type';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
|
||||
/**
|
||||
* Mock User สำหรับ Unit Test
|
||||
* ไม่มี assignments → CASL deny ทุก action (ทดสอบ FORBIDDEN case)
|
||||
*/
|
||||
const mockUser = {
|
||||
user_id: 1,
|
||||
publicId: 'test-uuid-user',
|
||||
assignments: [],
|
||||
} as unknown as User;
|
||||
|
||||
/** Context มาตรฐานสำหรับ test */
|
||||
const mockContext: ToolHandlerContext = {
|
||||
requestUser: mockUser,
|
||||
projectPublicId: '0195a1b2-c3d4-7000-8000-abc123def456',
|
||||
};
|
||||
|
||||
const mockAuditLogRepo = {
|
||||
create: jest.fn().mockReturnValue({}),
|
||||
save: jest.fn().mockResolvedValue({}),
|
||||
};
|
||||
|
||||
const mockRfaToolService = {
|
||||
getRfa: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, data: [{ publicId: 'rfa-uuid' }] }),
|
||||
};
|
||||
|
||||
const mockDrawingToolService = {
|
||||
getDrawing: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, data: [{ publicId: 'drawing-uuid' }] }),
|
||||
};
|
||||
|
||||
const mockTransmittalToolService = {
|
||||
getTransmittal: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, data: [{ publicId: 'transmittal-uuid' }] }),
|
||||
};
|
||||
|
||||
describe('AiToolRegistryService', () => {
|
||||
let service: AiToolRegistryService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AiToolRegistryService,
|
||||
{ provide: RfaToolService, useValue: mockRfaToolService },
|
||||
{ provide: DrawingToolService, useValue: mockDrawingToolService },
|
||||
{
|
||||
provide: TransmittalToolService,
|
||||
useValue: mockTransmittalToolService,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(AiAuditLog),
|
||||
useValue: mockAuditLogRepo,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
service = module.get<AiToolRegistryService>(AiToolRegistryService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getHandler()', () => {
|
||||
it('ควรคืน handler สำหรับ GET_RFA', () => {
|
||||
const handler = service.getHandler(ServerIntent.GET_RFA);
|
||||
expect(handler).toBeDefined();
|
||||
});
|
||||
|
||||
it('ควรคืน handler สำหรับ GET_DRAWING', () => {
|
||||
const handler = service.getHandler(ServerIntent.GET_DRAWING);
|
||||
expect(handler).toBeDefined();
|
||||
});
|
||||
|
||||
it('ควรคืน handler สำหรับ GET_TRANSMITTAL', () => {
|
||||
const handler = service.getHandler(ServerIntent.GET_TRANSMITTAL);
|
||||
expect(handler).toBeDefined();
|
||||
});
|
||||
|
||||
it('ควรคืน undefined สำหรับ intent ที่ไม่มีใน registry', () => {
|
||||
const handler = service.getHandler('UNKNOWN_INTENT' as ServerIntent);
|
||||
expect(handler).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('dispatch()', () => {
|
||||
it('ควร dispatch GET_RFA และคืนผลลัพธ์ถูกต้อง', async () => {
|
||||
const result = await service.dispatch(ServerIntent.GET_RFA, mockContext);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(Array.isArray(result.data)).toBe(true);
|
||||
}
|
||||
expect(mockRfaToolService.getRfa).toHaveBeenCalledWith(mockContext);
|
||||
});
|
||||
|
||||
it('ควรคืน INVALID_PARAMS เมื่อ intent ไม่มีใน registry', async () => {
|
||||
const result = await service.dispatch('UNKNOWN_INTENT', mockContext);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.reason).toBe('INVALID_PARAMS');
|
||||
}
|
||||
});
|
||||
|
||||
it('ควรบันทึก AuditLog ทุก dispatch', async () => {
|
||||
await service.dispatch(ServerIntent.GET_RFA, mockContext);
|
||||
expect(mockAuditLogRepo.create).toHaveBeenCalled();
|
||||
expect(mockAuditLogRepo.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ควรคืน SERVICE_ERROR เมื่อ handler โยน exception', async () => {
|
||||
mockRfaToolService.getRfa.mockRejectedValueOnce(
|
||||
new Error('DB connection failed')
|
||||
);
|
||||
const result = await service.dispatch(ServerIntent.GET_RFA, mockContext);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.reason).toBe('SERVICE_ERROR');
|
||||
}
|
||||
});
|
||||
|
||||
it('ควรบันทึก AuditLog status=FAILED เมื่อ handler คืน ok: false', async () => {
|
||||
mockRfaToolService.getRfa.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
reason: 'FORBIDDEN',
|
||||
message: 'No permission',
|
||||
});
|
||||
await service.dispatch(ServerIntent.GET_RFA, mockContext);
|
||||
expect(mockAuditLogRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: AiAuditStatus.FAILED,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
// File: src/modules/ai/tool/ai-tool-registry.service.ts
|
||||
// Change Log
|
||||
// - 2026-05-19: สร้าง AiToolRegistryService — Static Map จาก ServerIntent ไปยัง Tool Handlers (ADR-025).
|
||||
// - 2026-05-19: เพิ่ม Audit Logging สำหรับทุก Tool Execution (ADR-023, FR-005).
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { v7 as uuidv7 } from 'uuid';
|
||||
import { ServerIntent } from './types/server-intent.enum';
|
||||
import { ToolCallResult } from './types/tool-call-result.type';
|
||||
import { ToolHandlerContext } from './types/tool-handler-context.type';
|
||||
import { RfaToolService } from './rfa-tool.service';
|
||||
import { DrawingToolService } from './drawing-tool.service';
|
||||
import { TransmittalToolService } from './transmittal-tool.service';
|
||||
import { AiAuditLog, AiAuditStatus } from '../entities/ai-audit-log.entity';
|
||||
|
||||
/** ชนิดของ Tool Handler function */
|
||||
type ToolHandler = (
|
||||
context: ToolHandlerContext
|
||||
) => Promise<ToolCallResult<unknown>>;
|
||||
|
||||
@Injectable()
|
||||
export class AiToolRegistryService {
|
||||
private readonly logger = new Logger(AiToolRegistryService.name);
|
||||
/** Static Map จาก ServerIntent ไปยัง Tool Handler */
|
||||
private readonly handlerMap: Map<ServerIntent, ToolHandler>;
|
||||
|
||||
constructor(
|
||||
private readonly rfaToolService: RfaToolService,
|
||||
private readonly drawingToolService: DrawingToolService,
|
||||
private readonly transmittalToolService: TransmittalToolService,
|
||||
@InjectRepository(AiAuditLog)
|
||||
private readonly auditLogRepo: Repository<AiAuditLog>
|
||||
) {
|
||||
// ลงทะเบียน handlers ใน Static Map ตาม ADR-025
|
||||
this.handlerMap = new Map<ServerIntent, ToolHandler>([
|
||||
[ServerIntent.GET_RFA, (ctx) => this.rfaToolService.getRfa(ctx)],
|
||||
[
|
||||
ServerIntent.GET_DRAWING,
|
||||
(ctx) => this.drawingToolService.getDrawing(ctx),
|
||||
],
|
||||
[
|
||||
ServerIntent.GET_TRANSMITTAL,
|
||||
(ctx) => this.transmittalToolService.getTransmittal(ctx),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* ส่ง Intent ไปยัง Tool Handler ที่ตรงกัน
|
||||
* พร้อม Audit Logging ทุก Execution (FR-005)
|
||||
*/
|
||||
async dispatch(
|
||||
intent: string,
|
||||
context: ToolHandlerContext
|
||||
): Promise<ToolCallResult<unknown>> {
|
||||
const startMs = Date.now();
|
||||
const handler = this.handlerMap.get(intent as ServerIntent);
|
||||
if (!handler) {
|
||||
this.logger.warn(`ไม่พบ Handler สำหรับ Intent: ${intent}`);
|
||||
const result: ToolCallResult<unknown> = {
|
||||
ok: false,
|
||||
reason: 'INVALID_PARAMS',
|
||||
message: `ไม่รองรับ Intent '${intent}'`,
|
||||
};
|
||||
await this.writeAuditLog(intent, context, result, Date.now() - startMs);
|
||||
return result;
|
||||
}
|
||||
let result: ToolCallResult<unknown>;
|
||||
try {
|
||||
result = await handler(context);
|
||||
} catch (error: unknown) {
|
||||
const errMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||
this.logger.error(
|
||||
`Tool Handler สำหรับ Intent '${intent}' เกิด exception: ${errMsg}`
|
||||
);
|
||||
result = {
|
||||
ok: false,
|
||||
reason: 'SERVICE_ERROR',
|
||||
message: 'เกิดข้อผิดพลาดภายในระบบ กรุณาลองใหม่อีกครั้ง',
|
||||
};
|
||||
}
|
||||
const latencyMs = Date.now() - startMs;
|
||||
await this.writeAuditLog(intent, context, result, latencyMs);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* คืน handler function สำหรับ Unit Test (ตรวจสอบว่ามี intent นั้นอยู่หรือไม่)
|
||||
*/
|
||||
getHandler(intent: ServerIntent): ToolHandler | undefined {
|
||||
return this.handlerMap.get(intent);
|
||||
}
|
||||
|
||||
/**
|
||||
* บันทึก Audit Log ทุก Tool Execution (ADR-023 FR-005)
|
||||
* ทำแบบ fire-and-forget เพื่อไม่บล็อก response
|
||||
*/
|
||||
private async writeAuditLog(
|
||||
intent: string,
|
||||
context: ToolHandlerContext,
|
||||
result: ToolCallResult<unknown>,
|
||||
latencyMs: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
const log = this.auditLogRepo.create({
|
||||
publicId: uuidv7(),
|
||||
aiModel: 'tool-layer', // ระบุ layer ใน model field
|
||||
modelName: intent,
|
||||
processingTimeMs: latencyMs,
|
||||
status: result.ok ? AiAuditStatus.SUCCESS : AiAuditStatus.FAILED,
|
||||
errorMessage: result.ok ? undefined : result.reason,
|
||||
aiSuggestionJson: {
|
||||
intent,
|
||||
projectPublicId: context.projectPublicId,
|
||||
userPublicId: context.requestUser.publicId,
|
||||
params: context.params ?? {},
|
||||
ok: result.ok,
|
||||
reason: result.ok ? undefined : result.reason,
|
||||
},
|
||||
});
|
||||
await this.auditLogRepo.save(log);
|
||||
} catch (auditError: unknown) {
|
||||
// Audit log ล้มเหลวต้องไม่กระทบ response หลัก (ข้อผิดพลาดเป็น non-critical)
|
||||
this.logger.error(
|
||||
`เขียน Audit Log ล้มเหลว: ${(auditError as Error).message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
// File: src/modules/ai/tool/ai-tool-services.spec.ts
|
||||
// Change Log
|
||||
// - 2026-05-19: สร้าง Unit Test สำหรับ RfaToolService, DrawingToolService และ TransmittalToolService (ADR-025, ADR-016, ADR-019)
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { RfaToolService } from './rfa-tool.service';
|
||||
import { DrawingToolService } from './drawing-tool.service';
|
||||
import { TransmittalToolService } from './transmittal-tool.service';
|
||||
import { RfaService } from '../../rfa/rfa.service';
|
||||
import { ShopDrawingService } from '../../drawing/shop-drawing.service';
|
||||
import { TransmittalService } from '../../transmittal/transmittal.service';
|
||||
import { AbilityFactory } from '../../../common/auth/casl/ability.factory';
|
||||
import { UuidResolverService } from '../../../common/services/uuid-resolver.service';
|
||||
import { ToolHandlerContext } from './types/tool-handler-context.type';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
|
||||
describe('AI Tool Services (RFA, Drawing, Transmittal)', () => {
|
||||
let rfaToolService: RfaToolService;
|
||||
let drawingToolService: DrawingToolService;
|
||||
let transmittalToolService: TransmittalToolService;
|
||||
|
||||
const mockUser = {
|
||||
user_id: 1,
|
||||
publicId: 'test-user-uuid',
|
||||
} as unknown as User;
|
||||
|
||||
const mockContext: ToolHandlerContext = {
|
||||
requestUser: mockUser,
|
||||
projectPublicId: '0195a1b2-c3d4-7000-8000-abc123def456',
|
||||
};
|
||||
|
||||
const mockAbility = {
|
||||
can: jest.fn().mockReturnValue(true),
|
||||
};
|
||||
|
||||
const mockAbilityFactory = {
|
||||
createForUser: jest.fn().mockReturnValue(mockAbility),
|
||||
};
|
||||
|
||||
const mockUuidResolver = {
|
||||
resolveProjectId: jest.fn().mockResolvedValue(42),
|
||||
};
|
||||
|
||||
const mockRfaService = {
|
||||
findAll: jest.fn().mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
publicId: 'rfa-uuid-1',
|
||||
correspondence: {
|
||||
correspondenceNumber: 'RFA-001',
|
||||
},
|
||||
revisions: [
|
||||
{
|
||||
revisionLabel: 'A',
|
||||
issuedDate: new Date('2026-01-01T00:00:00Z'),
|
||||
rfaRevision: {
|
||||
statusCode: {
|
||||
statusCode: 'APPROVED',
|
||||
},
|
||||
items: [{}, {}],
|
||||
respondedAt: new Date('2026-01-02T00:00:00Z'),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
const mockShopDrawingService = {
|
||||
findAll: jest.fn().mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
publicId: 'drawing-uuid-1',
|
||||
drawingNumber: 'DRW-001',
|
||||
title: 'Shop Drawing 1',
|
||||
status: 'APPROVED',
|
||||
currentRevision: {
|
||||
revisionLabel: 'B',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
const mockTransmittalService = {
|
||||
findAll: jest.fn().mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
correspondence: {
|
||||
publicId: 'transmittal-uuid-1',
|
||||
correspondenceNumber: 'TRN-001',
|
||||
revisions: [
|
||||
{
|
||||
status: {
|
||||
statusCode: 'ISSUED',
|
||||
},
|
||||
subject: 'Transmittal Subject 1',
|
||||
issuedDate: new Date('2026-02-01T00:00:00Z'),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
RfaToolService,
|
||||
DrawingToolService,
|
||||
TransmittalToolService,
|
||||
{ provide: AbilityFactory, useValue: mockAbilityFactory },
|
||||
{ provide: UuidResolverService, useValue: mockUuidResolver },
|
||||
{ provide: RfaService, useValue: mockRfaService },
|
||||
{ provide: ShopDrawingService, useValue: mockShopDrawingService },
|
||||
{ provide: TransmittalService, useValue: mockTransmittalService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
rfaToolService = module.get<RfaToolService>(RfaToolService);
|
||||
drawingToolService = module.get<DrawingToolService>(DrawingToolService);
|
||||
transmittalToolService = module.get<TransmittalToolService>(
|
||||
TransmittalToolService
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('RfaToolService', () => {
|
||||
it('ควรดึงและแปลงข้อมูล RFA สำเร็จ (Happy Path)', async () => {
|
||||
mockAbility.can.mockReturnValue(true);
|
||||
const result = await rfaToolService.getRfa(mockContext);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0]).toEqual({
|
||||
publicId: 'rfa-uuid-1',
|
||||
rfaNumber: 'RFA-001',
|
||||
revisionCode: 'A',
|
||||
statusCode: 'APPROVED',
|
||||
drawingCount: 2,
|
||||
submittedAt: '2026-01-01T00:00:00.000Z',
|
||||
respondedAt: '2026-01-02T00:00:00.000Z',
|
||||
contractPublicId: '',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('ควรปฏิเสธการเข้าถึงเมื่อไม่มีสิทธิ์ (CASL FORBIDDEN)', async () => {
|
||||
mockAbility.can.mockReturnValue(false);
|
||||
const result = await rfaToolService.getRfa(mockContext);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.reason).toBe('FORBIDDEN');
|
||||
}
|
||||
});
|
||||
|
||||
it('ควรจัดการข้อผิดพลาดระบบได้อย่างสง่างาม (SERVICE_ERROR)', async () => {
|
||||
mockAbility.can.mockReturnValue(true);
|
||||
mockRfaService.findAll.mockRejectedValueOnce(
|
||||
new Error('Database Timeout')
|
||||
);
|
||||
const result = await rfaToolService.getRfa(mockContext);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.reason).toBe('SERVICE_ERROR');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('DrawingToolService', () => {
|
||||
it('ควรดึงและแปลงข้อมูล Shop Drawing สำเร็จ (Happy Path)', async () => {
|
||||
mockAbility.can.mockReturnValue(true);
|
||||
const result = await drawingToolService.getDrawing(mockContext);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0]).toEqual({
|
||||
publicId: 'drawing-uuid-1',
|
||||
drawingNumber: 'DRW-001',
|
||||
title: 'Shop Drawing 1',
|
||||
statusCode: 'APPROVED',
|
||||
drawingType: 'SHOP',
|
||||
latestRevision: 'B',
|
||||
contractPublicId: '',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('ควรปฏิเสธการเข้าถึงเมื่อไม่มีสิทธิ์ (CASL FORBIDDEN)', async () => {
|
||||
mockAbility.can.mockReturnValue(false);
|
||||
const result = await drawingToolService.getDrawing(mockContext);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.reason).toBe('FORBIDDEN');
|
||||
}
|
||||
});
|
||||
|
||||
it('ควรจัดการข้อผิดพลาดระบบได้อย่างสง่างาม (SERVICE_ERROR)', async () => {
|
||||
mockAbility.can.mockReturnValue(true);
|
||||
mockShopDrawingService.findAll.mockRejectedValueOnce(
|
||||
new Error('DB Error')
|
||||
);
|
||||
const result = await drawingToolService.getDrawing(mockContext);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.reason).toBe('SERVICE_ERROR');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('TransmittalToolService', () => {
|
||||
it('ควรดึงและแปลงข้อมูล Transmittal สำเร็จ (Happy Path)', async () => {
|
||||
mockAbility.can.mockReturnValue(true);
|
||||
const result = await transmittalToolService.getTransmittal(mockContext);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0]).toEqual({
|
||||
publicId: 'transmittal-uuid-1',
|
||||
transmittalNumber: 'TRN-001',
|
||||
statusCode: 'ISSUED',
|
||||
subject: 'Transmittal Subject 1',
|
||||
issuedAt: '2026-02-01T00:00:00.000Z',
|
||||
projectPublicId: mockContext.projectPublicId,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('ควรปฏิเสธการเข้าถึงเมื่อไม่มีสิทธิ์ (CASL FORBIDDEN)', async () => {
|
||||
mockAbility.can.mockReturnValue(false);
|
||||
const result = await transmittalToolService.getTransmittal(mockContext);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.reason).toBe('FORBIDDEN');
|
||||
}
|
||||
});
|
||||
|
||||
it('ควรจัดการข้อผิดพลาดระบบได้อย่างสง่างาม (SERVICE_ERROR)', async () => {
|
||||
mockAbility.can.mockReturnValue(true);
|
||||
mockTransmittalService.findAll.mockRejectedValueOnce(
|
||||
new Error('Elastic Error')
|
||||
);
|
||||
const result = await transmittalToolService.getTransmittal(mockContext);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.reason).toBe('SERVICE_ERROR');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
// File: src/modules/ai/tool/ai-tool.module.ts
|
||||
// Change Log
|
||||
// - 2026-05-19: สร้าง AiToolModule — submodule สำหรับ AI Tool Layer (ADR-025).
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AiToolRegistryService } from './ai-tool-registry.service';
|
||||
import { RfaToolService } from './rfa-tool.service';
|
||||
import { DrawingToolService } from './drawing-tool.service';
|
||||
import { TransmittalToolService } from './transmittal-tool.service';
|
||||
import { AiAuditLog } from '../entities/ai-audit-log.entity';
|
||||
import { RfaModule } from '../../rfa/rfa.module';
|
||||
import { DrawingModule } from '../../drawing/drawing.module';
|
||||
import { TransmittalModule } from '../../transmittal/transmittal.module';
|
||||
import { CaslModule } from '../../../common/auth/casl/casl.module';
|
||||
import { CommonModule } from '../../../common/common.module';
|
||||
|
||||
/**
|
||||
* AiToolModule — จัดการ Tool Registry และ Tool Service Handlers
|
||||
* import โดย AiModule เพื่อใช้ AiToolRegistryService ใน AI Gateway (ADR-025)
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
// Entity สำหรับ Audit Logging (FR-005)
|
||||
TypeOrmModule.forFeature([AiAuditLog]),
|
||||
// Domain Modules สำหรับ Tool Services
|
||||
RfaModule,
|
||||
DrawingModule,
|
||||
TransmittalModule,
|
||||
// CASL สำหรับ Authorization enforcement ใน Tool Handlers
|
||||
CaslModule,
|
||||
// CommonModule สำหรับ UuidResolverService
|
||||
CommonModule,
|
||||
],
|
||||
providers: [
|
||||
AiToolRegistryService,
|
||||
RfaToolService,
|
||||
DrawingToolService,
|
||||
TransmittalToolService,
|
||||
],
|
||||
exports: [AiToolRegistryService],
|
||||
})
|
||||
export class AiToolModule {}
|
||||
@@ -0,0 +1,93 @@
|
||||
// File: src/modules/ai/tool/drawing-tool.service.ts
|
||||
// Change Log
|
||||
// - 2026-05-19: สร้าง DrawingToolService — Tool Handler สำหรับ Intent GET_DRAWING (ADR-025, ADR-016, ADR-019).
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { AbilityFactory } from '../../../common/auth/casl/ability.factory';
|
||||
import { ShopDrawingService } from '../../drawing/shop-drawing.service';
|
||||
import { UuidResolverService } from '../../../common/services/uuid-resolver.service';
|
||||
import { ToolCallResult } from './types/tool-call-result.type';
|
||||
import { ToolHandlerContext } from './types/tool-handler-context.type';
|
||||
import { DrawingToolResult } from './types/drawing-tool-result.type';
|
||||
|
||||
interface ShopDrawingTransformed {
|
||||
publicId: string;
|
||||
drawingNumber?: string;
|
||||
title?: string;
|
||||
status?: string;
|
||||
currentRevision?: {
|
||||
revisionLabel?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DrawingToolService {
|
||||
private readonly logger = new Logger(DrawingToolService.name);
|
||||
|
||||
constructor(
|
||||
private readonly shopDrawingService: ShopDrawingService,
|
||||
private readonly abilityFactory: AbilityFactory,
|
||||
private readonly uuidResolver: UuidResolverService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* ดึงข้อมูล Drawing (Shop Drawing) สำหรับ LLM context
|
||||
* - ตรวจสอบสิทธิ์ด้วย CASL (ADR-016)
|
||||
* - คืนเฉพาะ publicId + metadata ตาม ADR-019
|
||||
* - จัดการ error แบบ Graceful Degradation (ADR-007)
|
||||
*/
|
||||
async getDrawing(
|
||||
context: ToolHandlerContext
|
||||
): Promise<ToolCallResult<DrawingToolResult[]>> {
|
||||
// ตรวจสอบสิทธิ์ด้วย CASL ก่อนดึงข้อมูล
|
||||
const ability = this.abilityFactory.createForUser(context.requestUser, {});
|
||||
if (!ability.can('read', 'drawing')) {
|
||||
this.logger.warn(
|
||||
`ผู้ใช้ ${context.requestUser.publicId} ไม่มีสิทธิ์อ่าน Drawing`
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'FORBIDDEN',
|
||||
message: 'คุณไม่มีสิทธิ์อ่านข้อมูล Drawing ในโครงการนี้',
|
||||
};
|
||||
}
|
||||
try {
|
||||
// แปลง projectPublicId → internal project id (ADR-019)
|
||||
const internalProjectId = await this.uuidResolver.resolveProjectId(
|
||||
context.projectPublicId
|
||||
);
|
||||
// ดึงข้อมูล Shop Drawing
|
||||
const result = await this.shopDrawingService.findAll({
|
||||
projectId: internalProjectId,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
});
|
||||
// Map ผลลัพธ์ไปยัง DrawingToolResult — ห้าม expose integer id (ADR-019)
|
||||
const data = result.data as unknown as ShopDrawingTransformed[];
|
||||
const toolResults: DrawingToolResult[] = data
|
||||
.filter((drawing) => drawing.publicId)
|
||||
.map((drawing) => {
|
||||
const latestRev = drawing.currentRevision;
|
||||
return {
|
||||
publicId: drawing.publicId,
|
||||
drawingNumber: drawing.drawingNumber ?? '',
|
||||
title: drawing.title ?? '',
|
||||
statusCode: drawing.status ?? 'UNKNOWN',
|
||||
drawingType: 'SHOP' as const,
|
||||
latestRevision: latestRev?.revisionLabel ?? null,
|
||||
contractPublicId: '', // เพิ่มภายหลังเมื่อ contract มี publicId
|
||||
};
|
||||
});
|
||||
return { ok: true, data: toolResults };
|
||||
} catch (error: unknown) {
|
||||
this.logger.error(
|
||||
`DrawingToolService.getDrawing เกิดข้อผิดพลาด: ${(error as Error).message}`
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'SERVICE_ERROR',
|
||||
message: 'เกิดข้อผิดพลาดในการดึงข้อมูล Drawing กรุณาลองใหม่',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// File: src/modules/ai/tool/rfa-tool.service.ts
|
||||
// Change Log
|
||||
// - 2026-05-19: สร้าง RfaToolService — Tool Handler สำหรับ Intent GET_RFA (ADR-025, ADR-016, ADR-019).
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { AbilityFactory } from '../../../common/auth/casl/ability.factory';
|
||||
import { RfaService } from '../../rfa/rfa.service';
|
||||
import { UuidResolverService } from '../../../common/services/uuid-resolver.service';
|
||||
import { ToolCallResult } from './types/tool-call-result.type';
|
||||
import { ToolHandlerContext } from './types/tool-handler-context.type';
|
||||
import { RfaToolResult } from './types/rfa-tool-result.type';
|
||||
|
||||
@Injectable()
|
||||
export class RfaToolService {
|
||||
private readonly logger = new Logger(RfaToolService.name);
|
||||
|
||||
constructor(
|
||||
private readonly rfaService: RfaService,
|
||||
private readonly abilityFactory: AbilityFactory,
|
||||
private readonly uuidResolver: UuidResolverService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* ดึงข้อมูล RFA สำหรับ LLM context
|
||||
* - ตรวจสอบสิทธิ์ด้วย CASL (ADR-016)
|
||||
* - คืนเฉพาะ publicId + business codes ตาม ADR-019
|
||||
* - จัดการ error แบบ Graceful Degradation (ADR-007)
|
||||
*/
|
||||
async getRfa(
|
||||
context: ToolHandlerContext
|
||||
): Promise<ToolCallResult<RfaToolResult[]>> {
|
||||
// ตรวจสอบสิทธิ์ด้วย CASL ก่อนดึงข้อมูล
|
||||
const ability = this.abilityFactory.createForUser(context.requestUser, {});
|
||||
if (!ability.can('read', 'rfa')) {
|
||||
this.logger.warn(
|
||||
`ผู้ใช้ ${context.requestUser.publicId} ไม่มีสิทธิ์อ่าน RFA`
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'FORBIDDEN',
|
||||
message: 'คุณไม่มีสิทธิ์อ่านข้อมูล RFA ในโครงการนี้',
|
||||
};
|
||||
}
|
||||
try {
|
||||
// แปลง projectPublicId → internal project id (ADR-019)
|
||||
const internalProjectId = await this.uuidResolver.resolveProjectId(
|
||||
context.projectPublicId
|
||||
);
|
||||
// ดึงข้อมูล RFA จาก RfaService
|
||||
const result = await this.rfaService.findAll(
|
||||
{
|
||||
projectId: internalProjectId,
|
||||
revisionStatus: 'CURRENT',
|
||||
limit: 20,
|
||||
page: 1,
|
||||
},
|
||||
context.requestUser
|
||||
);
|
||||
// Map ผลลัพธ์ไปยัง RfaToolResult — ห้าม expose integer id (ADR-019)
|
||||
const toolResults: RfaToolResult[] = result.data
|
||||
.filter((rfa) => rfa.publicId)
|
||||
.map((rfa) => {
|
||||
const currentRevision = rfa.revisions?.[0];
|
||||
const rfaRevision = currentRevision?.rfaRevision;
|
||||
return {
|
||||
publicId: rfa.publicId as string,
|
||||
rfaNumber: rfa.correspondence?.correspondenceNumber ?? '',
|
||||
revisionCode: currentRevision?.revisionLabel ?? '0',
|
||||
statusCode: rfaRevision?.statusCode?.statusCode ?? 'UNKNOWN',
|
||||
drawingCount: rfaRevision?.items?.length ?? 0,
|
||||
submittedAt: currentRevision?.issuedDate
|
||||
? currentRevision.issuedDate.toISOString()
|
||||
: null,
|
||||
respondedAt: rfaRevision?.respondedAt
|
||||
? new Date(
|
||||
rfaRevision.respondedAt as string | number | Date
|
||||
).toISOString()
|
||||
: null,
|
||||
contractPublicId: '', // Contract publicId — ถ้า contract entity มี publicId ให้เพิ่มทีหลัง
|
||||
};
|
||||
});
|
||||
return { ok: true, data: toolResults };
|
||||
} catch (error: unknown) {
|
||||
this.logger.error(
|
||||
`RfaToolService.getRfa เกิดข้อผิดพลาด: ${(error as Error).message}`
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'SERVICE_ERROR',
|
||||
message: 'เกิดข้อผิดพลาดในการดึงข้อมูล RFA กรุณาลองใหม่',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// File: src/modules/ai/tool/transmittal-tool.service.ts
|
||||
// Change Log
|
||||
// - 2026-05-19: สร้าง TransmittalToolService — Tool Handler สำหรับ Intent GET_TRANSMITTAL (ADR-025, ADR-016, ADR-019).
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { AbilityFactory } from '../../../common/auth/casl/ability.factory';
|
||||
import { TransmittalService } from '../../transmittal/transmittal.service';
|
||||
import { UuidResolverService } from '../../../common/services/uuid-resolver.service';
|
||||
import { ToolCallResult } from './types/tool-call-result.type';
|
||||
import { ToolHandlerContext } from './types/tool-handler-context.type';
|
||||
import { TransmittalToolResult } from './types/transmittal-tool-result.type';
|
||||
|
||||
@Injectable()
|
||||
export class TransmittalToolService {
|
||||
private readonly logger = new Logger(TransmittalToolService.name);
|
||||
|
||||
constructor(
|
||||
private readonly transmittalService: TransmittalService,
|
||||
private readonly abilityFactory: AbilityFactory,
|
||||
private readonly uuidResolver: UuidResolverService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* ดึงข้อมูล Transmittal สำหรับ LLM context
|
||||
* - ตรวจสอบสิทธิ์ด้วย CASL (ADR-016)
|
||||
* - คืนเฉพาะ publicId + business codes ตาม ADR-019
|
||||
* - จัดการ error แบบ Graceful Degradation (ADR-007)
|
||||
*/
|
||||
async getTransmittal(
|
||||
context: ToolHandlerContext
|
||||
): Promise<ToolCallResult<TransmittalToolResult[]>> {
|
||||
// ตรวจสอบสิทธิ์ด้วย CASL ก่อนดึงข้อมูล
|
||||
const ability = this.abilityFactory.createForUser(context.requestUser, {});
|
||||
if (!ability.can('read', 'transmittal')) {
|
||||
this.logger.warn(
|
||||
`ผู้ใช้ ${context.requestUser.publicId} ไม่มีสิทธิ์อ่าน Transmittal`
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'FORBIDDEN',
|
||||
message: 'คุณไม่มีสิทธิ์อ่านข้อมูล Transmittal ในโครงการนี้',
|
||||
};
|
||||
}
|
||||
try {
|
||||
// แปลง projectPublicId → internal project id (ADR-019)
|
||||
const internalProjectId = await this.uuidResolver.resolveProjectId(
|
||||
context.projectPublicId
|
||||
);
|
||||
// ดึงข้อมูล Transmittal
|
||||
const result = await this.transmittalService.findAll({
|
||||
projectId: internalProjectId,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
});
|
||||
// Map ผลลัพธ์ไปยัง TransmittalToolResult — ห้าม expose integer id (ADR-019)
|
||||
const toolResults: TransmittalToolResult[] = result.data
|
||||
.filter((t) => t.correspondence?.publicId)
|
||||
.map((t) => {
|
||||
const currentRevision = t.correspondence?.revisions?.[0];
|
||||
return {
|
||||
publicId: t.correspondence.publicId,
|
||||
transmittalNumber: t.correspondence?.correspondenceNumber ?? '',
|
||||
statusCode: currentRevision?.status?.statusCode ?? 'UNKNOWN',
|
||||
subject: currentRevision?.subject ?? '',
|
||||
issuedAt: currentRevision?.issuedDate
|
||||
? currentRevision.issuedDate.toISOString()
|
||||
: null,
|
||||
projectPublicId: context.projectPublicId,
|
||||
};
|
||||
});
|
||||
return { ok: true, data: toolResults };
|
||||
} catch (error: unknown) {
|
||||
this.logger.error(
|
||||
`TransmittalToolService.getTransmittal เกิดข้อผิดพลาด: ${(error as Error).message}`
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'SERVICE_ERROR',
|
||||
message: 'เกิดข้อผิดพลาดในการดึงข้อมูล Transmittal กรุณาลองใหม่',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// File: src/modules/ai/tool/types/drawing-tool-result.type.ts
|
||||
// Change Log
|
||||
// - 2026-05-19: สร้าง DrawingToolResult DTO สำหรับ AI Tool Layer (ADR-019, ADR-025).
|
||||
|
||||
/**
|
||||
* ผลลัพธ์ Drawing สำหรับ LLM Context
|
||||
* ปฏิบัติตาม ADR-019: ไม่มี Integer Primary Key (`id`)
|
||||
*/
|
||||
export interface DrawingToolResult {
|
||||
/** UUID ของ Drawing (ADR-019) */
|
||||
publicId: string;
|
||||
/** เลขที่ Drawing */
|
||||
drawingNumber: string;
|
||||
/** ชื่อ Drawing */
|
||||
title: string;
|
||||
/** รหัสสถานะ เช่น ACTIVE, SUPERSEDED */
|
||||
statusCode: string;
|
||||
/** ประเภท Drawing: SHOP หรือ AS_BUILT */
|
||||
drawingType: 'SHOP' | 'AS_BUILT';
|
||||
/** Revision ล่าสุด */
|
||||
latestRevision: string | null;
|
||||
/** UUID ของ Contract (ADR-019) */
|
||||
contractPublicId: string;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// File: src/modules/ai/tool/types/rfa-tool-result.type.ts
|
||||
// Change Log
|
||||
// - 2026-05-19: สร้าง RfaToolResult DTO สำหรับ AI Tool Layer (ADR-019, ADR-025).
|
||||
|
||||
/**
|
||||
* ผลลัพธ์ RFA สำหรับ LLM Context
|
||||
* ปฏิบัติตาม ADR-019: ไม่มี Integer Primary Key (`id`),
|
||||
* ใช้เฉพาะ publicId และ Business Codes
|
||||
*/
|
||||
export interface RfaToolResult {
|
||||
/** UUID ของ RFA (ADR-019) */
|
||||
publicId: string;
|
||||
/** เลขที่เอกสาร RFA */
|
||||
rfaNumber: string;
|
||||
/** รหัส Revision */
|
||||
revisionCode: string;
|
||||
/** รหัสสถานะ เช่น DFT, FAP, APP */
|
||||
statusCode: string;
|
||||
/** จำนวน Drawing ที่อ้างอิง */
|
||||
drawingCount: number;
|
||||
/** วันที่ส่ง (ISO 8601 หรือ null) */
|
||||
submittedAt: string | null;
|
||||
/** วันที่ตอบกลับ (ISO 8601 หรือ null) */
|
||||
respondedAt: string | null;
|
||||
/** UUID ของ Contract ที่เกี่ยวข้อง (ADR-019) */
|
||||
contractPublicId: string;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// File: src/modules/ai/tool/types/server-intent.enum.ts
|
||||
// Change Log
|
||||
// - 2026-05-19: สร้าง ServerIntent enum สำหรับ AI Tool Layer (ADR-024, ADR-025).
|
||||
|
||||
/**
|
||||
* Server-side Intent codes ที่ AI Gateway รองรับ
|
||||
* ทุก Intent จะถูก map ไปยัง Tool Handler ใน AiToolRegistryService
|
||||
*/
|
||||
export enum ServerIntent {
|
||||
/** ดึงข้อมูล RFA สำหรับ LLM context */
|
||||
GET_RFA = 'GET_RFA',
|
||||
/** ดึงข้อมูล Drawing (Shop/As-Built) สำหรับ LLM context */
|
||||
GET_DRAWING = 'GET_DRAWING',
|
||||
/** ดึงข้อมูล Transmittal สำหรับ LLM context */
|
||||
GET_TRANSMITTAL = 'GET_TRANSMITTAL',
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// File: src/modules/ai/tool/types/tool-call-result.type.ts
|
||||
// Change Log
|
||||
// - 2026-05-19: สร้าง ToolCallReason และ ToolCallResult<T> สำหรับ AI Tool Layer (ADR-025, ADR-007, ADR-019).
|
||||
|
||||
/**
|
||||
* ประเภทของ Reason เมื่อ Tool ทำงานไม่สำเร็จ
|
||||
* ตาม ADR-007 Layered Error Classification
|
||||
*/
|
||||
export type ToolCallReason =
|
||||
| 'FORBIDDEN' // ไม่มีสิทธิ์ (CASL fail)
|
||||
| 'NOT_FOUND' // ไม่พบข้อมูล
|
||||
| 'INVALID_PARAMS' // พารามิเตอร์ไม่ถูกต้อง
|
||||
| 'SERVICE_ERROR'; // ข้อผิดพลาดจาก Service layer
|
||||
|
||||
/**
|
||||
* ผลลัพธ์จากการเรียก Tool — Discriminated Union
|
||||
* ok: true → data พร้อมใช้งาน
|
||||
* ok: false → reason บอกสาเหตุ, message สำหรับ LLM context
|
||||
*/
|
||||
export type ToolCallResult<T> =
|
||||
| { ok: true; data: T }
|
||||
| { ok: false; reason: ToolCallReason; message: string };
|
||||
@@ -0,0 +1,18 @@
|
||||
// File: src/modules/ai/tool/types/tool-handler-context.type.ts
|
||||
// Change Log
|
||||
// - 2026-05-19: สร้าง ToolHandlerContext สำหรับส่ง context ไปยัง Tool Handlers (ADR-025).
|
||||
|
||||
import { User } from '../../../user/entities/user.entity';
|
||||
|
||||
/**
|
||||
* Context ที่ส่งไปยัง Tool Handler ทุกตัว
|
||||
* ใช้สำหรับ CASL authorization และ query filtering
|
||||
*/
|
||||
export interface ToolHandlerContext {
|
||||
/** User ที่ร้องขอ — ใช้สำหรับ CASL check */
|
||||
requestUser: User;
|
||||
/** UUID ของ Project ที่ต้องการดึงข้อมูล (ADR-023A: mandatory for Qdrant isolation) */
|
||||
projectPublicId: string;
|
||||
/** Parameters เพิ่มเติม (เช่น statusCode, limit, search) */
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// File: src/modules/ai/tool/types/transmittal-tool-result.type.ts
|
||||
// Change Log
|
||||
// - 2026-05-19: สร้าง TransmittalToolResult DTO สำหรับ AI Tool Layer (ADR-019, ADR-025).
|
||||
|
||||
/**
|
||||
* ผลลัพธ์ Transmittal สำหรับ LLM Context
|
||||
* ปฏิบัติตาม ADR-019: ไม่มี Integer Primary Key (`id`)
|
||||
*/
|
||||
export interface TransmittalToolResult {
|
||||
/** UUID ของ Transmittal (ADR-019) */
|
||||
publicId: string;
|
||||
/** เลขที่เอกสาร Transmittal */
|
||||
transmittalNumber: string;
|
||||
/** รหัสสถานะ */
|
||||
statusCode: string;
|
||||
/** หัวข้อ */
|
||||
subject: string;
|
||||
/** วันที่ออก */
|
||||
issuedAt: string | null;
|
||||
/** UUID ของ Project (ADR-019) */
|
||||
projectPublicId: string;
|
||||
}
|
||||
Reference in New Issue
Block a user