feat(ai): unify AI architecture, implement RAG and legacy migration
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user