Files
lcbp3/backend/src/modules/ai/ai-ingest.service.ts
T
admin 6cb3ae10ee
CI / CD Pipeline / build (push) Failing after 5m36s
CI / CD Pipeline / deploy (push) Has been skipped
feat(ai): unify AI architecture, implement RAG and legacy migration
2026-05-15 11:10:44 +07:00

382 lines
13 KiB
TypeScript

// 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,
};
}
}