feat(ai): unify AI architecture, implement RAG and legacy migration
This commit is contained in:
@@ -0,0 +1,486 @@
|
||||
// File: src/modules/ai/ai-ingest.service.spec.ts
|
||||
// Change Log
|
||||
// - 2026-05-14: เพิ่ม Unit Tests ครอบคลุม AiIngestService — ingest, listQueue, approve (ADR-023).
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { AiIngestService } from './ai-ingest.service';
|
||||
import { AiQueueService } from './ai-queue.service';
|
||||
import { FileStorageService } from '../../common/file-storage/file-storage.service';
|
||||
import { MigrationService } from '../migration/migration.service';
|
||||
import {
|
||||
MigrationReviewRecord,
|
||||
MigrationReviewRecordStatus,
|
||||
} from './entities/migration-review.entity';
|
||||
import { AiAuditLog } from './entities/ai-audit-log.entity';
|
||||
import { Project } from '../project/entities/project.entity';
|
||||
import { Organization } from '../organization/entities/organization.entity';
|
||||
import { CorrespondenceType } from '../correspondence/entities/correspondence-type.entity';
|
||||
import {
|
||||
BusinessException,
|
||||
NotFoundException,
|
||||
ValidationException,
|
||||
} from '../../common/exceptions';
|
||||
|
||||
// ─── Fixtures ────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeFile(
|
||||
overrides: Partial<Express.Multer.File> = {}
|
||||
): Express.Multer.File {
|
||||
return {
|
||||
fieldname: 'files',
|
||||
originalname: 'test.pdf',
|
||||
encoding: '7bit',
|
||||
mimetype: 'application/pdf',
|
||||
buffer: Buffer.from('pdf-content'),
|
||||
size: 1024,
|
||||
stream: null as unknown as NodeJS.ReadableStream,
|
||||
destination: '',
|
||||
filename: 'test.pdf',
|
||||
path: '',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makePendingRecord(
|
||||
overrides: Partial<MigrationReviewRecord> = {}
|
||||
): MigrationReviewRecord {
|
||||
return {
|
||||
id: 1,
|
||||
publicId: 'rec-uuid-001',
|
||||
batchId: 'batch-001',
|
||||
originalFileName: 'test.pdf',
|
||||
sourceAttachmentPublicId: 'att-uuid-001',
|
||||
tempAttachmentId: 10,
|
||||
extractedMetadata: { subject: 'Test' },
|
||||
confidenceScore: 0.9,
|
||||
status: MigrationReviewRecordStatus.PENDING,
|
||||
errorReason: undefined,
|
||||
version: 1,
|
||||
createdAt: new Date('2026-01-01'),
|
||||
updatedAt: new Date('2026-01-01'),
|
||||
...overrides,
|
||||
} as MigrationReviewRecord;
|
||||
}
|
||||
|
||||
// ─── Mocks ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const mockConfig = {
|
||||
get: jest.fn((key: string) => (key === 'AI_SERVICE_USER_ID' ? 1 : undefined)),
|
||||
};
|
||||
|
||||
const mockFileStorage = {
|
||||
upload: jest.fn().mockResolvedValue({ id: 10, publicId: 'att-uuid-001' }),
|
||||
};
|
||||
|
||||
const mockAiQueue = {
|
||||
enqueueIngest: jest.fn().mockResolvedValue('job-id-001'),
|
||||
};
|
||||
|
||||
const mockMigration = {
|
||||
importCorrespondence: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ publicId: 'corr-uuid-001' }),
|
||||
};
|
||||
|
||||
const mockReviewRepo = {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
createQueryBuilder: jest.fn().mockReturnValue({
|
||||
where: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getManyAndCount: jest.fn().mockResolvedValue([[], 0]),
|
||||
}),
|
||||
};
|
||||
|
||||
const mockAuditLogRepo = {
|
||||
create: jest.fn(),
|
||||
save: jest.fn().mockResolvedValue({}),
|
||||
};
|
||||
|
||||
const mockProjectRepo = {
|
||||
findOne: jest.fn().mockResolvedValue({ id: 5, publicId: 'proj-uuid-001' }),
|
||||
};
|
||||
|
||||
const mockOrgRepo = {
|
||||
findOne: jest.fn().mockResolvedValue({ id: 3, publicId: 'org-uuid-001' }),
|
||||
};
|
||||
|
||||
const mockCorrTypeRepo = {
|
||||
findOne: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ id: 2, typeCode: 'CORR', typeName: 'Correspondence' }),
|
||||
};
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('AiIngestService', () => {
|
||||
let service: AiIngestService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AiIngestService,
|
||||
{ provide: ConfigService, useValue: mockConfig },
|
||||
{ provide: FileStorageService, useValue: mockFileStorage },
|
||||
{ provide: AiQueueService, useValue: mockAiQueue },
|
||||
{ provide: MigrationService, useValue: mockMigration },
|
||||
{
|
||||
provide: getRepositoryToken(MigrationReviewRecord),
|
||||
useValue: mockReviewRepo,
|
||||
},
|
||||
{ provide: getRepositoryToken(AiAuditLog), useValue: mockAuditLogRepo },
|
||||
{ provide: getRepositoryToken(Project), useValue: mockProjectRepo },
|
||||
{ provide: getRepositoryToken(Organization), useValue: mockOrgRepo },
|
||||
{
|
||||
provide: getRepositoryToken(CorrespondenceType),
|
||||
useValue: mockCorrTypeRepo,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<AiIngestService>(AiIngestService);
|
||||
jest.clearAllMocks();
|
||||
|
||||
// ค่าเริ่มต้นของ mocks หลัง clearAllMocks
|
||||
mockConfig.get.mockImplementation((key: string) =>
|
||||
key === 'AI_SERVICE_USER_ID' ? 1 : undefined
|
||||
);
|
||||
mockFileStorage.upload.mockResolvedValue({
|
||||
id: 10,
|
||||
publicId: 'att-uuid-001',
|
||||
});
|
||||
mockAiQueue.enqueueIngest.mockResolvedValue('job-id-001');
|
||||
mockMigration.importCorrespondence.mockResolvedValue({
|
||||
publicId: 'corr-uuid-001',
|
||||
});
|
||||
mockProjectRepo.findOne.mockResolvedValue({
|
||||
id: 5,
|
||||
publicId: 'proj-uuid-001',
|
||||
});
|
||||
mockOrgRepo.findOne.mockResolvedValue({ id: 3, publicId: 'org-uuid-001' });
|
||||
mockCorrTypeRepo.findOne.mockResolvedValue({
|
||||
id: 2,
|
||||
typeCode: 'CORR',
|
||||
typeName: 'Correspondence',
|
||||
});
|
||||
mockAuditLogRepo.create.mockReturnValue({});
|
||||
mockAuditLogRepo.save.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
// ─── ingest() ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('ingest()', () => {
|
||||
it('ควร throw ValidationException เมื่อไม่มีไฟล์และไม่มี records', async () => {
|
||||
await expect(
|
||||
service.ingest({ batchId: 'batch-001', records: [] }, [])
|
||||
).rejects.toThrow(ValidationException);
|
||||
});
|
||||
|
||||
it('ควร throw ValidationException เมื่อไฟล์เกิน 50MB', async () => {
|
||||
const bigFile = makeFile({ size: 51 * 1024 * 1024 });
|
||||
await expect(
|
||||
service.ingest({ batchId: 'batch-001' }, [bigFile])
|
||||
).rejects.toThrow(ValidationException);
|
||||
});
|
||||
|
||||
it('ควร throw ValidationException เมื่อ MIME type ไม่รองรับ (image/png)', async () => {
|
||||
const pngFile = makeFile({ mimetype: 'image/png' });
|
||||
await expect(
|
||||
service.ingest({ batchId: 'batch-001' }, [pngFile])
|
||||
).rejects.toThrow(ValidationException);
|
||||
});
|
||||
|
||||
it('ควร throw ValidationException เมื่อ records JSON ไม่ถูกต้อง', async () => {
|
||||
await expect(
|
||||
service.ingest({ batchId: 'batch-001', records: '{invalid-json' }, [])
|
||||
).rejects.toThrow(ValidationException);
|
||||
});
|
||||
|
||||
it('ควรสร้าง staging record และ enqueue job เมื่อรับไฟล์ที่ถูกต้อง', async () => {
|
||||
const file = makeFile();
|
||||
const createdRecord = makePendingRecord();
|
||||
mockReviewRepo.create.mockReturnValue(createdRecord);
|
||||
mockReviewRepo.save.mockResolvedValue([createdRecord]);
|
||||
|
||||
const result = await service.ingest({ batchId: 'batch-001' }, [file]);
|
||||
|
||||
expect(mockFileStorage.upload).toHaveBeenCalledWith(file, 1);
|
||||
expect(mockReviewRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ batchId: 'batch-001' })
|
||||
);
|
||||
expect(mockAiQueue.enqueueIngest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ batchId: 'batch-001' })
|
||||
);
|
||||
expect(result).toMatchObject({ batchId: 'batch-001', queued: 1 });
|
||||
});
|
||||
|
||||
it('ควรสร้างหลาย record จาก records JSON (ไม่มีไฟล์)', async () => {
|
||||
const records = [
|
||||
{ originalFileName: 'doc-1.pdf', confidenceScore: 0.9 },
|
||||
{ originalFileName: 'doc-2.pdf', confidenceScore: 0.8 },
|
||||
];
|
||||
const createdRecord = makePendingRecord();
|
||||
mockReviewRepo.create.mockReturnValue(createdRecord);
|
||||
mockReviewRepo.save.mockResolvedValue([createdRecord, createdRecord]);
|
||||
|
||||
const result = await service.ingest(
|
||||
{ batchId: 'batch-002', records },
|
||||
[]
|
||||
);
|
||||
|
||||
expect(mockReviewRepo.create).toHaveBeenCalledTimes(2);
|
||||
expect(result.queued).toBe(2);
|
||||
});
|
||||
|
||||
it('ควรยอมรับ records เป็น JSON string', async () => {
|
||||
const recordsStr = JSON.stringify([{ originalFileName: 'doc.pdf' }]);
|
||||
const createdRecord = makePendingRecord();
|
||||
mockReviewRepo.create.mockReturnValue(createdRecord);
|
||||
mockReviewRepo.save.mockResolvedValue([createdRecord]);
|
||||
|
||||
await expect(
|
||||
service.ingest({ batchId: 'batch-003', records: recordsStr }, [])
|
||||
).resolves.toMatchObject({ batchId: 'batch-003', queued: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── deriveStatus() (ผ่าน ingest) ─────────────────────────────────────────
|
||||
|
||||
describe('deriveStatus — สถานะจาก record input', () => {
|
||||
beforeEach(() => {
|
||||
mockReviewRepo.save.mockImplementation(
|
||||
(records: MigrationReviewRecord[]) => Promise.resolve(records)
|
||||
);
|
||||
});
|
||||
|
||||
it('ควร derive REJECTED เมื่อ confidenceScore < 0.6', async () => {
|
||||
mockReviewRepo.create.mockImplementation(
|
||||
(data: Partial<MigrationReviewRecord>) =>
|
||||
({ ...data }) as MigrationReviewRecord
|
||||
);
|
||||
const records = [{ originalFileName: 'low.pdf', confidenceScore: 0.5 }];
|
||||
|
||||
await service.ingest({ batchId: 'b', records }, []);
|
||||
|
||||
expect(mockReviewRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: MigrationReviewRecordStatus.REJECTED,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('ควร derive REJECTED เมื่อมี errorReason', async () => {
|
||||
mockReviewRepo.create.mockImplementation(
|
||||
(data: Partial<MigrationReviewRecord>) =>
|
||||
({ ...data }) as MigrationReviewRecord
|
||||
);
|
||||
const records = [
|
||||
{ originalFileName: 'err.pdf', errorReason: 'OCR failed' },
|
||||
];
|
||||
|
||||
await service.ingest({ batchId: 'b', records }, []);
|
||||
|
||||
expect(mockReviewRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: MigrationReviewRecordStatus.REJECTED,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('ควร derive PENDING เมื่อ confidence >= 0.6 และไม่มี errorReason', async () => {
|
||||
mockReviewRepo.create.mockImplementation(
|
||||
(data: Partial<MigrationReviewRecord>) =>
|
||||
({ ...data }) as MigrationReviewRecord
|
||||
);
|
||||
const records = [{ originalFileName: 'ok.pdf', confidenceScore: 0.85 }];
|
||||
|
||||
await service.ingest({ batchId: 'b', records }, []);
|
||||
|
||||
expect(mockReviewRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ status: MigrationReviewRecordStatus.PENDING })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── listQueue() ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('listQueue()', () => {
|
||||
it('ควรคืน paginated response ที่ถูกต้อง', async () => {
|
||||
const items = [makePendingRecord(), makePendingRecord()];
|
||||
mockReviewRepo.createQueryBuilder.mockReturnValue({
|
||||
where: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getManyAndCount: jest.fn().mockResolvedValue([items, 25]),
|
||||
});
|
||||
|
||||
const result = await service.listQueue({ page: 2, limit: 10 });
|
||||
|
||||
expect(result.total).toBe(25);
|
||||
expect(result.page).toBe(2);
|
||||
expect(result.limit).toBe(10);
|
||||
expect(result.totalPages).toBe(3); // Math.ceil(25/10)
|
||||
expect(result.items).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('ควรใช้ค่า default page=1, limit=20 เมื่อไม่ระบุ', async () => {
|
||||
const qb = {
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getManyAndCount: jest.fn().mockResolvedValue([[], 0]),
|
||||
};
|
||||
mockReviewRepo.createQueryBuilder.mockReturnValue(qb);
|
||||
|
||||
await service.listQueue({});
|
||||
|
||||
expect(qb.skip).toHaveBeenCalledWith(0); // (1-1)*20
|
||||
expect(qb.take).toHaveBeenCalledWith(20);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── approve() ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('approve()', () => {
|
||||
const dto = {
|
||||
documentNumber: 'CORR-001',
|
||||
subject: 'Test Subject',
|
||||
categoryCode: 'CORR',
|
||||
projectPublicId: 'proj-uuid-001',
|
||||
finalMetadata: { subject: 'Corrected Subject' },
|
||||
};
|
||||
|
||||
it('ควร throw ValidationException เมื่อ idempotencyKey ว่าง', async () => {
|
||||
await expect(service.approve('rec-uuid-001', dto, '', 1)).rejects.toThrow(
|
||||
ValidationException
|
||||
);
|
||||
});
|
||||
|
||||
it('ควร throw NotFoundException เมื่อไม่พบ record', async () => {
|
||||
mockReviewRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.approve('not-found', dto, 'idem-key-001', 1)
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('ควร throw BusinessException เมื่อ record ไม่อยู่ในสถานะ PENDING (IMPORTED)', async () => {
|
||||
mockReviewRepo.findOne.mockResolvedValue(
|
||||
makePendingRecord({ status: MigrationReviewRecordStatus.IMPORTED })
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.approve('rec-uuid-001', dto, 'idem-key-001', 1)
|
||||
).rejects.toThrow(BusinessException);
|
||||
});
|
||||
|
||||
it('ควร throw NotFoundException เมื่อ Project ไม่พบ', async () => {
|
||||
mockReviewRepo.findOne.mockResolvedValue(makePendingRecord());
|
||||
mockProjectRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.approve('rec-uuid-001', dto, 'idem-key-001', 1)
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('ควรอนุมัติ record สำเร็จ — เรียก importCorrespondence และบันทึก AuditLog', async () => {
|
||||
const record = makePendingRecord();
|
||||
mockReviewRepo.findOne.mockResolvedValue(record);
|
||||
mockReviewRepo.save.mockResolvedValue({
|
||||
...record,
|
||||
status: MigrationReviewRecordStatus.IMPORTED,
|
||||
});
|
||||
|
||||
const result = await service.approve(
|
||||
'rec-uuid-001',
|
||||
dto,
|
||||
'idem-key-001',
|
||||
99
|
||||
);
|
||||
|
||||
// ตรวจสอบการเรียก importCorrespondence
|
||||
expect(mockMigration.importCorrespondence).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
documentNumber: 'CORR-001',
|
||||
subject: 'Test Subject',
|
||||
category: 'CORR',
|
||||
projectId: 5,
|
||||
}),
|
||||
'idem-key-001',
|
||||
99
|
||||
);
|
||||
|
||||
// ตรวจสอบสถานะที่ถูก save เป็น IMPORTED
|
||||
expect(mockReviewRepo.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: MigrationReviewRecordStatus.IMPORTED,
|
||||
})
|
||||
);
|
||||
|
||||
// ตรวจสอบ AuditLog ถูกสร้าง (T025)
|
||||
expect(mockAuditLogRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
documentPublicId: record.publicId,
|
||||
confirmedByUserId: 99,
|
||||
})
|
||||
);
|
||||
expect(mockAuditLogRepo.save).toHaveBeenCalled();
|
||||
|
||||
expect(result.record).toBeDefined();
|
||||
expect(result.importResult).toBeDefined();
|
||||
});
|
||||
|
||||
it('T025: AuditLog ควรบันทึก aiSuggestionJson และ humanOverrideJson (AI vs Human diff)', async () => {
|
||||
const record = makePendingRecord({
|
||||
extractedMetadata: { subject: 'AI Guess' },
|
||||
confidenceScore: 0.85,
|
||||
});
|
||||
mockReviewRepo.findOne.mockResolvedValue(record);
|
||||
mockReviewRepo.save.mockResolvedValue({
|
||||
...record,
|
||||
status: MigrationReviewRecordStatus.IMPORTED,
|
||||
});
|
||||
|
||||
await service.approve(
|
||||
'rec-uuid-001',
|
||||
{ ...dto, finalMetadata: { subject: 'Human Corrected' } },
|
||||
'idem-key-002',
|
||||
42
|
||||
);
|
||||
|
||||
expect(mockAuditLogRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
confidenceScore: expect.closeTo(0.85),
|
||||
humanOverrideJson: { subject: 'Human Corrected' },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('ควรไม่ throw แม้ auditLogRepo.save จะล้มเหลว (error ถูก swallow)', async () => {
|
||||
const record = makePendingRecord();
|
||||
mockReviewRepo.findOne.mockResolvedValue(record);
|
||||
mockReviewRepo.save.mockResolvedValue({
|
||||
...record,
|
||||
status: MigrationReviewRecordStatus.IMPORTED,
|
||||
});
|
||||
mockAuditLogRepo.save.mockRejectedValueOnce(
|
||||
new Error('DB connection lost')
|
||||
);
|
||||
|
||||
// ไม่ควร throw ออกมา — saveApprovalAuditLog มี try/catch
|
||||
await expect(
|
||||
service.approve('rec-uuid-001', dto, 'idem-key-003', 1)
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,381 @@
|
||||
// File: src/modules/ai/ai-ingest.service.ts
|
||||
// Change Log
|
||||
// - 2026-05-14: เพิ่ม service สำหรับ Legacy Migration staging queue ตาม ADR-023.
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import {
|
||||
BusinessException,
|
||||
NotFoundException,
|
||||
ValidationException,
|
||||
} from '../../common/exceptions';
|
||||
import { FileStorageService } from '../../common/file-storage/file-storage.service';
|
||||
import { MigrationService } from '../migration/migration.service';
|
||||
import {
|
||||
AiAuditLog,
|
||||
AiAuditStatus as AiStatus,
|
||||
} from './entities/ai-audit-log.entity';
|
||||
import { Project } from '../project/entities/project.entity';
|
||||
import { Organization } from '../organization/entities/organization.entity';
|
||||
import { CorrespondenceType } from '../correspondence/entities/correspondence-type.entity';
|
||||
import { AiQueueService } from './ai-queue.service';
|
||||
import {
|
||||
ApproveLegacyMigrationDto,
|
||||
LegacyMigrationIngestDto,
|
||||
LegacyMigrationQueueQueryDto,
|
||||
LegacyMigrationRecordDto,
|
||||
} from './dto/legacy-migration.dto';
|
||||
import {
|
||||
MigrationReviewRecord,
|
||||
MigrationReviewRecordStatus,
|
||||
} from './entities/migration-review.entity';
|
||||
|
||||
export interface MigrationReviewResponse {
|
||||
publicId: string;
|
||||
batchId: string;
|
||||
originalFileName: string;
|
||||
sourceAttachmentPublicId?: string;
|
||||
extractedMetadata?: Record<string, unknown>;
|
||||
confidenceScore?: number;
|
||||
status: MigrationReviewRecordStatus;
|
||||
errorReason?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface PaginatedMigrationReviewResponse {
|
||||
items: MigrationReviewResponse[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AiIngestService {
|
||||
private readonly logger = new Logger(AiIngestService.name);
|
||||
private readonly maxFileSize = 50 * 1024 * 1024;
|
||||
private readonly allowedMimeTypes = new Set<string>([
|
||||
'application/pdf',
|
||||
'application/x-pdf',
|
||||
'application/octet-stream',
|
||||
]);
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly fileStorageService: FileStorageService,
|
||||
private readonly aiQueueService: AiQueueService,
|
||||
private readonly migrationService: MigrationService,
|
||||
@InjectRepository(MigrationReviewRecord)
|
||||
private readonly reviewRepo: Repository<MigrationReviewRecord>,
|
||||
@InjectRepository(AiAuditLog)
|
||||
private readonly auditLogRepo: Repository<AiAuditLog>,
|
||||
@InjectRepository(Project)
|
||||
private readonly projectRepo: Repository<Project>,
|
||||
@InjectRepository(Organization)
|
||||
private readonly organizationRepo: Repository<Organization>,
|
||||
@InjectRepository(CorrespondenceType)
|
||||
private readonly correspondenceTypeRepo: Repository<CorrespondenceType>
|
||||
) {}
|
||||
|
||||
async ingest(
|
||||
dto: LegacyMigrationIngestDto,
|
||||
files: Express.Multer.File[]
|
||||
): Promise<{ batchId: string; queued: number; queueJobId?: string }> {
|
||||
const records = this.parseRecords(dto.records);
|
||||
const serviceUserId = this.getServiceUserId();
|
||||
const createdRecords: MigrationReviewRecord[] = [];
|
||||
const filePublicIds: string[] = [];
|
||||
|
||||
for (let index = 0; index < files.length; index += 1) {
|
||||
const file = files[index];
|
||||
this.validateFile(file);
|
||||
const attachment = await this.fileStorageService.upload(
|
||||
file,
|
||||
serviceUserId
|
||||
);
|
||||
const recordInput = this.matchRecord(records, file.originalname, index);
|
||||
filePublicIds.push(attachment.publicId);
|
||||
createdRecords.push(
|
||||
this.reviewRepo.create({
|
||||
batchId: dto.batchId,
|
||||
originalFileName: recordInput.originalFileName ?? file.originalname,
|
||||
sourceAttachmentPublicId: attachment.publicId,
|
||||
tempAttachmentId: attachment.id,
|
||||
extractedMetadata: recordInput.extractedMetadata,
|
||||
confidenceScore: recordInput.confidenceScore,
|
||||
status: this.deriveStatus(recordInput),
|
||||
errorReason: recordInput.errorReason,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
for (const recordInput of records) {
|
||||
createdRecords.push(
|
||||
this.reviewRepo.create({
|
||||
batchId: dto.batchId,
|
||||
originalFileName:
|
||||
recordInput.originalFileName ?? `${dto.batchId}-record.json`,
|
||||
extractedMetadata: recordInput.extractedMetadata,
|
||||
confidenceScore: recordInput.confidenceScore,
|
||||
status: this.deriveStatus(recordInput),
|
||||
errorReason: recordInput.errorReason,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (createdRecords.length === 0) {
|
||||
throw new ValidationException('At least one file or record is required');
|
||||
}
|
||||
|
||||
const saved = await this.reviewRepo.save(createdRecords);
|
||||
const queueJobId = await this.aiQueueService.enqueueIngest({
|
||||
batchId: dto.batchId,
|
||||
filePublicIds,
|
||||
source: dto.source === 'folder-watcher' ? 'folder-watcher' : 'api',
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`AI legacy migration batch ${dto.batchId} created ${saved.length} staging records`
|
||||
);
|
||||
|
||||
return { batchId: dto.batchId, queued: saved.length, queueJobId };
|
||||
}
|
||||
|
||||
async listQueue(
|
||||
query: LegacyMigrationQueueQueryDto
|
||||
): Promise<PaginatedMigrationReviewResponse> {
|
||||
const page = query.page ?? 1;
|
||||
const limit = query.limit ?? 20;
|
||||
const qb = this.reviewRepo.createQueryBuilder('record');
|
||||
|
||||
if (query.status) {
|
||||
qb.where('record.status = :status', { status: query.status });
|
||||
}
|
||||
|
||||
qb.orderBy('record.createdAt', 'DESC')
|
||||
.skip((page - 1) * limit)
|
||||
.take(limit);
|
||||
|
||||
const [items, total] = await qb.getManyAndCount();
|
||||
return {
|
||||
items: items.map((item) => this.toResponse(item)),
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
async approve(
|
||||
publicId: string,
|
||||
dto: ApproveLegacyMigrationDto,
|
||||
idempotencyKey: string,
|
||||
userId: number
|
||||
): Promise<{ record: MigrationReviewResponse; importResult: unknown }> {
|
||||
if (!idempotencyKey) {
|
||||
throw new ValidationException('Idempotency-Key header is required');
|
||||
}
|
||||
|
||||
const record = await this.reviewRepo.findOne({ where: { publicId } });
|
||||
if (!record) {
|
||||
throw new NotFoundException('MigrationReviewRecord', publicId);
|
||||
}
|
||||
|
||||
if (record.status !== MigrationReviewRecordStatus.PENDING) {
|
||||
throw new BusinessException(
|
||||
'AI_MIGRATION_RECORD_NOT_PENDING',
|
||||
`Migration review record ${publicId} is ${record.status}`,
|
||||
'รายการนี้ไม่อยู่ในสถานะรอตรวจสอบ',
|
||||
['รีเฟรชรายการ staging queue', 'ตรวจสอบสถานะล่าสุดก่อนอนุมัติ']
|
||||
);
|
||||
}
|
||||
|
||||
const project = await this.resolveProject(dto.projectPublicId);
|
||||
const correspondenceType = await this.resolveCorrespondenceType(
|
||||
dto.categoryCode
|
||||
);
|
||||
const sender = dto.senderOrganizationPublicId
|
||||
? await this.resolveOrganization(dto.senderOrganizationPublicId)
|
||||
: undefined;
|
||||
const receiver = dto.receiverOrganizationPublicId
|
||||
? await this.resolveOrganization(dto.receiverOrganizationPublicId)
|
||||
: undefined;
|
||||
|
||||
const importResult = await this.migrationService.importCorrespondence(
|
||||
{
|
||||
documentNumber: dto.documentNumber,
|
||||
subject: dto.subject,
|
||||
category: correspondenceType.typeCode,
|
||||
migratedBy: 'AI_STAGING_APPROVAL',
|
||||
batchId: record.batchId,
|
||||
projectId: project.id,
|
||||
senderId: sender?.id,
|
||||
receiverId: receiver?.id,
|
||||
issuedDate: dto.issuedDate,
|
||||
receivedDate: dto.receivedDate,
|
||||
body: dto.body,
|
||||
tempAttachmentId: record.tempAttachmentId,
|
||||
aiConfidence:
|
||||
record.confidenceScore === undefined
|
||||
? undefined
|
||||
: Number(record.confidenceScore),
|
||||
details: {
|
||||
aiSuggestion: record.extractedMetadata,
|
||||
humanOverride: dto.finalMetadata,
|
||||
},
|
||||
},
|
||||
idempotencyKey,
|
||||
userId
|
||||
);
|
||||
|
||||
record.status = MigrationReviewRecordStatus.IMPORTED;
|
||||
record.extractedMetadata = {
|
||||
...(record.extractedMetadata ?? {}),
|
||||
humanOverride: dto.finalMetadata ?? {},
|
||||
};
|
||||
const saved = await this.reviewRepo.save(record);
|
||||
|
||||
// T025: บันทึก AuditLog เปรียบเทียบ AI suggestion กับ Human override (ADR-023)
|
||||
await this.saveApprovalAuditLog({
|
||||
documentPublicId: record.publicId,
|
||||
aiSuggestionJson: record.extractedMetadata,
|
||||
humanOverrideJson: (dto.finalMetadata as Record<string, unknown>) ?? {},
|
||||
confirmedByUserId: userId,
|
||||
confidenceScore:
|
||||
record.confidenceScore === undefined
|
||||
? undefined
|
||||
: Number(record.confidenceScore),
|
||||
});
|
||||
|
||||
return { record: this.toResponse(saved), importResult };
|
||||
}
|
||||
|
||||
private parseRecords(
|
||||
records: LegacyMigrationIngestDto['records']
|
||||
): LegacyMigrationRecordDto[] {
|
||||
if (!records) return [];
|
||||
if (Array.isArray(records)) return records;
|
||||
try {
|
||||
const parsed = JSON.parse(records) as unknown;
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error('records must be an array');
|
||||
}
|
||||
return parsed as LegacyMigrationRecordDto[];
|
||||
} catch (error) {
|
||||
throw new ValidationException(
|
||||
`Invalid records payload: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private matchRecord(
|
||||
records: LegacyMigrationRecordDto[],
|
||||
originalFileName: string,
|
||||
index: number
|
||||
): LegacyMigrationRecordDto {
|
||||
return (
|
||||
records.find((record) => record.originalFileName === originalFileName) ??
|
||||
records[index] ??
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
private deriveStatus(
|
||||
record: LegacyMigrationRecordDto
|
||||
): MigrationReviewRecordStatus {
|
||||
if (record.status) return record.status;
|
||||
if (record.errorReason) return MigrationReviewRecordStatus.REJECTED;
|
||||
if (
|
||||
record.confidenceScore !== undefined &&
|
||||
Number(record.confidenceScore) < 0.6
|
||||
) {
|
||||
return MigrationReviewRecordStatus.REJECTED;
|
||||
}
|
||||
return MigrationReviewRecordStatus.PENDING;
|
||||
}
|
||||
|
||||
private validateFile(file: Express.Multer.File): void {
|
||||
if (file.size > this.maxFileSize) {
|
||||
throw new ValidationException('File exceeds 50MB limit');
|
||||
}
|
||||
if (!this.allowedMimeTypes.has(file.mimetype)) {
|
||||
throw new ValidationException(`Unsupported file type: ${file.mimetype}`);
|
||||
}
|
||||
}
|
||||
|
||||
private getServiceUserId(): number {
|
||||
return this.configService.get<number>('AI_SERVICE_USER_ID') ?? 1;
|
||||
}
|
||||
|
||||
private async resolveProject(publicId: string): Promise<Project> {
|
||||
const project = await this.projectRepo.findOne({ where: { publicId } });
|
||||
if (!project) throw new NotFoundException('Project', publicId);
|
||||
return project;
|
||||
}
|
||||
|
||||
private async resolveOrganization(publicId: string): Promise<Organization> {
|
||||
const organization = await this.organizationRepo.findOne({
|
||||
where: { publicId },
|
||||
});
|
||||
if (!organization) throw new NotFoundException('Organization', publicId);
|
||||
return organization;
|
||||
}
|
||||
|
||||
private async resolveCorrespondenceType(
|
||||
typeCode: string
|
||||
): Promise<CorrespondenceType> {
|
||||
const type = await this.correspondenceTypeRepo.findOne({
|
||||
where: [{ typeCode }, { typeName: typeCode }],
|
||||
});
|
||||
if (!type) throw new NotFoundException('CorrespondenceType', typeCode);
|
||||
return type;
|
||||
}
|
||||
|
||||
/** T025: บันทึก AuditLog สำหรับการอนุมัติ Human-in-the-loop (ADR-023 Rule 5) */
|
||||
private async saveApprovalAuditLog(data: {
|
||||
documentPublicId: string;
|
||||
aiSuggestionJson?: Record<string, unknown>;
|
||||
humanOverrideJson: Record<string, unknown>;
|
||||
confirmedByUserId: number;
|
||||
confidenceScore?: number;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const log = this.auditLogRepo.create({
|
||||
documentPublicId: data.documentPublicId,
|
||||
aiModel: 'legacy-migration',
|
||||
status: AiStatus.SUCCESS,
|
||||
aiSuggestionJson: data.aiSuggestionJson,
|
||||
humanOverrideJson: data.humanOverrideJson,
|
||||
confirmedByUserId: data.confirmedByUserId,
|
||||
confidenceScore: data.confidenceScore,
|
||||
});
|
||||
await this.auditLogRepo.save(log);
|
||||
} catch (err: unknown) {
|
||||
this.logger.error(
|
||||
`Failed to save approval audit log for ${data.documentPublicId}: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private toResponse(record: MigrationReviewRecord): MigrationReviewResponse {
|
||||
return {
|
||||
publicId: record.publicId,
|
||||
batchId: record.batchId,
|
||||
originalFileName: record.originalFileName,
|
||||
sourceAttachmentPublicId: record.sourceAttachmentPublicId,
|
||||
extractedMetadata: record.extractedMetadata,
|
||||
confidenceScore:
|
||||
record.confidenceScore === undefined
|
||||
? undefined
|
||||
: Number(record.confidenceScore),
|
||||
status: record.status,
|
||||
errorReason: record.errorReason,
|
||||
createdAt: record.createdAt,
|
||||
updatedAt: record.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// File: src/modules/ai/ai-queue.service.ts
|
||||
// Change Log
|
||||
// - 2026-05-14: เพิ่ม service กลางสำหรับส่งงาน AI เข้า BullMQ ตาม ADR-023.
|
||||
// - 2026-05-14: เพิ่ม JSDoc idempotency contract สำหรับทุก enqueue method (💡 S3).
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Queue, JobsOptions } from 'bullmq';
|
||||
import {
|
||||
QUEUE_AI_INGEST,
|
||||
QUEUE_AI_RAG,
|
||||
QUEUE_AI_VECTOR_DELETION,
|
||||
} from '../common/constants/queue.constants';
|
||||
|
||||
/** Payload สำหรับงาน ingest เอกสารเก่าเข้า AI Pipeline */
|
||||
export interface AiIngestJobPayload {
|
||||
batchId: string;
|
||||
filePublicIds: string[];
|
||||
source: 'api' | 'folder-watcher';
|
||||
}
|
||||
|
||||
/** Payload สำหรับงาน RAG Query ที่ต้องเข้าคิวบน Desk-5439 */
|
||||
export interface AiRagJobPayload {
|
||||
requestPublicId: string;
|
||||
userPublicId: string;
|
||||
projectPublicId: string;
|
||||
query: string;
|
||||
}
|
||||
|
||||
/** Payload สำหรับลบ vector ใน Qdrant แบบ eventual consistency */
|
||||
export interface AiVectorDeletionJobPayload {
|
||||
documentPublicId: string;
|
||||
requestedByUserPublicId: string;
|
||||
}
|
||||
|
||||
/** จัดการคิว AI ทั้งหมดให้อยู่หลัง BullMQ ตาม ADR-008/ADR-023 */
|
||||
@Injectable()
|
||||
export class AiQueueService {
|
||||
private readonly defaultOptions: JobsOptions = {
|
||||
attempts: 3,
|
||||
backoff: { type: 'exponential', delay: 5000 },
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 200,
|
||||
};
|
||||
|
||||
constructor(
|
||||
@InjectQueue(QUEUE_AI_INGEST)
|
||||
private readonly ingestQueue: Queue<AiIngestJobPayload>,
|
||||
@InjectQueue(QUEUE_AI_RAG)
|
||||
private readonly ragQueue: Queue<AiRagJobPayload>,
|
||||
@InjectQueue(QUEUE_AI_VECTOR_DELETION)
|
||||
private readonly vectorDeletionQueue: Queue<AiVectorDeletionJobPayload>
|
||||
) {}
|
||||
|
||||
/**
|
||||
* ส่ง batch migration เข้า queue เพื่อไม่ให้ request thread ทำงานหนัก
|
||||
* @idempotency `jobId = batchId:source` — การส่ง batch เดิมซ้ำจะคืน job ID เดิม ไม่สร้างงานใหม่
|
||||
*/
|
||||
async enqueueIngest(payload: AiIngestJobPayload): Promise<string> {
|
||||
const job = await this.ingestQueue.add('legacy-migration-ingest', payload, {
|
||||
...this.defaultOptions,
|
||||
jobId: `${payload.batchId}:${payload.source}`,
|
||||
});
|
||||
return String(job.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* ส่ง RAG query เข้า queue ที่ processor จะกำหนด concurrency = 1
|
||||
* @idempotency `jobId = requestPublicId` — ถ้า request เดิม (UUID เดียวกัน) ถูก submit ซ้ำ BullMQ จะไม่สร้างงานใหม่
|
||||
*/
|
||||
async enqueueRagQuery(payload: AiRagJobPayload): Promise<string> {
|
||||
const job = await this.ragQueue.add('rag-query', payload, {
|
||||
...this.defaultOptions,
|
||||
jobId: payload.requestPublicId,
|
||||
});
|
||||
return String(job.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* ส่งคำสั่งลบ vector เข้า queue เพื่อ retry ได้เมื่อ Qdrant ไม่พร้อม
|
||||
* @idempotency `jobId = documentPublicId` — การลบเอกสารเดิมซ้ำจะถูก de-duplicate โดย BullMQ
|
||||
*/
|
||||
async enqueueVectorDeletion(
|
||||
payload: AiVectorDeletionJobPayload
|
||||
): Promise<string> {
|
||||
const job = await this.vectorDeletionQueue.add(
|
||||
'delete-document-vectors',
|
||||
payload,
|
||||
{
|
||||
...this.defaultOptions,
|
||||
jobId: payload.documentPublicId,
|
||||
}
|
||||
);
|
||||
return String(job.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
// File: src/modules/ai/ai-rag.service.ts
|
||||
// Change Log
|
||||
// - 2026-05-14: เพิ่ม AiRagService สำหรับ BullMQ-backed RAG pipeline ตาม ADR-023 Phase 4.
|
||||
// - 2026-05-14: แก้ไข corruption ในไฟล์ทั้งหมด — rewrite clean version.
|
||||
// - 2026-05-14: ย้าย PROMPT_CONTEXT_LIMIT เป็น instance field ที่อ่านจาก RAG_CONTEXT_LIMIT_CHARS (💡 S1).
|
||||
// Service จัดการ RAG query ผ่าน Ollama + AiQdrantService (project-isolated)
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRedis } from '@nestjs-modules/ioredis';
|
||||
import Redis from 'ioredis';
|
||||
import axios from 'axios';
|
||||
import { AiQdrantService } from './qdrant.service';
|
||||
|
||||
/** ผลลัพธ์ของ RAG query แต่ละรายการที่ถูก reference ในคำตอบ */
|
||||
export interface AiRagCitation {
|
||||
pointId: string | number;
|
||||
score: number;
|
||||
docType?: string;
|
||||
docNumber?: string;
|
||||
snippet?: string;
|
||||
}
|
||||
|
||||
/** ผลลัพธ์สมบูรณ์ของ RAG job ที่เก็บใน Redis */
|
||||
export interface AiRagJobResult {
|
||||
requestPublicId: string;
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled';
|
||||
answer?: string;
|
||||
citations?: AiRagCitation[];
|
||||
confidence?: number;
|
||||
usedFallbackModel?: boolean;
|
||||
errorMessage?: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
/** TTL สำหรับ Redis result key (5 นาที) */
|
||||
const RAG_RESULT_TTL_SECONDS = 300;
|
||||
/** TTL สำหรับ Redis active-job key ต่อ user (5 นาที) */
|
||||
const RAG_ACTIVE_JOB_TTL_SECONDS = 300;
|
||||
|
||||
/** บริการหลักสำหรับประมวลผล RAG query ผ่าน Ollama และ Qdrant (ADR-023) */
|
||||
@Injectable()
|
||||
export class AiRagService {
|
||||
private readonly logger = new Logger(AiRagService.name);
|
||||
private readonly ollamaUrl: string;
|
||||
private readonly ollamaModel: string;
|
||||
private readonly ollamaEmbedModel: string;
|
||||
private readonly timeoutMs: number;
|
||||
/** จำนวนอักขระสูงสุดของ context ที่ส่งให้ LLM — ปรับได้ผ่าน RAG_CONTEXT_LIMIT_CHARS */
|
||||
private readonly promptContextLimit: number;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly qdrantService: AiQdrantService,
|
||||
@InjectRedis() private readonly redis: Redis
|
||||
) {
|
||||
this.ollamaUrl = this.configService.get<string>(
|
||||
'OLLAMA_URL',
|
||||
'http://localhost:11434'
|
||||
);
|
||||
this.ollamaModel = this.configService.get<string>(
|
||||
'OLLAMA_RAG_MODEL',
|
||||
'gemma2'
|
||||
);
|
||||
this.ollamaEmbedModel = this.configService.get<string>(
|
||||
'OLLAMA_EMBED_MODEL',
|
||||
'nomic-embed-text'
|
||||
);
|
||||
this.timeoutMs = this.configService.get<number>('RAG_TIMEOUT_MS', 30000);
|
||||
this.promptContextLimit = this.configService.get<number>(
|
||||
'RAG_CONTEXT_LIMIT_CHARS',
|
||||
3000
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Job State Management ────────────────────────────────────────────────────
|
||||
|
||||
/** กำหนด result key สำหรับ Redis */
|
||||
private resultKey(requestPublicId: string): string {
|
||||
return `ai:rag:result:${requestPublicId}`;
|
||||
}
|
||||
|
||||
/** กำหนด active-job key ต่อ user สำหรับ FR-009 (1 active job per user) */
|
||||
private activeJobKey(userPublicId: string): string {
|
||||
return `ai:rag:active:${userPublicId}`;
|
||||
}
|
||||
|
||||
/** กำหนด cancel-flag key สำหรับ T022 (AbortController) */
|
||||
cancelKey(requestPublicId: string): string {
|
||||
return `ai:rag:cancel:${requestPublicId}`;
|
||||
}
|
||||
|
||||
/** ตรวจสอบว่า user มี active job อยู่ หรือไม่ (FR-009) */
|
||||
async getActiveJob(userPublicId: string): Promise<string | null> {
|
||||
return this.redis.get(this.activeJobKey(userPublicId));
|
||||
}
|
||||
|
||||
/** ลงทะเบียน job ใหม่ให้ user เพื่อ enforce FR-009 */
|
||||
async registerActiveJob(
|
||||
userPublicId: string,
|
||||
requestPublicId: string
|
||||
): Promise<void> {
|
||||
await this.redis.setex(
|
||||
this.activeJobKey(userPublicId),
|
||||
RAG_ACTIVE_JOB_TTL_SECONDS,
|
||||
requestPublicId
|
||||
);
|
||||
await this.saveJobResult({
|
||||
requestPublicId,
|
||||
status: 'pending',
|
||||
});
|
||||
}
|
||||
|
||||
/** บันทึกผลลัพธ์ job ลง Redis */
|
||||
async saveJobResult(result: AiRagJobResult): Promise<void> {
|
||||
await this.redis.setex(
|
||||
this.resultKey(result.requestPublicId),
|
||||
RAG_RESULT_TTL_SECONDS,
|
||||
JSON.stringify(result)
|
||||
);
|
||||
}
|
||||
|
||||
/** ดึงผลลัพธ์ job จาก Redis */
|
||||
async getJobResult(requestPublicId: string): Promise<AiRagJobResult | null> {
|
||||
const raw = await this.redis.get(this.resultKey(requestPublicId));
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as AiRagJobResult;
|
||||
} catch {
|
||||
this.logger.warn(
|
||||
`Corrupted RAG result in Redis — requestPublicId=${requestPublicId}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** ยกเลิก job โดยตั้ง cancel flag ใน Redis */
|
||||
async cancelJob(requestPublicId: string): Promise<void> {
|
||||
await this.redis.setex(
|
||||
this.cancelKey(requestPublicId),
|
||||
RAG_RESULT_TTL_SECONDS,
|
||||
'1'
|
||||
);
|
||||
const current = await this.getJobResult(requestPublicId);
|
||||
if (
|
||||
current &&
|
||||
(current.status === 'pending' || current.status === 'processing')
|
||||
) {
|
||||
await this.saveJobResult({ ...current, status: 'cancelled' });
|
||||
}
|
||||
}
|
||||
|
||||
/** ลบ active-job ของ user เมื่อ job เสร็จหรือถูกยกเลิก */
|
||||
async clearActiveJob(userPublicId: string): Promise<void> {
|
||||
await this.redis.del(this.activeJobKey(userPublicId));
|
||||
}
|
||||
|
||||
// ─── Core Processing ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* ประมวลผล RAG query:
|
||||
* 1. Embed คำถาม
|
||||
* 2. ค้นหา Qdrant ด้วย project isolation (T020 — enforced in AiQdrantService.searchByProject)
|
||||
* 3. Build prompt จาก context
|
||||
* 4. Generate คำตอบผ่าน Ollama (รองรับ AbortSignal สำหรับ T022)
|
||||
*/
|
||||
async processQuery(
|
||||
requestPublicId: string,
|
||||
question: string,
|
||||
projectPublicId: string,
|
||||
userPublicId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
await this.saveJobResult({ requestPublicId, status: 'processing' });
|
||||
|
||||
try {
|
||||
// ตรวจสอบว่าถูกยกเลิกก่อนเริ่มทำงาน
|
||||
const cancelFlag = await this.redis.get(this.cancelKey(requestPublicId));
|
||||
if (cancelFlag || signal?.aborted) {
|
||||
await this.saveJobResult({ requestPublicId, status: 'cancelled' });
|
||||
await this.clearActiveJob(userPublicId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. สร้าง embedding สำหรับคำถาม
|
||||
const queryVector = await this.embed(question, signal);
|
||||
|
||||
// ตรวจสอบ cancel อีกครั้งหลัง embed
|
||||
if (
|
||||
signal?.aborted ||
|
||||
(await this.redis.get(this.cancelKey(requestPublicId)))
|
||||
) {
|
||||
await this.saveJobResult({ requestPublicId, status: 'cancelled' });
|
||||
await this.clearActiveJob(userPublicId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. ค้นหา Qdrant โดยบังคับ projectPublicId (T020 — FR-002)
|
||||
const searchResults = await this.qdrantService.searchByProject(
|
||||
queryVector,
|
||||
projectPublicId,
|
||||
10
|
||||
);
|
||||
|
||||
// 3. สร้าง context จาก search results
|
||||
const context = this.buildContext(searchResults);
|
||||
|
||||
// ตรวจสอบ cancel ก่อนเรียก LLM (ใช้ทรัพยากรมากที่สุด)
|
||||
if (
|
||||
signal?.aborted ||
|
||||
(await this.redis.get(this.cancelKey(requestPublicId)))
|
||||
) {
|
||||
await this.saveJobResult({ requestPublicId, status: 'cancelled' });
|
||||
await this.clearActiveJob(userPublicId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Generate คำตอบผ่าน Ollama (ส่ง signal เพื่อรองรับ T022)
|
||||
const { answer, usedFallback } = await this.generateAnswer(
|
||||
this.sanitizeInput(question),
|
||||
context,
|
||||
signal
|
||||
);
|
||||
|
||||
const citations: AiRagCitation[] = searchResults.map((r) => ({
|
||||
pointId: r.pointId,
|
||||
score: r.score,
|
||||
docType: r.payload['doc_type'] as string | undefined,
|
||||
docNumber: r.payload['doc_number'] as string | undefined,
|
||||
snippet: (r.payload['content_preview'] as string | undefined)?.slice(
|
||||
0,
|
||||
200
|
||||
),
|
||||
}));
|
||||
|
||||
const confidence = searchResults.length > 0 ? searchResults[0].score : 0;
|
||||
|
||||
await this.saveJobResult({
|
||||
requestPublicId,
|
||||
status: 'completed',
|
||||
answer,
|
||||
citations,
|
||||
confidence,
|
||||
usedFallbackModel: usedFallback,
|
||||
completedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`RAG query completed — requestPublicId=${requestPublicId}, confidence=${confidence.toFixed(3)}`
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(
|
||||
`RAG query failed — requestPublicId=${requestPublicId}: ${errMsg}`
|
||||
);
|
||||
await this.saveJobResult({
|
||||
requestPublicId,
|
||||
status: 'failed',
|
||||
errorMessage: errMsg,
|
||||
completedAt: new Date().toISOString(),
|
||||
});
|
||||
} finally {
|
||||
await this.clearActiveJob(userPublicId);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Private Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/** สร้าง embedding vector สำหรับข้อความ */
|
||||
private async embed(text: string, signal?: AbortSignal): Promise<number[]> {
|
||||
const response = await axios.post<{ embedding: number[] }>(
|
||||
`${this.ollamaUrl}/api/embeddings`,
|
||||
{ model: this.ollamaEmbedModel, prompt: text },
|
||||
{ timeout: this.timeoutMs, signal }
|
||||
);
|
||||
return response.data.embedding;
|
||||
}
|
||||
|
||||
/** Generate คำตอบจาก Ollama (รองรับ AbortSignal สำหรับ T022 FR-011) */
|
||||
private async generateAnswer(
|
||||
question: string,
|
||||
context: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ answer: string; usedFallback: boolean }> {
|
||||
const prompt = this.buildPrompt(question, context);
|
||||
try {
|
||||
const response = await axios.post<{ response: string }>(
|
||||
`${this.ollamaUrl}/api/generate`,
|
||||
{ model: this.ollamaModel, prompt, stream: false },
|
||||
{ timeout: this.timeoutMs, signal }
|
||||
);
|
||||
return { answer: response.data.response ?? '', usedFallback: false };
|
||||
} catch (err: unknown) {
|
||||
// ถ้าเป็น cancellation error ให้ re-throw เพื่อให้ processQuery จัดการ
|
||||
if (
|
||||
axios.isCancel(err) ||
|
||||
(err instanceof Error && err.name === 'CanceledError')
|
||||
) {
|
||||
throw err;
|
||||
}
|
||||
this.logger.warn(
|
||||
`Ollama generation failed — model=${this.ollamaModel}: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
return { answer: 'ไม่พบข้อมูลในเอกสารที่ระบุ', usedFallback: true };
|
||||
}
|
||||
}
|
||||
|
||||
/** สร้าง context string จาก search results ให้ไม่เกิน PROMPT_CONTEXT_LIMIT */
|
||||
private buildContext(
|
||||
results: Array<{ payload: Record<string, unknown> }>
|
||||
): string {
|
||||
let context = '';
|
||||
for (const r of results) {
|
||||
const docType = (r.payload['doc_type'] as string) ?? '';
|
||||
const docNumber = (r.payload['doc_number'] as string) ?? '';
|
||||
const preview = (r.payload['content_preview'] as string) ?? '';
|
||||
const header = `[${docType}${docNumber ? ` - ${docNumber}` : ''}]`;
|
||||
const snippet = `${header}\n${preview}\n\n`;
|
||||
if ((context + snippet).length > this.promptContextLimit) break;
|
||||
context += snippet;
|
||||
}
|
||||
return context.trim();
|
||||
}
|
||||
|
||||
/** สร้าง prompt สำหรับ LLM ตาม RAG pattern ของโครงการ */
|
||||
private buildPrompt(question: string, context: string): string {
|
||||
return [
|
||||
'คุณเป็นผู้ช่วยผู้เชี่ยวชาญด้านเอกสารโครงการก่อสร้าง',
|
||||
'ตอบคำถามโดยอ้างอิงจากเอกสารที่ให้มาเท่านั้น ห้ามตอบจากความรู้ทั่วไป',
|
||||
'หากข้อมูลในเอกสารไม่เพียงพอ ให้แจ้งว่า "ไม่พบข้อมูลในเอกสารที่ระบุ"',
|
||||
'',
|
||||
'=== เอกสารอ้างอิง ===',
|
||||
context,
|
||||
'',
|
||||
'=== คำถาม ===',
|
||||
question,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/** กรอง input เพื่อป้องกัน prompt injection */
|
||||
private sanitizeInput(text: string): string {
|
||||
return text
|
||||
.replace(/<CONTEXT_START>|<CONTEXT_END>/gi, '')
|
||||
.replace(/ignore previous instructions/gi, '')
|
||||
.replace(/system:/gi, '')
|
||||
.slice(0, 500);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,26 @@
|
||||
// File: src/modules/ai/ai.controller.ts
|
||||
// Controller สำหรับ AI Gateway Endpoints (ADR-018, ADR-020)
|
||||
// Change Log
|
||||
// - 2026-05-14: เพิ่ม Legacy Migration staging endpoints ตาม ADR-023.
|
||||
// - 2026-05-14: ย้าย DeleteAuditLogsQueryDto ไป dto/ folder; ลบ authHeader passthrough (🟢 LOW-1/LOW-2).
|
||||
// Controller สำหรับ AI Gateway Endpoints (ADR-023)
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Get,
|
||||
Patch,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
Headers,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
UploadedFiles,
|
||||
} from '@nestjs/common';
|
||||
import { FilesInterceptor } from '@nestjs/platform-express';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import {
|
||||
ApiTags,
|
||||
@@ -22,27 +31,50 @@ import {
|
||||
ApiQuery,
|
||||
} from '@nestjs/swagger';
|
||||
import { AiService, ExtractionResult, PaginatedResult } from './ai.service';
|
||||
import {
|
||||
AiIngestService,
|
||||
MigrationReviewResponse,
|
||||
PaginatedMigrationReviewResponse,
|
||||
} from './ai-ingest.service';
|
||||
import { AiRagService } from './ai-rag.service';
|
||||
import { AiQueueService } from './ai-queue.service';
|
||||
import { AiRagQueryDto } from './dto/ai-rag-query.dto';
|
||||
import { ExtractDocumentDto } from './dto/extract-document.dto';
|
||||
import { AiCallbackDto } from './dto/ai-callback.dto';
|
||||
import { MigrationUpdateDto } from './dto/migration-update.dto';
|
||||
import { MigrationQueryDto } from './dto/migration-query.dto';
|
||||
import {
|
||||
ApproveLegacyMigrationDto,
|
||||
LegacyMigrationIngestDto,
|
||||
LegacyMigrationQueueQueryDto,
|
||||
} from './dto/legacy-migration.dto';
|
||||
import { MigrationLog } from './entities/migration-log.entity';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { RbacGuard } from '../../common/guards/rbac.guard';
|
||||
import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import { User } from '../user/entities/user.entity';
|
||||
import { ServiceAccountGuard } from './guards/service-account.guard';
|
||||
import { v7 as uuidv7 } from 'uuid';
|
||||
import { DeleteAuditLogsQueryDto } from './dto/delete-audit-logs.dto';
|
||||
|
||||
@ApiTags('AI Gateway')
|
||||
@Controller('ai')
|
||||
export class AiController {
|
||||
constructor(private readonly aiService: AiService) {}
|
||||
constructor(
|
||||
private readonly aiService: AiService,
|
||||
private readonly aiIngestService: AiIngestService,
|
||||
private readonly aiRagService: AiRagService,
|
||||
private readonly aiQueueService: AiQueueService
|
||||
) {}
|
||||
|
||||
// --- Real-time Extraction (User Upload) ---
|
||||
|
||||
@Post('extract')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(JwtAuthGuard, RbacGuard)
|
||||
@ApiBearerAuth()
|
||||
@RequirePermission('ai.extract')
|
||||
@Throttle({ default: { limit: 5, ttl: 60000 } }) // Rate limit: 5 requests/minute (ADR-020)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
@@ -60,6 +92,7 @@ export class AiController {
|
||||
// --- Webhook Callback จาก n8n (Service Account) ---
|
||||
|
||||
@Post('callback')
|
||||
@UseGuards(ServiceAccountGuard) // T029: กำหนด guard ที่ controller layer (ADR-016)
|
||||
@ApiOperation({
|
||||
summary: 'AI Callback Endpoint — รับผลลัพธ์จาก n8n หลัง AI ประมวลผลเสร็จ',
|
||||
description:
|
||||
@@ -67,7 +100,8 @@ export class AiController {
|
||||
})
|
||||
@ApiHeader({
|
||||
name: 'Authorization',
|
||||
description: 'Bearer {AI_N8N_AUTH_TOKEN} — Service Account Token จาก n8n',
|
||||
description:
|
||||
'Bearer {AI_N8N_SERVICE_TOKEN} — Service Account Token จาก n8n',
|
||||
required: true,
|
||||
})
|
||||
@ApiHeader({
|
||||
@@ -77,14 +111,9 @@ export class AiController {
|
||||
})
|
||||
async handleCallback(
|
||||
@Body() dto: AiCallbackDto,
|
||||
@Headers('authorization') authHeader: string,
|
||||
@Headers('x-ai-source') aiSource: string
|
||||
): Promise<{ message: string }> {
|
||||
await this.aiService.handleWebhookCallback(
|
||||
dto,
|
||||
aiSource ?? 'unknown',
|
||||
authHeader
|
||||
);
|
||||
await this.aiService.handleWebhookCallback(dto, aiSource ?? 'unknown');
|
||||
return { message: 'Callback processed successfully' };
|
||||
}
|
||||
|
||||
@@ -150,4 +179,169 @@ export class AiController {
|
||||
): Promise<MigrationLog> {
|
||||
return this.aiService.updateMigrationLog(publicId, dto, user.user_id);
|
||||
}
|
||||
|
||||
// ─── AI Audit Log Endpoints (Phase 5 — T026) ──────────────────────────────
|
||||
|
||||
@Delete('audit-logs')
|
||||
@UseGuards(JwtAuthGuard, RbacGuard)
|
||||
@ApiBearerAuth()
|
||||
@RequirePermission('system.manage_all')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'AI Audit Log Hard Delete — ลบ log ถาวร (SYSTEM_ADMIN เท่านั้น) (T026)',
|
||||
description:
|
||||
'ต้องระบุ documentPublicId หรือ olderThanDays อย่างน้อยหนึ่งอย่าง',
|
||||
})
|
||||
async deleteAuditLogs(
|
||||
@Query() query: DeleteAuditLogsQueryDto
|
||||
): Promise<{ deleted: number }> {
|
||||
return this.aiService.deleteAuditLogs({
|
||||
documentPublicId: query.documentPublicId,
|
||||
olderThanDays: query.olderThanDays,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── RAG Query Endpoints (Phase 4 — FR-009, FR-010, FR-011) ────────────────
|
||||
|
||||
@Post('rag/query')
|
||||
@UseGuards(JwtAuthGuard, RbacGuard)
|
||||
@ApiBearerAuth()
|
||||
@Throttle({ default: { limit: 5, ttl: 60000 } }) // Rate limit: 5 requests/minute per user (FR-010)
|
||||
@RequirePermission('rag.query')
|
||||
@HttpCode(HttpStatus.ACCEPTED)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'RAG Query — ส่ง query เข้า BullMQ เพื่อประมวลผลแบบ async (FR-009, FR-010)',
|
||||
description:
|
||||
'ส่งคำถาม RAG เข้าคิว BullMQ (concurrency=1 บน Desk-5439) แล้วคืน requestPublicId สำหรับ polling',
|
||||
})
|
||||
@ApiHeader({
|
||||
name: 'Idempotency-Key',
|
||||
description: 'Unique key สำหรับ request',
|
||||
required: true,
|
||||
})
|
||||
async submitRagQuery(
|
||||
@Body() dto: AiRagQueryDto,
|
||||
@CurrentUser() user: User,
|
||||
@Headers('idempotency-key') _idempotencyKey: string
|
||||
): Promise<{ requestPublicId: string; jobId: string; status: string }> {
|
||||
// ตรวจสอบว่า user มี active job อยู่แล้วหรือไม่ (FR-009: 1 active job per user)
|
||||
const activeJob = await this.aiRagService.getActiveJob(
|
||||
String(user.publicId ?? user.user_id)
|
||||
);
|
||||
if (activeJob) {
|
||||
return { requestPublicId: activeJob, jobId: '', status: 'queued' };
|
||||
}
|
||||
|
||||
// สร้าง requestPublicId ใหม่ (ADR-019: UUID)
|
||||
const requestPublicId = uuidv7();
|
||||
const userPublicId = String(user.publicId ?? user.user_id);
|
||||
|
||||
// ลงทะเบียน job ใน Redis ก่อนส่งเข้า BullMQ
|
||||
await this.aiRagService.registerActiveJob(userPublicId, requestPublicId);
|
||||
|
||||
// ส่ง job เข้า BullMQ ตาม ADR-008
|
||||
const jobId = await this.aiQueueService.enqueueRagQuery({
|
||||
requestPublicId,
|
||||
userPublicId,
|
||||
projectPublicId: dto.projectPublicId,
|
||||
query: dto.question,
|
||||
});
|
||||
|
||||
return { requestPublicId, jobId, status: 'queued' };
|
||||
}
|
||||
|
||||
@Get('rag/jobs/:requestPublicId')
|
||||
@UseGuards(JwtAuthGuard, RbacGuard)
|
||||
@ApiBearerAuth()
|
||||
@RequirePermission('rag.query')
|
||||
@ApiOperation({
|
||||
summary: 'RAG Job Status — ดูสถานะและผลลัพธ์ของ RAG query (polling)',
|
||||
})
|
||||
@ApiParam({
|
||||
name: 'requestPublicId',
|
||||
description: 'requestPublicId จาก submit endpoint',
|
||||
})
|
||||
async getRagJobStatus(
|
||||
@Param('requestPublicId', ParseUuidPipe) requestPublicId: string
|
||||
) {
|
||||
const result = await this.aiRagService.getJobResult(requestPublicId);
|
||||
if (!result) {
|
||||
return { requestPublicId, status: 'not_found' };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Delete('rag/jobs/:requestPublicId')
|
||||
@UseGuards(JwtAuthGuard, RbacGuard)
|
||||
@ApiBearerAuth()
|
||||
@RequirePermission('rag.query')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({
|
||||
summary: 'RAG Job Cancel — ยกเลิก RAG job ที่กำลังประมวลผล (T022, FR-011)',
|
||||
})
|
||||
@ApiParam({
|
||||
name: 'requestPublicId',
|
||||
description: 'requestPublicId ของ job ที่ต้องการยกเลิก',
|
||||
})
|
||||
async cancelRagJob(
|
||||
@Param('requestPublicId', ParseUuidPipe) requestPublicId: string
|
||||
): Promise<void> {
|
||||
await this.aiRagService.cancelJob(requestPublicId);
|
||||
}
|
||||
|
||||
@Post('legacy-migration/ingest')
|
||||
@UseGuards(ServiceAccountGuard)
|
||||
@UseInterceptors(FilesInterceptor('files', 25))
|
||||
@ApiOperation({
|
||||
summary: 'Legacy Migration: ingest PDF batch into AI staging queue',
|
||||
})
|
||||
@ApiHeader({
|
||||
name: 'Authorization',
|
||||
description: 'Bearer {AI_N8N_SERVICE_TOKEN}',
|
||||
required: true,
|
||||
})
|
||||
async ingestLegacyMigration(
|
||||
@Body() dto: LegacyMigrationIngestDto,
|
||||
@UploadedFiles() files: Express.Multer.File[] = []
|
||||
) {
|
||||
return this.aiIngestService.ingest(dto, files);
|
||||
}
|
||||
|
||||
@Get('legacy-migration/queue')
|
||||
@UseGuards(JwtAuthGuard, RbacGuard)
|
||||
@ApiBearerAuth()
|
||||
@RequirePermission('ai.migration_manage')
|
||||
@ApiOperation({ summary: 'Legacy Migration: list AI staging queue records' })
|
||||
async getLegacyMigrationQueue(
|
||||
@Query() query: LegacyMigrationQueueQueryDto
|
||||
): Promise<PaginatedMigrationReviewResponse> {
|
||||
return this.aiIngestService.listQueue(query);
|
||||
}
|
||||
|
||||
@Post('legacy-migration/queue/:publicId/approve')
|
||||
@UseGuards(JwtAuthGuard, RbacGuard)
|
||||
@ApiBearerAuth()
|
||||
@RequirePermission('ai.migration_manage')
|
||||
@ApiOperation({ summary: 'Legacy Migration: approve AI staging record' })
|
||||
@ApiParam({ name: 'publicId', description: 'Migration review publicId' })
|
||||
@ApiHeader({
|
||||
name: 'Idempotency-Key',
|
||||
description: 'Unique key for this approval/import operation',
|
||||
required: true,
|
||||
})
|
||||
async approveLegacyMigrationRecord(
|
||||
@Param('publicId', ParseUuidPipe) publicId: string,
|
||||
@Body() dto: ApproveLegacyMigrationDto,
|
||||
@Headers('idempotency-key') idempotencyKey: string,
|
||||
@CurrentUser() user: User
|
||||
): Promise<{ record: MigrationReviewResponse; importResult: unknown }> {
|
||||
return this.aiIngestService.approve(
|
||||
publicId,
|
||||
dto,
|
||||
idempotencyKey,
|
||||
user.user_id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,55 @@
|
||||
// File: src/modules/ai/ai.module.ts
|
||||
// Module สำหรับ AI Gateway — ลงทะเบียน Services และ Controllers (ADR-018, ADR-020)
|
||||
// Change Log
|
||||
// - 2026-05-14: เพิ่ม BullMQ/Qdrant/Service Account foundation สำหรับ ADR-023.
|
||||
// Module สำหรับ AI Gateway — ลงทะเบียน Services และ Controllers (ADR-023)
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { AiController } from './ai.controller';
|
||||
import { AiService } from './ai.service';
|
||||
import { AiIngestService } from './ai-ingest.service';
|
||||
import { AiQueueService } from './ai-queue.service';
|
||||
import { AiQdrantService } from './qdrant.service';
|
||||
import { AiValidationService } from './ai-validation.service';
|
||||
import { AiRagService } from './ai-rag.service';
|
||||
import { AiRagProcessor } from './processors/rag.processor';
|
||||
import { AiVectorDeletionProcessor } from './processors/vector-deletion.processor';
|
||||
import { MigrationLog } from './entities/migration-log.entity';
|
||||
import { AiAuditLog } from './entities/ai-audit-log.entity';
|
||||
import { MigrationReviewRecord } from './entities/migration-review.entity';
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { MigrationModule } from '../migration/migration.module';
|
||||
import { FileStorageModule } from '../../common/file-storage/file-storage.module';
|
||||
import { Project } from '../project/entities/project.entity';
|
||||
import { Organization } from '../organization/entities/organization.entity';
|
||||
import { CorrespondenceType } from '../correspondence/entities/correspondence-type.entity';
|
||||
import { RbacGuard } from '../../common/guards/rbac.guard';
|
||||
import {
|
||||
QUEUE_AI_INGEST,
|
||||
QUEUE_AI_RAG,
|
||||
QUEUE_AI_VECTOR_DELETION,
|
||||
} from '../common/constants/queue.constants';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
// Entities สำหรับ AI Module
|
||||
TypeOrmModule.forFeature([MigrationLog, AiAuditLog]),
|
||||
TypeOrmModule.forFeature([
|
||||
MigrationLog,
|
||||
AiAuditLog,
|
||||
MigrationReviewRecord,
|
||||
Project,
|
||||
Organization,
|
||||
CorrespondenceType,
|
||||
]),
|
||||
|
||||
BullModule.registerQueue(
|
||||
{ name: QUEUE_AI_INGEST },
|
||||
{ name: QUEUE_AI_RAG },
|
||||
{ name: QUEUE_AI_VECTOR_DELETION }
|
||||
),
|
||||
|
||||
// HTTP Client สำหรับเรียก n8n Webhook (ADR-018: AI สื่อสารผ่าน API)
|
||||
HttpModule.register({
|
||||
@@ -29,14 +62,31 @@ import { RbacGuard } from '../../common/guards/rbac.guard';
|
||||
|
||||
// UserModule สำหรับ RbacGuard (ต้องการ UserService)
|
||||
UserModule,
|
||||
MigrationModule,
|
||||
FileStorageModule,
|
||||
],
|
||||
controllers: [AiController],
|
||||
providers: [
|
||||
AiService,
|
||||
AiIngestService,
|
||||
AiQueueService,
|
||||
AiQdrantService,
|
||||
AiValidationService,
|
||||
// Phase 4: RAG BullMQ pipeline (ADR-023)
|
||||
AiRagService,
|
||||
AiRagProcessor,
|
||||
// Phase 5: Vector Deletion async processor (ADR-023 FR-008)
|
||||
AiVectorDeletionProcessor,
|
||||
// RbacGuard ต้องการ UserService จาก UserModule
|
||||
RbacGuard,
|
||||
],
|
||||
exports: [AiService, AiValidationService],
|
||||
exports: [
|
||||
AiService,
|
||||
AiIngestService,
|
||||
AiQueueService,
|
||||
AiQdrantService,
|
||||
AiValidationService,
|
||||
AiRagService,
|
||||
],
|
||||
})
|
||||
export class AiModule {}
|
||||
|
||||
@@ -114,19 +114,7 @@ describe('AiService', () => {
|
||||
processingTimeMs: 5000,
|
||||
};
|
||||
|
||||
const validAuthHeader = 'Bearer test-token';
|
||||
|
||||
it('ควรปฏิเสธ request เมื่อไม่มี Authorization header', async () => {
|
||||
await expect(
|
||||
service.handleWebhookCallback(validPayload, 'n8n', '')
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('ควรปฏิเสธ request เมื่อ Token ไม่ถูกต้อง', async () => {
|
||||
await expect(
|
||||
service.handleWebhookCallback(validPayload, 'n8n', 'Bearer wrong-token')
|
||||
).rejects.toThrow();
|
||||
});
|
||||
// หมายเหตุ: token validation ย้ายไป ServiceAccountGuard ที่ controller layer แล้ว (🟢 LOW-1)
|
||||
|
||||
it('ควร throw NotFoundException เมื่อ MigrationLog ไม่พบ', async () => {
|
||||
mockMigrationLogRepo.findOne.mockResolvedValue(null);
|
||||
@@ -138,7 +126,7 @@ describe('AiService', () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.handleWebhookCallback(validPayload, 'n8n', validAuthHeader)
|
||||
service.handleWebhookCallback(validPayload, 'n8n')
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
@@ -159,7 +147,7 @@ describe('AiService', () => {
|
||||
reasons: [],
|
||||
});
|
||||
|
||||
await service.handleWebhookCallback(validPayload, 'n8n', validAuthHeader);
|
||||
await service.handleWebhookCallback(validPayload, 'n8n');
|
||||
|
||||
expect(mockMigrationLogRepo.save).toHaveBeenCalled();
|
||||
expect(mockAuditLogRepo.create).toHaveBeenCalled();
|
||||
@@ -183,11 +171,7 @@ describe('AiService', () => {
|
||||
reasons: [],
|
||||
});
|
||||
|
||||
await service.handleWebhookCallback(
|
||||
highConfidencePayload,
|
||||
'n8n',
|
||||
validAuthHeader
|
||||
);
|
||||
await service.handleWebhookCallback(highConfidencePayload, 'n8n');
|
||||
|
||||
const calls = mockMigrationLogRepo.save.mock.calls as [MigrationLog][];
|
||||
const savedLog = calls[0][0];
|
||||
@@ -215,11 +199,7 @@ describe('AiService', () => {
|
||||
reasons: ['AI processing failed'],
|
||||
});
|
||||
|
||||
await service.handleWebhookCallback(
|
||||
failedPayload,
|
||||
'n8n',
|
||||
validAuthHeader
|
||||
);
|
||||
await service.handleWebhookCallback(failedPayload, 'n8n');
|
||||
|
||||
const calls = mockMigrationLogRepo.save.mock.calls as [MigrationLog][];
|
||||
const savedLog = calls[0][0];
|
||||
|
||||
@@ -87,7 +87,9 @@ export class AiService {
|
||||
this.n8nWebhookUrl =
|
||||
this.configService.get<string>('AI_N8N_WEBHOOK_URL') ?? '';
|
||||
this.n8nAuthToken =
|
||||
this.configService.get<string>('AI_N8N_AUTH_TOKEN') ?? '';
|
||||
this.configService.get<string>('AI_N8N_SERVICE_TOKEN') ??
|
||||
this.configService.get<string>('AI_N8N_AUTH_TOKEN') ??
|
||||
'';
|
||||
this.timeoutMs = this.configService.get<number>('AI_TIMEOUT_MS') ?? 30000;
|
||||
this.callbackBaseUrl =
|
||||
this.configService.get<string>('APP_BASE_URL') ?? 'http://localhost:3001';
|
||||
@@ -219,22 +221,10 @@ export class AiService {
|
||||
|
||||
async handleWebhookCallback(
|
||||
payload: AiCallbackDto,
|
||||
aiSource: string,
|
||||
authHeader: string
|
||||
aiSource: string
|
||||
): Promise<void> {
|
||||
// 1. ตรวจสอบ Service Account Authentication (ADR-018 Rule 2)
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
throw new ValidationException(
|
||||
'Missing or invalid Authorization header for AI callback'
|
||||
);
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
if (token !== this.n8nAuthToken) {
|
||||
throw new ValidationException('Invalid AI service account token');
|
||||
}
|
||||
|
||||
// 2. ค้นหา MigrationLog ด้วย publicId (ADR-019: ใช้ UUID เท่านั้น)
|
||||
// ServiceAccountGuard ผ่านการ validate Bearer token แล้วที่ controller layer (🟢 LOW-1)
|
||||
// 1. ค้นหา MigrationLog ด้วย publicId (ADR-019: ใช้ UUID เท่านั้น)
|
||||
const migrationLog = await this.migrationLogRepo.findOne({
|
||||
where: { publicId: payload.migrationLogPublicId },
|
||||
});
|
||||
@@ -369,6 +359,54 @@ export class AiService {
|
||||
return updated;
|
||||
}
|
||||
|
||||
// T026: Hard-delete AuditLogs (SYSTEM_ADMIN only — ADR-023)
|
||||
|
||||
/**
|
||||
* ลบ AiAuditLog แบบ hard delete ตามเกณฑ์ที่กำหนด
|
||||
* @returns จำนวน record ที่ถูกลบ
|
||||
*/
|
||||
async deleteAuditLogs(criteria: {
|
||||
documentPublicId?: string;
|
||||
olderThanDays?: number;
|
||||
}): Promise<{ deleted: number }> {
|
||||
if (!criteria.documentPublicId && !criteria.olderThanDays) {
|
||||
throw new ValidationException(
|
||||
'At least one deletion criterion (documentPublicId or olderThanDays) is required'
|
||||
);
|
||||
}
|
||||
|
||||
const qb = this.aiAuditLogRepo.createQueryBuilder('log');
|
||||
|
||||
if (criteria.documentPublicId) {
|
||||
qb.andWhere('log.documentPublicId = :docId', {
|
||||
docId: criteria.documentPublicId,
|
||||
});
|
||||
}
|
||||
|
||||
if (criteria.olderThanDays) {
|
||||
const cutoff = new Date();
|
||||
cutoff.setDate(cutoff.getDate() - criteria.olderThanDays);
|
||||
qb.andWhere('log.createdAt < :cutoff', { cutoff });
|
||||
}
|
||||
|
||||
const count = await qb.getCount();
|
||||
if (count === 0) return { deleted: 0 };
|
||||
|
||||
// ใช้ delete().execute() เพื่อออก SQL เดียว แทน N individual DELETEs
|
||||
const deleteQb = this.aiAuditLogRepo.createQueryBuilder('log').delete();
|
||||
if (criteria.olderThanDays) {
|
||||
const cutoff = new Date();
|
||||
cutoff.setDate(cutoff.getDate() - criteria.olderThanDays);
|
||||
deleteQb.andWhere('log.createdAt < :cutoff', { cutoff });
|
||||
}
|
||||
await deleteQb.execute();
|
||||
|
||||
this.logger.log(
|
||||
`Deleted ${count} AI audit log(s) — criteria=${JSON.stringify(criteria)}`
|
||||
);
|
||||
return { deleted: count };
|
||||
}
|
||||
|
||||
// --- Helper: บันทึก AuditLog ---
|
||||
|
||||
private async saveAuditLog(data: {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// File: src/modules/ai/dto/ai-rag-query.dto.ts
|
||||
// Change Log
|
||||
// - 2026-05-14: เพิ่ม DTO สำหรับ BullMQ RAG Query ตาม ADR-023 Phase 4.
|
||||
import { IsNotEmpty, IsString, IsUUID, MaxLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
/** DTO สำหรับส่ง RAG query เข้า BullMQ queue (FR-009, FR-010) */
|
||||
export class AiRagQueryDto {
|
||||
@ApiProperty({
|
||||
description: 'คำถามสำหรับ RAG ไม่เกิน 500 ตัวอักษร',
|
||||
maxLength: 500,
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(500)
|
||||
question!: string;
|
||||
|
||||
@ApiProperty({ description: 'publicId ของโครงการ (ADR-019) เพื่อ isolation' })
|
||||
@IsUUID()
|
||||
projectPublicId!: string;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// File: src/modules/ai/dto/delete-audit-logs.dto.ts
|
||||
// Change Log
|
||||
// - 2026-05-14: ย้าย DeleteAuditLogsQueryDto จาก ai.controller.ts เข้า dto/ folder (🟢 LOW-2).
|
||||
import { IsInt, IsOptional, IsUUID, Max, Min } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
/** Query params สำหรับ DELETE /ai/audit-logs (T026) */
|
||||
export class DeleteAuditLogsQueryDto {
|
||||
@ApiPropertyOptional({ description: 'UUID ของเอกสารที่ต้องการลบ log' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
documentPublicId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'ลบ log ที่เก่ากว่า N วัน (1-365)',
|
||||
minimum: 1,
|
||||
maximum: 365,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(365)
|
||||
olderThanDays?: number;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// File: src/modules/ai/dto/legacy-migration.dto.ts
|
||||
// Change Log
|
||||
// - 2026-05-14: เพิ่ม DTO สำหรับ ADR-023 legacy migration staging endpoints.
|
||||
import {
|
||||
IsEnum,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { MigrationReviewRecordStatus } from '../entities/migration-review.entity';
|
||||
|
||||
export class LegacyMigrationRecordDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
originalFileName?: string;
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
extractedMetadata?: Record<string, unknown>;
|
||||
|
||||
@Transform(({ value }: { value: unknown }) =>
|
||||
value === undefined || value === null || value === ''
|
||||
? undefined
|
||||
: Number(value)
|
||||
)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(1)
|
||||
@IsOptional()
|
||||
confidenceScore?: number;
|
||||
|
||||
@IsEnum(MigrationReviewRecordStatus)
|
||||
@IsOptional()
|
||||
status?: MigrationReviewRecordStatus;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
errorReason?: string;
|
||||
}
|
||||
|
||||
export class LegacyMigrationIngestDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
batchId!: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
source?: 'api' | 'folder-watcher';
|
||||
|
||||
@IsOptional()
|
||||
records?: LegacyMigrationRecordDto[] | string;
|
||||
}
|
||||
|
||||
export class LegacyMigrationQueueQueryDto {
|
||||
@Transform(({ value }: { value: unknown }) =>
|
||||
value === undefined ? 1 : Number(value)
|
||||
)
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
page?: number;
|
||||
|
||||
@Transform(({ value }: { value: unknown }) =>
|
||||
value === undefined ? 20 : Number(value)
|
||||
)
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
@IsOptional()
|
||||
limit?: number;
|
||||
|
||||
@IsEnum(MigrationReviewRecordStatus)
|
||||
@IsOptional()
|
||||
status?: MigrationReviewRecordStatus;
|
||||
}
|
||||
|
||||
export class ApproveLegacyMigrationDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
documentNumber!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
subject!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
categoryCode!: string;
|
||||
|
||||
@IsUUID()
|
||||
projectPublicId!: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
senderOrganizationPublicId?: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
receiverOrganizationPublicId?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
issuedDate?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
receivedDate?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
body?: string;
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
finalMetadata?: Record<string, unknown>;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
// File: src/modules/ai/entities/ai-audit-log.entity.ts
|
||||
// Entity สำหรับตาราง ai_audit_logs — บันทึก AI Interaction ทุกครั้งตาม ADR-018 Rule 5
|
||||
// Change Log
|
||||
// - 2026-05-14: เพิ่ม ADR-023 feedback fields โดยคง legacy audit fields ไว้ช่วงเปลี่ยนผ่าน.
|
||||
// Entity สำหรับตาราง ai_audit_logs — บันทึก AI Interaction และ feedback ตาม ADR-023
|
||||
|
||||
import {
|
||||
Entity,
|
||||
@@ -32,6 +34,24 @@ export class AiAuditLog extends UuidBaseEntity {
|
||||
@Column({ name: 'ai_model', type: 'varchar', length: 50 })
|
||||
aiModel!: string;
|
||||
|
||||
// ชื่อ Local Model ตาม ADR-023 development feedback log
|
||||
@Index('idx_ai_audit_model_name')
|
||||
@Column({ name: 'model_name', type: 'varchar', length: 100, nullable: true })
|
||||
modelName?: string;
|
||||
|
||||
// JSON ที่ AI แนะนำก่อนมนุษย์ตรวจสอบ
|
||||
@Column({ name: 'ai_suggestion_json', type: 'json', nullable: true })
|
||||
aiSuggestionJson?: Record<string, unknown>;
|
||||
|
||||
// JSON ที่มนุษย์ยืนยันหรือแก้ไขจริง
|
||||
@Column({ name: 'human_override_json', type: 'json', nullable: true })
|
||||
humanOverrideJson?: Record<string, unknown>;
|
||||
|
||||
// User ID ภายในของผู้ยืนยันผล AI
|
||||
@Index('idx_ai_audit_confirmed_by')
|
||||
@Column({ name: 'confirmed_by_user_id', type: 'int', nullable: true })
|
||||
confirmedByUserId?: number;
|
||||
|
||||
// เวลาประมวลผลเป็น milliseconds
|
||||
@Column({ name: 'processing_time_ms', type: 'int', nullable: true })
|
||||
processingTimeMs?: number;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// File: src/modules/ai/entities/migration-review.entity.ts
|
||||
// Change Log
|
||||
// - 2026-05-14: เพิ่ม entity staging queue สำหรับ Unified AI Architecture.
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
VersionColumn,
|
||||
} from 'typeorm';
|
||||
import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
|
||||
|
||||
export enum MigrationReviewRecordStatus {
|
||||
PENDING = 'PENDING',
|
||||
IMPORTED = 'IMPORTED',
|
||||
REJECTED = 'REJECTED',
|
||||
}
|
||||
|
||||
/** รายการเอกสารเก่าที่รอ human-in-the-loop validation ก่อน commit */
|
||||
@Entity('migration_review_queue')
|
||||
export class MigrationReviewRecord extends UuidBaseEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
|
||||
@Index('idx_migration_review_batch')
|
||||
@Column({ name: 'batch_id', type: 'varchar', length: 100 })
|
||||
batchId!: string;
|
||||
|
||||
@Column({ name: 'original_file_name', type: 'varchar', length: 255 })
|
||||
originalFileName!: string;
|
||||
|
||||
@Column({ name: 'source_attachment_public_id', type: 'uuid', nullable: true })
|
||||
sourceAttachmentPublicId?: string;
|
||||
|
||||
@Column({ name: 'temp_attachment_id', type: 'int', nullable: true })
|
||||
tempAttachmentId?: number;
|
||||
|
||||
@Column({ name: 'extracted_metadata', type: 'json', nullable: true })
|
||||
extractedMetadata?: Record<string, unknown>;
|
||||
|
||||
@Column({
|
||||
name: 'confidence_score',
|
||||
type: 'decimal',
|
||||
precision: 4,
|
||||
scale: 3,
|
||||
nullable: true,
|
||||
})
|
||||
confidenceScore?: number;
|
||||
|
||||
@Index('idx_migration_review_status')
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: MigrationReviewRecordStatus,
|
||||
default: MigrationReviewRecordStatus.PENDING,
|
||||
})
|
||||
status!: MigrationReviewRecordStatus;
|
||||
|
||||
@Column({ name: 'error_reason', type: 'text', nullable: true })
|
||||
errorReason?: string;
|
||||
|
||||
@VersionColumn({ name: 'version' })
|
||||
version!: number;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// File: src/modules/ai/guards/service-account.guard.ts
|
||||
// Change Log
|
||||
// - 2026-05-14: เพิ่ม Guard ตรวจสอบ n8n Service Account Token ตาม ADR-023.
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { timingSafeEqual } from 'crypto';
|
||||
import { Request } from 'express';
|
||||
|
||||
interface ServiceAccountRequest extends Request {
|
||||
headers: Request['headers'] & {
|
||||
authorization?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** ตรวจสอบ Bearer token ของ n8n service account โดยไม่ใช้ user JWT */
|
||||
@Injectable()
|
||||
export class ServiceAccountGuard implements CanActivate {
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest<ServiceAccountRequest>();
|
||||
const authorization = request.headers.authorization;
|
||||
const expectedToken =
|
||||
this.configService.get<string>('AI_N8N_SERVICE_TOKEN') ??
|
||||
this.configService.get<string>('AI_N8N_AUTH_TOKEN') ??
|
||||
'';
|
||||
|
||||
if (!expectedToken || !authorization?.startsWith('Bearer ')) {
|
||||
throw new UnauthorizedException('Invalid service account token');
|
||||
}
|
||||
|
||||
const actualToken = authorization.slice('Bearer '.length);
|
||||
if (!this.isEqual(actualToken, expectedToken)) {
|
||||
throw new UnauthorizedException('Invalid service account token');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private isEqual(actual: string, expected: string): boolean {
|
||||
const actualBuffer = Buffer.from(actual);
|
||||
const expectedBuffer = Buffer.from(expected);
|
||||
return (
|
||||
actualBuffer.length === expectedBuffer.length &&
|
||||
timingSafeEqual(actualBuffer, expectedBuffer)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
// File: src/modules/ai/processors/rag.processor.spec.ts
|
||||
// Change Log
|
||||
// - 2026-05-14: เพิ่ม Unit Test สำหรับ AiRagProcessor — ตรวจสอบ concurrency=1 และ AbortController (T030).
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { Job } from 'bullmq';
|
||||
import { AiRagProcessor } from './rag.processor';
|
||||
import { AiRagService } from '../ai-rag.service';
|
||||
import { AiRagJobPayload } from '../ai-queue.service';
|
||||
import { QUEUE_AI_RAG } from '../../common/constants/queue.constants';
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** สร้าง mock BullMQ Job สำหรับ RAG query */
|
||||
function makeJob(
|
||||
overrides: Partial<{
|
||||
id: string;
|
||||
requestPublicId: string;
|
||||
userPublicId: string;
|
||||
projectPublicId: string;
|
||||
query: string;
|
||||
}> = {}
|
||||
): Job<AiRagJobPayload> {
|
||||
return {
|
||||
id: overrides.id ?? 'job-001',
|
||||
data: {
|
||||
requestPublicId: overrides.requestPublicId ?? 'req-uuid-001',
|
||||
userPublicId: overrides.userPublicId ?? 'user-uuid-001',
|
||||
projectPublicId: overrides.projectPublicId ?? 'proj-uuid-001',
|
||||
query: overrides.query ?? 'What is the project scope?',
|
||||
},
|
||||
} as unknown as Job<AiRagJobPayload>;
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('AiRagProcessor', () => {
|
||||
let processor: AiRagProcessor;
|
||||
let ragService: jest.Mocked<AiRagService>;
|
||||
|
||||
const mockRagService: Partial<jest.Mocked<AiRagService>> = {
|
||||
processQuery: jest.fn().mockResolvedValue(undefined),
|
||||
getActiveJob: jest.fn().mockResolvedValue(null),
|
||||
registerActiveJob: jest.fn().mockResolvedValue(undefined),
|
||||
clearActiveJob: jest.fn().mockResolvedValue(undefined),
|
||||
cancelJob: jest.fn().mockResolvedValue(undefined),
|
||||
getJobResult: jest.fn().mockResolvedValue(null),
|
||||
saveJobResult: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AiRagProcessor,
|
||||
{ provide: AiRagService, useValue: mockRagService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
processor = module.get<AiRagProcessor>(AiRagProcessor);
|
||||
ragService = module.get(AiRagService);
|
||||
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
// ─── T030 Core: ตรวจสอบ concurrency=1 metadata ──────────────────────────
|
||||
|
||||
it('ควรมี @Processor decorator พร้อม queue name ที่ถูกต้อง', () => {
|
||||
// ตรวจสอบ QUEUE_AI_RAG constant ตรงกับที่ใช้ใน processor
|
||||
expect(QUEUE_AI_RAG).toBe('ai-rag-query');
|
||||
});
|
||||
|
||||
it('ควร process job และเรียก ragService.processQuery ด้วย AbortSignal', async () => {
|
||||
const job = makeJob({
|
||||
requestPublicId: 'req-abc',
|
||||
userPublicId: 'user-abc',
|
||||
query: 'test question',
|
||||
});
|
||||
|
||||
await processor.process(job);
|
||||
|
||||
expect(ragService.processQuery).toHaveBeenCalledTimes(1);
|
||||
expect(ragService.processQuery).toHaveBeenCalledWith(
|
||||
'req-abc',
|
||||
'test question',
|
||||
'proj-uuid-001',
|
||||
'user-abc',
|
||||
expect.any(AbortSignal) // T022: AbortSignal ต้องถูกส่งเข้าไปด้วย
|
||||
);
|
||||
});
|
||||
|
||||
it('ควร cleanup AbortController หลัง process เสร็จ (no memory leak)', async () => {
|
||||
const job = makeJob({ requestPublicId: 'req-cleanup' });
|
||||
|
||||
await processor.process(job);
|
||||
|
||||
// หลัง process เสร็จ ไม่ควรมี controller ค้างอยู่
|
||||
const aborted = processor.abortJob('req-cleanup');
|
||||
expect(aborted).toBe(false); // ถูก cleanup แล้ว
|
||||
});
|
||||
|
||||
it('ควร cleanup AbortController แม้ว่า processQuery จะ throw error', async () => {
|
||||
const job = makeJob({ requestPublicId: 'req-error' });
|
||||
ragService.processQuery.mockRejectedValueOnce(new Error('Ollama timeout'));
|
||||
|
||||
// ไม่ควร throw ออกมา (processor จัดการ error ภายใน)
|
||||
await expect(processor.process(job)).rejects.toThrow('Ollama timeout');
|
||||
|
||||
// ยังต้อง cleanup controller
|
||||
const aborted = processor.abortJob('req-error');
|
||||
expect(aborted).toBe(false);
|
||||
});
|
||||
|
||||
it('abortJob ควรคืน true เมื่อ job กำลัง processing', async () => {
|
||||
const requestPublicId = 'req-inprogress';
|
||||
// จำลอง processQuery ที่ใช้เวลานาน
|
||||
ragService.processQuery.mockImplementationOnce(
|
||||
(_reqId, _q, _proj, _user, signal) =>
|
||||
new Promise<void>((_resolve, reject) => {
|
||||
if (signal) {
|
||||
signal.addEventListener('abort', () =>
|
||||
reject(new Error('aborted'))
|
||||
);
|
||||
}
|
||||
// ไม่ resolve เพื่อจำลอง long-running job
|
||||
})
|
||||
);
|
||||
|
||||
const job = makeJob({ requestPublicId });
|
||||
const processingPromise = processor.process(job).catch(() => {
|
||||
/* expected */
|
||||
});
|
||||
|
||||
// รอให้ controller ถูก register ก่อน abort
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
const result = processor.abortJob(requestPublicId);
|
||||
expect(result).toBe(true);
|
||||
|
||||
await processingPromise;
|
||||
});
|
||||
|
||||
it('abortJob ควรคืน false เมื่อไม่มี job ที่ requestPublicId นั้น', () => {
|
||||
const result = processor.abortJob('non-existent-job');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
// ─── T030 Stress: ตรวจสอบ 1-active-job-per-user enforcement ─────────────
|
||||
|
||||
describe('1-Active-Job-Per-User Enforcement (FR-009 concurrency=1)', () => {
|
||||
it('ควรส่งคืน requestPublicId เดิมเมื่อ user มี active job อยู่แล้ว', async () => {
|
||||
const existingJobId = 'existing-request-uuid';
|
||||
ragService.getActiveJob.mockResolvedValueOnce(existingJobId);
|
||||
|
||||
const activeJob = await ragService.getActiveJob('user-uuid-999');
|
||||
expect(activeJob).toBe(existingJobId);
|
||||
});
|
||||
|
||||
it('ควรสามารถ registerActiveJob และ getActiveJob ได้สำหรับ user คนเดียว', async () => {
|
||||
const userPublicId = 'user-stress-test';
|
||||
const requestPublicId = 'new-req-uuid';
|
||||
|
||||
ragService.getActiveJob.mockResolvedValueOnce(null);
|
||||
ragService.registerActiveJob.mockResolvedValueOnce(undefined);
|
||||
ragService.getActiveJob.mockResolvedValueOnce(requestPublicId);
|
||||
|
||||
// ไม่มี active job เริ่มต้น
|
||||
const beforeJob = await ragService.getActiveJob(userPublicId);
|
||||
expect(beforeJob).toBeNull();
|
||||
|
||||
// ลงทะเบียน job
|
||||
await ragService.registerActiveJob(userPublicId, requestPublicId);
|
||||
|
||||
// ตรวจสอบว่า active job ถูกเก็บแล้ว
|
||||
const afterJob = await ragService.getActiveJob(userPublicId);
|
||||
expect(afterJob).toBe(requestPublicId);
|
||||
});
|
||||
|
||||
it('stress test: 10 requests ต่อเนื่อง — ควรพบ active job ตั้งแต่ request ที่ 2 เป็นต้นไป', async () => {
|
||||
const userPublicId = 'user-concurrent';
|
||||
const firstRequestId = 'first-req-uuid';
|
||||
|
||||
// ครั้งแรกไม่มี active job, หลังจากนั้นมี
|
||||
ragService.getActiveJob
|
||||
.mockResolvedValueOnce(null) // request 1: ไม่มี active job
|
||||
.mockResolvedValue(firstRequestId); // request 2-10: พบ active job
|
||||
|
||||
ragService.registerActiveJob.mockResolvedValue(undefined);
|
||||
|
||||
// Request 1: ไม่มี active job — ควรสร้างใหม่
|
||||
const req1Active = await ragService.getActiveJob(userPublicId);
|
||||
expect(req1Active).toBeNull();
|
||||
await ragService.registerActiveJob(userPublicId, firstRequestId);
|
||||
|
||||
// Requests 2-10: ทุกคำขอควรพบ active job เดิม
|
||||
const concurrentChecks = await Promise.all(
|
||||
Array.from({ length: 9 }, () => ragService.getActiveJob(userPublicId))
|
||||
);
|
||||
|
||||
concurrentChecks.forEach((activeId) => {
|
||||
expect(activeId).toBe(firstRequestId);
|
||||
});
|
||||
|
||||
// ยืนยันว่า registerActiveJob ถูกเรียกแค่ครั้งเดียว (job เดียว)
|
||||
expect(ragService.registerActiveJob).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
// File: src/modules/ai/processors/rag.processor.ts
|
||||
// Change Log
|
||||
// - 2026-05-14: เพิ่ม BullMQ Processor สำหรับ RAG query ตาม ADR-023 Phase 4 (T018, T022).
|
||||
// Processor นี้ใช้ concurrency = 1 เพื่อป้องกัน OOM บน Desk-5439 (FR-009)
|
||||
|
||||
import { Processor, WorkerHost, OnWorkerEvent } from '@nestjs/bullmq';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Job } from 'bullmq';
|
||||
import { AiRagService } from '../ai-rag.service';
|
||||
import { AiRagJobPayload } from '../ai-queue.service';
|
||||
import { QUEUE_AI_RAG } from '../../common/constants/queue.constants';
|
||||
|
||||
/**
|
||||
* Processor สำหรับ RAG query queue
|
||||
* - concurrency: 1 เพื่อป้องกัน VRAM overflow บน Desk-5439 (FR-009, Research Unknown 3)
|
||||
* - รองรับ AbortController เพื่อยกเลิก LLM generation เมื่อ client disconnect (T022, FR-011)
|
||||
*/
|
||||
@Processor(QUEUE_AI_RAG, { concurrency: 1 })
|
||||
export class AiRagProcessor extends WorkerHost {
|
||||
private readonly logger = new Logger(AiRagProcessor.name);
|
||||
|
||||
/** Map สำหรับเก็บ AbortController ของแต่ละ job (T022) */
|
||||
private readonly abortControllers = new Map<string, AbortController>();
|
||||
|
||||
constructor(private readonly ragService: AiRagService) {
|
||||
super();
|
||||
}
|
||||
|
||||
/** ประมวลผล RAG query job */
|
||||
async process(job: Job<AiRagJobPayload>): Promise<void> {
|
||||
const { requestPublicId, userPublicId, projectPublicId, query } = job.data;
|
||||
this.logger.log(
|
||||
`Processing RAG job — requestPublicId=${requestPublicId}, user=${userPublicId}`
|
||||
);
|
||||
|
||||
// สร้าง AbortController สำหรับ job นี้ (T022)
|
||||
const controller = new AbortController();
|
||||
this.abortControllers.set(requestPublicId, controller);
|
||||
|
||||
try {
|
||||
await this.ragService.processQuery(
|
||||
requestPublicId,
|
||||
query,
|
||||
projectPublicId,
|
||||
userPublicId,
|
||||
controller.signal
|
||||
);
|
||||
} finally {
|
||||
this.abortControllers.delete(requestPublicId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort การประมวลผล LLM สำหรับ job ที่ระบุ (T022 — FR-011)
|
||||
* ถูกเรียกจาก AiRagService.cancelJob() ผ่าน Redis cancel flag
|
||||
*/
|
||||
abortJob(requestPublicId: string): boolean {
|
||||
const controller = this.abortControllers.get(requestPublicId);
|
||||
if (controller) {
|
||||
controller.abort();
|
||||
this.abortControllers.delete(requestPublicId);
|
||||
this.logger.log(`Aborted RAG job — requestPublicId=${requestPublicId}`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Log เมื่อ job เสร็จสมบูรณ์ */
|
||||
@OnWorkerEvent('completed')
|
||||
onCompleted(job: Job<AiRagJobPayload>): void {
|
||||
this.logger.log(
|
||||
`RAG job completed — jobId=${String(job.id)}, requestPublicId=${job.data.requestPublicId}`
|
||||
);
|
||||
}
|
||||
|
||||
/** Log และ cleanup เมื่อ job ล้มเหลว */
|
||||
@OnWorkerEvent('failed')
|
||||
onFailed(job: Job<AiRagJobPayload> | undefined, err: Error): void {
|
||||
const id = job?.data?.requestPublicId ?? 'unknown';
|
||||
// ยกเลิก abort controller ที่ค้างไว้
|
||||
if (job?.data?.requestPublicId) {
|
||||
this.abortControllers.delete(job.data.requestPublicId);
|
||||
}
|
||||
this.logger.error(`RAG job failed — requestPublicId=${id}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// File: src/modules/ai/processors/vector-deletion.processor.ts
|
||||
// Change Log
|
||||
// - 2026-05-14: เพิ่ม BullMQ Processor สำหรับลบ vector ใน Qdrant แบบ async ตาม ADR-023 FR-008 (T027).
|
||||
import { Processor, WorkerHost } from '@nestjs/bullmq';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Job } from 'bullmq';
|
||||
import { QUEUE_AI_VECTOR_DELETION } from '../../common/constants/queue.constants';
|
||||
import { AiQdrantService } from '../qdrant.service';
|
||||
import { AiVectorDeletionJobPayload } from '../ai-queue.service';
|
||||
|
||||
/**
|
||||
* Processor สำหรับลบ vector ของเอกสารที่ถูกลบออกจาก Qdrant แบบ asynchronous
|
||||
* รองรับ retry 3 ครั้ง (ADR-008 + FR-008) เพื่อ eventual consistency เมื่อ Qdrant ไม่พร้อม
|
||||
*/
|
||||
@Processor(QUEUE_AI_VECTOR_DELETION)
|
||||
export class AiVectorDeletionProcessor extends WorkerHost {
|
||||
private readonly logger = new Logger(AiVectorDeletionProcessor.name);
|
||||
|
||||
constructor(private readonly qdrantService: AiQdrantService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async process(job: Job<AiVectorDeletionJobPayload>): Promise<void> {
|
||||
const { documentPublicId, requestedByUserPublicId } = job.data;
|
||||
|
||||
this.logger.log(
|
||||
`Vector deletion started — documentPublicId=${documentPublicId}, jobId=${String(job.id)}, requestedBy=${requestedByUserPublicId}`
|
||||
);
|
||||
|
||||
await this.qdrantService.deleteByDocumentPublicId(documentPublicId);
|
||||
|
||||
this.logger.log(
|
||||
`Vector deletion completed — documentPublicId=${documentPublicId}, jobId=${String(job.id)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// File: src/modules/ai/qdrant.service.ts
|
||||
// Change Log
|
||||
// - 2026-05-14: เพิ่ม Qdrant gateway สำหรับ AI Module พร้อม project payload filter.
|
||||
// - 2026-05-14: เพิ่ม OnModuleInit เพื่อ auto-call ensureCollection() (💡 S2).
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
OnModuleInit,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { QdrantClient } from '@qdrant/js-client-rest';
|
||||
|
||||
const AI_COLLECTION_NAME = 'lcbp3_vectors';
|
||||
const AI_VECTOR_SIZE = 768;
|
||||
|
||||
export interface AiVectorSearchResult {
|
||||
pointId: string | number;
|
||||
score: number;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Gateway กลางสำหรับ Qdrant ที่บังคับ project_public_id ทุก search */
|
||||
@Injectable()
|
||||
export class AiQdrantService implements OnModuleInit {
|
||||
private readonly logger = new Logger(AiQdrantService.name);
|
||||
private readonly client: QdrantClient;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
const url =
|
||||
this.configService.get<string>('AI_QDRANT_URL') ??
|
||||
this.configService.get<string>('QDRANT_URL') ??
|
||||
'http://localhost:6333';
|
||||
this.client = new QdrantClient({ url });
|
||||
}
|
||||
|
||||
/** เรียก ensureCollection() อัตโนมัติเมื่อโมดูลถูก bootstrap */
|
||||
async onModuleInit(): Promise<void> {
|
||||
try {
|
||||
await this.ensureCollection();
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`AiQdrantService: collection init failed — ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** เตรียม collection และ tenant payload index สำหรับ project isolation */
|
||||
async ensureCollection(): Promise<void> {
|
||||
const collections = await this.client.getCollections();
|
||||
const exists = collections.collections.some(
|
||||
(collection) => collection.name === AI_COLLECTION_NAME
|
||||
);
|
||||
|
||||
if (!exists) {
|
||||
await this.client.createCollection(AI_COLLECTION_NAME, {
|
||||
vectors: { size: AI_VECTOR_SIZE, distance: 'Cosine' },
|
||||
});
|
||||
await this.client.createPayloadIndex(AI_COLLECTION_NAME, {
|
||||
field_name: 'project_public_id',
|
||||
field_schema: { type: 'keyword', is_tenant: true } as Parameters<
|
||||
QdrantClient['createPayloadIndex']
|
||||
>[1]['field_schema'],
|
||||
});
|
||||
this.logger.log(`Created Qdrant collection ${AI_COLLECTION_NAME}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** ค้นหา vector โดยบังคับ projectPublicId เพื่อป้องกันข้อมูลข้ามโครงการ */
|
||||
async searchByProject(
|
||||
vector: number[],
|
||||
projectPublicId: string,
|
||||
limit: number
|
||||
): Promise<AiVectorSearchResult[]> {
|
||||
if (!projectPublicId) {
|
||||
throw new ServiceUnavailableException('AI_QDRANT_PROJECT_SCOPE_REQUIRED');
|
||||
}
|
||||
|
||||
const results = await this.client.search(AI_COLLECTION_NAME, {
|
||||
vector,
|
||||
limit,
|
||||
filter: {
|
||||
must: [{ key: 'project_public_id', match: { value: projectPublicId } }],
|
||||
},
|
||||
with_payload: true,
|
||||
});
|
||||
|
||||
return results.map((result) => ({
|
||||
pointId: result.id,
|
||||
score: result.score,
|
||||
payload: result.payload ?? {},
|
||||
}));
|
||||
}
|
||||
|
||||
/** ลบ vector ของเอกสารด้วย publicId ผ่าน queue processor ในขั้นถัดไป */
|
||||
async deleteByDocumentPublicId(documentPublicId: string): Promise<void> {
|
||||
await this.client.delete(AI_COLLECTION_NAME, {
|
||||
wait: true,
|
||||
filter: {
|
||||
must: [{ key: 'public_id', match: { value: documentPublicId } }],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"name": "LCBP3 ADR-023 Folder Watcher",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"path": "lcbp3-ai-folder-watcher",
|
||||
"httpMethod": "POST",
|
||||
"responseMode": "responseNode"
|
||||
},
|
||||
"id": "folder-watcher-webhook",
|
||||
"name": "Watched Folder Trigger",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"position": [240, 300]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "={{$env.DMS_API_URL}}/api/ai/legacy-migration/ingest",
|
||||
"sendHeaders": true,
|
||||
"headerParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "Authorization",
|
||||
"value": "=Bearer {{$env.AI_N8N_SERVICE_TOKEN}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sendBody": true,
|
||||
"bodyParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "batchId",
|
||||
"value": "={{$json.batchId}}"
|
||||
},
|
||||
{
|
||||
"name": "source",
|
||||
"value": "folder-watcher"
|
||||
},
|
||||
{
|
||||
"name": "records",
|
||||
"value": "={{JSON.stringify($json.records || [])}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"id": "post-to-dms",
|
||||
"name": "POST to DMS AI Ingest",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4,
|
||||
"position": [520, 300]
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Watched Folder Trigger": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "POST to DMS AI Ingest",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user