feat(rfa-ai): Complete RFA Approval Refactor and AI Model Revision
This commit is contained in:
@@ -17,27 +17,26 @@ import { RedisModule } from '@nestjs-modules/ioredis';
|
||||
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { envValidationSchema } from './common/config/env.validation.js';
|
||||
import { envValidationSchema } from './common/config/env.validation';
|
||||
import redisConfig from './common/config/redis.config';
|
||||
import { winstonConfig } from './modules/monitoring/logger/winston.config';
|
||||
|
||||
// Entities & Interceptors
|
||||
import { AuditLog } from './common/entities/audit-log.entity';
|
||||
import { AuditLogInterceptor } from './common/interceptors/audit-log.interceptor';
|
||||
import { IdempotencyInterceptor } from './common/interceptors/idempotency.interceptor';
|
||||
import { MaintenanceModeGuard } from './common/guards/maintenance-mode.guard';
|
||||
|
||||
// Modules
|
||||
import { CommonModule } from './common/common.module';
|
||||
import { AuthModule } from './common/auth/auth.module.js';
|
||||
import { AuthModule } from './common/auth/auth.module';
|
||||
import { UserModule } from './modules/user/user.module';
|
||||
import { ProjectModule } from './modules/project/project.module';
|
||||
import { OrganizationModule } from './modules/organization/organization.module';
|
||||
import { ContractModule } from './modules/contract/contract.module';
|
||||
import { MasterModule } from './modules/master/master.module'; // [NEW] ✅ เพิ่ม MasterModule
|
||||
import { FileStorageModule } from './common/file-storage/file-storage.module.js';
|
||||
import { FileStorageModule } from './common/file-storage/file-storage.module';
|
||||
import { DocumentNumberingModule } from './modules/document-numbering/document-numbering.module';
|
||||
import { JsonSchemaModule } from './modules/json-schema/json-schema.module.js';
|
||||
import { JsonSchemaModule } from './modules/json-schema/json-schema.module';
|
||||
import { WorkflowEngineModule } from './modules/workflow-engine/workflow-engine.module';
|
||||
import { CorrespondenceModule } from './modules/correspondence/correspondence.module';
|
||||
import { RfaModule } from './modules/rfa/rfa.module';
|
||||
@@ -136,9 +135,6 @@ import { DistributionModule } from './modules/distribution/distribution.module';
|
||||
}),
|
||||
}),
|
||||
|
||||
// Register AuditLog Entity (Global Scope)
|
||||
TypeOrmModule.forFeature([AuditLog]),
|
||||
|
||||
// 3. BullMQ (Redis) Setup
|
||||
BullModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
|
||||
@@ -8,12 +8,12 @@ import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AuthService } from './auth.service.js';
|
||||
import { AuthController } from './auth.controller.js';
|
||||
import { SessionController } from './session.controller.js';
|
||||
import { UserModule } from '../../modules/user/user.module.js';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy.js';
|
||||
import { JwtRefreshStrategy } from './strategies/jwt-refresh.strategy.js';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { SessionController } from './session.controller';
|
||||
import { UserModule } from '../../modules/user/user.module';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { JwtRefreshStrategy } from './strategies/jwt-refresh.strategy';
|
||||
import { User } from '../../modules/user/entities/user.entity';
|
||||
import { RefreshToken } from './entities/refresh-token.entity'; // [P2-2]
|
||||
import { CaslModule } from './casl/casl.module';
|
||||
|
||||
@@ -7,7 +7,7 @@ import { ConfigService } from '@nestjs/config';
|
||||
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||
import type { Cache } from 'cache-manager';
|
||||
import { Request } from 'express';
|
||||
import { UserService } from '../../../modules/user/user.service.js';
|
||||
import { UserService } from '../../../modules/user/user.service';
|
||||
|
||||
// Interface สำหรับ Payload ใน Token
|
||||
export interface JwtPayload {
|
||||
|
||||
@@ -47,6 +47,14 @@ export class Attachment extends UuidBaseEntity {
|
||||
@Column({ name: 'reference_date', type: 'date', nullable: true })
|
||||
referenceDate?: Date;
|
||||
|
||||
@Column({
|
||||
name: 'ai_processing_status',
|
||||
type: 'enum',
|
||||
enum: ['PENDING', 'PROCESSING', 'DONE', 'FAILED'],
|
||||
default: 'PENDING',
|
||||
})
|
||||
aiProcessingStatus!: 'PENDING' | 'PROCESSING' | 'DONE' | 'FAILED';
|
||||
|
||||
// ADR-021: FK ไปยัง workflow_histories สำหรับไฟล์แนบประจำ Step
|
||||
// NULL = ไฟล์แนบหลัก (Main Document), NOT NULL = ไฟล์ประจำ Workflow Step
|
||||
@Column({ name: 'workflow_history_id', nullable: true })
|
||||
|
||||
@@ -2,18 +2,26 @@ import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { ScheduleModule } from '@nestjs/schedule'; // ✅ Import
|
||||
import { FileStorageService } from './file-storage.service.js';
|
||||
import { FileStorageController } from './file-storage.controller.js';
|
||||
import { FileCleanupService } from './file-cleanup.service.js'; // ✅ Import
|
||||
import { FileStorageService } from './file-storage.service';
|
||||
import { FileStorageController } from './file-storage.controller';
|
||||
import { FileCleanupService } from './file-cleanup.service'; // ✅ Import
|
||||
import { Attachment } from './entities/attachment.entity';
|
||||
import { UserModule } from '../../modules/user/user.module';
|
||||
import {
|
||||
QUEUE_AI_BATCH,
|
||||
QUEUE_AI_REALTIME,
|
||||
} from '../../modules/common/constants/queue.constants';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Attachment]),
|
||||
ScheduleModule.forRoot(), // ✅ เปิดใช้งาน Cron Job],
|
||||
UserModule,
|
||||
BullModule.registerQueue({ name: 'rag-ocr' }),
|
||||
BullModule.registerQueue(
|
||||
{ name: 'rag-ocr' },
|
||||
{ name: QUEUE_AI_REALTIME },
|
||||
{ name: QUEUE_AI_BATCH }
|
||||
),
|
||||
],
|
||||
controllers: [FileStorageController],
|
||||
providers: [
|
||||
|
||||
@@ -17,6 +17,10 @@ import * as crypto from 'crypto';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Attachment } from './entities/attachment.entity';
|
||||
import { ForbiddenException } from '@nestjs/common'; // ✅ Import เพิ่ม
|
||||
import {
|
||||
QUEUE_AI_BATCH,
|
||||
QUEUE_AI_REALTIME,
|
||||
} from '../../modules/common/constants/queue.constants';
|
||||
|
||||
@Injectable()
|
||||
export class FileStorageService {
|
||||
@@ -28,7 +32,13 @@ export class FileStorageService {
|
||||
@InjectRepository(Attachment)
|
||||
private attachmentRepository: Repository<Attachment>,
|
||||
private configService: ConfigService,
|
||||
@Optional() @InjectQueue('rag-ocr') private readonly ragOcrQueue?: Queue
|
||||
@Optional() @InjectQueue('rag-ocr') private readonly ragOcrQueue?: Queue,
|
||||
@Optional()
|
||||
@InjectQueue(QUEUE_AI_REALTIME)
|
||||
private readonly aiRealtimeQueue?: Queue,
|
||||
@Optional()
|
||||
@InjectQueue(QUEUE_AI_BATCH)
|
||||
private readonly aiBatchQueue?: Queue
|
||||
) {
|
||||
// ใช้ env vars จาก docker-compose สำหรับ Production
|
||||
// ถ้าไม่ได้กำหนดจะ fallback เป็น ./uploads/temp และ ./uploads/permanent
|
||||
@@ -185,6 +195,13 @@ export class FileStorageService {
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (options?.ragMeta?.projectPublicId) {
|
||||
await this.enqueueAiJobsForCommittedAttachment(
|
||||
saved,
|
||||
options.ragMeta.projectPublicId
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.logger.error(`File missing during commit: ${oldPath}`);
|
||||
throw new NotFoundException(
|
||||
@@ -279,6 +296,57 @@ export class FileStorageService {
|
||||
return crypto.createHash('sha256').update(buffer).digest('hex');
|
||||
}
|
||||
|
||||
private async enqueueAiJobsForCommittedAttachment(
|
||||
attachment: Attachment,
|
||||
projectPublicId: string
|
||||
): Promise<void> {
|
||||
const commonPayload = {
|
||||
documentPublicId: attachment.publicId,
|
||||
projectPublicId,
|
||||
payload: { pdfPath: attachment.filePath },
|
||||
};
|
||||
const suggestResult = await this.aiRealtimeQueue
|
||||
?.add(
|
||||
'ai-suggest',
|
||||
{
|
||||
...commonPayload,
|
||||
jobType: 'ai-suggest',
|
||||
idempotencyKey: `suggest:${attachment.publicId}`,
|
||||
},
|
||||
{ jobId: `suggest:${attachment.publicId}` }
|
||||
)
|
||||
.then(() => true)
|
||||
.catch((err: unknown) => {
|
||||
this.logger.warn(
|
||||
`AI job queue failed, document saved without AI: ${attachment.publicId} (${err instanceof Error ? err.message : String(err)})`
|
||||
);
|
||||
return false;
|
||||
});
|
||||
const embedResult = await this.aiBatchQueue
|
||||
?.add(
|
||||
'embed-document',
|
||||
{
|
||||
...commonPayload,
|
||||
jobType: 'embed-document',
|
||||
idempotencyKey: `embed:${attachment.publicId}`,
|
||||
},
|
||||
{ jobId: `embed:${attachment.publicId}` }
|
||||
)
|
||||
.then(() => true)
|
||||
.catch((err: unknown) => {
|
||||
this.logger.warn(
|
||||
`AI job queue failed, document saved without AI: ${attachment.publicId} (${err instanceof Error ? err.message : String(err)})`
|
||||
);
|
||||
return false;
|
||||
});
|
||||
if (suggestResult === false || embedResult === false) {
|
||||
await this.attachmentRepository.update(
|
||||
{ publicId: attachment.publicId },
|
||||
{ aiProcessingStatus: 'FAILED' }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ✅ NEW: Import Staging File (For Legacy Migration)
|
||||
* ย้ายไฟล์จาก staging_ai ไปยัง permanent storage โดยตรง
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// File: src/config/bullmq.config.ts
|
||||
// Change Log:
|
||||
// - 2026-05-13: Add BullMQ config registry for reminder and distribution queues.
|
||||
// - 2026-05-15: เพิ่ม config สำหรับ ai-realtime และ ai-batch ตาม ADR-023A.
|
||||
|
||||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
@@ -9,6 +10,26 @@ export default registerAs('bullmq', () => ({
|
||||
reminderQueue: process.env.BULLMQ_REMINDER_QUEUE || 'rfa-reminders',
|
||||
distributionQueue:
|
||||
process.env.BULLMQ_DISTRIBUTION_QUEUE || 'rfa-distribution',
|
||||
aiRealtimeQueue: {
|
||||
name: process.env.BULLMQ_AI_REALTIME_QUEUE || 'ai-realtime',
|
||||
concurrency: 1,
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
backoff: { type: 'exponential', delay: 2000 },
|
||||
removeOnComplete: 100,
|
||||
removeOnFail: 200,
|
||||
},
|
||||
},
|
||||
aiBatchQueue: {
|
||||
name: process.env.BULLMQ_AI_BATCH_QUEUE || 'ai-batch',
|
||||
concurrency: 1,
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
backoff: { type: 'exponential', delay: 5000 },
|
||||
removeOnComplete: 100,
|
||||
removeOnFail: 500,
|
||||
},
|
||||
},
|
||||
connection: {
|
||||
host: process.env.REDIS_HOST || 'cache',
|
||||
port: Number(process.env.REDIS_PORT || '6379'),
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { Readable } from 'stream';
|
||||
import { AiIngestService } from './ai-ingest.service';
|
||||
import { AiQueueService } from './ai-queue.service';
|
||||
import { FileStorageService } from '../../common/file-storage/file-storage.service';
|
||||
@@ -35,7 +36,7 @@ function makeFile(
|
||||
mimetype: 'application/pdf',
|
||||
buffer: Buffer.from('pdf-content'),
|
||||
size: 1024,
|
||||
stream: null as unknown as NodeJS.ReadableStream,
|
||||
stream: new Readable(),
|
||||
destination: '',
|
||||
filename: 'test.pdf',
|
||||
path: '',
|
||||
|
||||
@@ -41,6 +41,7 @@ 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 { CreateAiJobDto } from './dto/create-ai-job.dto';
|
||||
import { MigrationUpdateDto } from './dto/migration-update.dto';
|
||||
import { MigrationQueryDto } from './dto/migration-query.dto';
|
||||
import {
|
||||
@@ -71,6 +72,49 @@ export class AiController {
|
||||
|
||||
// --- Real-time Extraction (User Upload) ---
|
||||
|
||||
@Post('suggest')
|
||||
@UseGuards(JwtAuthGuard, RbacGuard)
|
||||
@ApiBearerAuth()
|
||||
@RequirePermission('ai.suggest')
|
||||
@HttpCode(HttpStatus.ACCEPTED)
|
||||
@ApiOperation({
|
||||
summary: 'AI Suggest — enqueue metadata suggestion job',
|
||||
description:
|
||||
'รับ documentPublicId/projectPublicId แล้วส่งงานเข้า ai-realtime queue เพื่อให้ frontend polling สถานะ',
|
||||
})
|
||||
@ApiHeader({
|
||||
name: 'Idempotency-Key',
|
||||
description: 'Unique key เพื่อป้องกัน duplicate AI Suggest job',
|
||||
required: true,
|
||||
})
|
||||
async suggestDocumentMetadata(
|
||||
@Body() dto: CreateAiJobDto,
|
||||
@Headers('idempotency-key') idempotencyKey: string
|
||||
): Promise<{ success: boolean; jobId?: string; status: string }> {
|
||||
const result = await this.aiService.queueSuggestJob({
|
||||
...dto,
|
||||
jobType: 'ai-suggest',
|
||||
idempotencyKey: idempotencyKey || dto.idempotencyKey,
|
||||
});
|
||||
return {
|
||||
success: result.success,
|
||||
jobId: result.jobId,
|
||||
status: result.success ? 'queued' : 'failed',
|
||||
};
|
||||
}
|
||||
|
||||
@Get('jobs/:jobId/status')
|
||||
@UseGuards(JwtAuthGuard, RbacGuard)
|
||||
@ApiBearerAuth()
|
||||
@RequirePermission('ai.suggest')
|
||||
@ApiOperation({
|
||||
summary: 'AI Job Status — polling endpoint สำหรับ AI Suggest',
|
||||
})
|
||||
@ApiParam({ name: 'jobId', description: 'BullMQ job id' })
|
||||
async getAiJobStatus(@Param('jobId') jobId: string) {
|
||||
return this.aiService.getAiJobStatus(jobId);
|
||||
}
|
||||
|
||||
@Post('extract')
|
||||
@UseGuards(JwtAuthGuard, RbacGuard)
|
||||
@ApiBearerAuth()
|
||||
@@ -202,6 +246,43 @@ export class AiController {
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Phase 6: AI Analytics & Single Audit Log Delete (T036, T037) ────────
|
||||
|
||||
@Get('analytics/summary')
|
||||
@UseGuards(JwtAuthGuard, RbacGuard)
|
||||
@ApiBearerAuth()
|
||||
@RequirePermission('ai.read_analytics')
|
||||
@ApiOperation({
|
||||
summary: 'AI Analytics Summary — สรุปสถิติ AI Audit Logs (T036)',
|
||||
description:
|
||||
'คำนวณ avgConfidence, overrideRate, rejectedRate แยกตาม document type และ overall',
|
||||
})
|
||||
async getAnalyticsSummary() {
|
||||
return this.aiService.getAnalyticsSummary();
|
||||
}
|
||||
|
||||
@Delete('audit-logs/:publicId')
|
||||
@UseGuards(JwtAuthGuard, RbacGuard)
|
||||
@ApiBearerAuth()
|
||||
@RequirePermission('ai.delete_audit')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'AI Audit Log Single Delete — ลบ log เดี่ยวโดย publicId (SYSTEM_ADMIN เท่านั้น) (T037)',
|
||||
description:
|
||||
'ลบ AiAuditLog เดี่ยวและบันทึกใน audit_logs (action: AI_AUDIT_LOG_DELETED)',
|
||||
})
|
||||
@ApiParam({
|
||||
name: 'publicId',
|
||||
description: 'UUID ของ AiAuditLog (ADR-019)',
|
||||
})
|
||||
async deleteAuditLogByPublicId(
|
||||
@Param('publicId', ParseUuidPipe) publicId: string,
|
||||
@CurrentUser() user: User
|
||||
): Promise<{ deleted: boolean; publicId: string }> {
|
||||
return this.aiService.deleteAuditLogByPublicId(publicId, user.user_id);
|
||||
}
|
||||
|
||||
// ─── RAG Query Endpoints (Phase 4 — FR-009, FR-010, FR-011) ────────────────
|
||||
|
||||
@Post('rag/query')
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
// File: src/modules/ai/ai.module.ts
|
||||
// Change Log
|
||||
// - 2026-05-14: เพิ่ม BullMQ/Qdrant/Service Account foundation สำหรับ ADR-023.
|
||||
// - 2026-05-15: เพิ่ม ai-realtime/ai-batch foundation และ stale paused recovery ตาม ADR-023A.
|
||||
// Module สำหรับ AI Gateway — ลงทะเบียน Services และ Controllers (ADR-023)
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Logger, Module, OnModuleInit } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { BullModule, InjectQueue } from '@nestjs/bullmq';
|
||||
import { Queue } from 'bullmq';
|
||||
import { AiController } from './ai.controller';
|
||||
import { AiService } from './ai.service';
|
||||
import { AiIngestService } from './ai-ingest.service';
|
||||
@@ -16,20 +18,30 @@ import { AiQdrantService } from './qdrant.service';
|
||||
import { AiValidationService } from './ai-validation.service';
|
||||
import { AiRagService } from './ai-rag.service';
|
||||
import { AiRagProcessor } from './processors/rag.processor';
|
||||
import { AiRealtimeProcessor } from './processors/ai-realtime.processor';
|
||||
import { AiBatchProcessor } from './processors/ai-batch.processor';
|
||||
import { AiVectorDeletionProcessor } from './processors/vector-deletion.processor';
|
||||
import { OllamaService } from './services/ollama.service';
|
||||
import { OcrService } from './services/ocr.service';
|
||||
import { EmbeddingService } from './services/embedding.service';
|
||||
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 { AuditLogModule } from '../audit-log/audit-log.module';
|
||||
import { AuditLog } from '../../common/entities/audit-log.entity';
|
||||
import { Attachment } from '../../common/file-storage/entities/attachment.entity';
|
||||
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_BATCH,
|
||||
QUEUE_AI_INGEST,
|
||||
QUEUE_AI_RAG,
|
||||
QUEUE_AI_REALTIME,
|
||||
QUEUE_AI_VECTOR_DELETION,
|
||||
} from '../common/constants/queue.constants';
|
||||
|
||||
@@ -39,7 +51,9 @@ import {
|
||||
TypeOrmModule.forFeature([
|
||||
MigrationLog,
|
||||
AiAuditLog,
|
||||
AuditLog,
|
||||
MigrationReviewRecord,
|
||||
Attachment,
|
||||
Project,
|
||||
Organization,
|
||||
CorrespondenceType,
|
||||
@@ -47,6 +61,24 @@ import {
|
||||
|
||||
BullModule.registerQueue(
|
||||
{ name: QUEUE_AI_INGEST },
|
||||
{
|
||||
name: QUEUE_AI_REALTIME,
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
backoff: { type: 'exponential', delay: 2000 },
|
||||
removeOnComplete: 100,
|
||||
removeOnFail: 200,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: QUEUE_AI_BATCH,
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
backoff: { type: 'exponential', delay: 5000 },
|
||||
removeOnComplete: 100,
|
||||
removeOnFail: 500,
|
||||
},
|
||||
},
|
||||
{ name: QUEUE_AI_RAG },
|
||||
{ name: QUEUE_AI_VECTOR_DELETION }
|
||||
),
|
||||
@@ -64,6 +96,7 @@ import {
|
||||
UserModule,
|
||||
MigrationModule,
|
||||
FileStorageModule,
|
||||
AuditLogModule,
|
||||
],
|
||||
controllers: [AiController],
|
||||
providers: [
|
||||
@@ -72,6 +105,11 @@ import {
|
||||
AiQueueService,
|
||||
AiQdrantService,
|
||||
AiValidationService,
|
||||
OllamaService,
|
||||
OcrService,
|
||||
EmbeddingService,
|
||||
AiRealtimeProcessor,
|
||||
AiBatchProcessor,
|
||||
// Phase 4: RAG BullMQ pipeline (ADR-023)
|
||||
AiRagService,
|
||||
AiRagProcessor,
|
||||
@@ -86,7 +124,28 @@ import {
|
||||
AiQueueService,
|
||||
AiQdrantService,
|
||||
AiValidationService,
|
||||
OllamaService,
|
||||
OcrService,
|
||||
AiRagService,
|
||||
],
|
||||
})
|
||||
export class AiModule {}
|
||||
export class AiModule implements OnModuleInit {
|
||||
private readonly logger = new Logger(AiModule.name);
|
||||
|
||||
constructor(
|
||||
@InjectQueue(QUEUE_AI_REALTIME)
|
||||
private readonly aiRealtimeQueue: Queue,
|
||||
@InjectQueue(QUEUE_AI_BATCH)
|
||||
private readonly aiBatchQueue: Queue
|
||||
) {}
|
||||
|
||||
/** ป้องกัน ai-batch ค้าง paused หลัง service restart ระหว่าง ai-realtime job */
|
||||
async onModuleInit(): Promise<void> {
|
||||
const isPaused = await this.aiBatchQueue.isPaused();
|
||||
const activeCount = await this.aiRealtimeQueue.getActiveCount();
|
||||
if (isPaused && activeCount === 0) {
|
||||
await this.aiBatchQueue.resume();
|
||||
this.logger.warn('ai-batch auto-resumed on startup (stale paused state)');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { getQueueToken } from '@nestjs/bullmq';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AiService } from './ai.service';
|
||||
@@ -15,6 +16,11 @@ import { AiAuditLog, AiAuditStatus } from './entities/ai-audit-log.entity';
|
||||
import { AiCallbackDto } from './dto/ai-callback.dto';
|
||||
import { MigrationUpdateDto } from './dto/migration-update.dto';
|
||||
import { NotFoundException, BusinessException } from '../../common/exceptions';
|
||||
import { AuditLog } from '../../common/entities/audit-log.entity';
|
||||
import {
|
||||
QUEUE_AI_BATCH,
|
||||
QUEUE_AI_REALTIME,
|
||||
} from '../common/constants/queue.constants';
|
||||
|
||||
describe('AiService', () => {
|
||||
let service: AiService;
|
||||
@@ -38,6 +44,19 @@ describe('AiService', () => {
|
||||
save: jest.fn(),
|
||||
};
|
||||
|
||||
const mockMainAuditLogRepo = {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
};
|
||||
|
||||
const mockQueue = {
|
||||
add: jest.fn(),
|
||||
isPaused: jest.fn().mockResolvedValue(false),
|
||||
getActiveCount: jest.fn().mockResolvedValue(0),
|
||||
resume: jest.fn(),
|
||||
getState: jest.fn().mockResolvedValue('completed'),
|
||||
};
|
||||
|
||||
// Mock ConfigService — คืนค่า Config ตาม Key
|
||||
const mockConfigService = {
|
||||
get: jest.fn((key: string) => {
|
||||
@@ -80,6 +99,8 @@ describe('AiService', () => {
|
||||
);
|
||||
mockAuditLogRepo.create.mockReturnValue({});
|
||||
mockAuditLogRepo.save.mockResolvedValue({});
|
||||
mockMainAuditLogRepo.create.mockReturnValue({});
|
||||
mockMainAuditLogRepo.save.mockResolvedValue({});
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
@@ -89,6 +110,12 @@ describe('AiService', () => {
|
||||
useValue: mockMigrationLogRepo,
|
||||
},
|
||||
{ provide: getRepositoryToken(AiAuditLog), useValue: mockAuditLogRepo },
|
||||
{
|
||||
provide: getRepositoryToken(AuditLog),
|
||||
useValue: mockMainAuditLogRepo,
|
||||
},
|
||||
{ provide: getQueueToken(QUEUE_AI_REALTIME), useValue: mockQueue },
|
||||
{ provide: getQueueToken(QUEUE_AI_BATCH), useValue: mockQueue },
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
{ provide: HttpService, useValue: mockHttpService },
|
||||
{ provide: AiValidationService, useValue: mockValidationService },
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
// File: src/modules/ai/ai.service.ts
|
||||
// Service หลักของ AI Gateway — เชื่อมต่อระหว่าง DMS กับ n8n/Ollama Pipeline (ADR-018, ADR-020)
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Injectable, Logger, Optional } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Job, Queue } from 'bullmq';
|
||||
import { firstValueFrom, timeout, catchError } from 'rxjs';
|
||||
import { AxiosError } from 'axios';
|
||||
import {
|
||||
@@ -25,6 +27,14 @@ import { ExtractDocumentDto } from './dto/extract-document.dto';
|
||||
import { MigrationUpdateDto } from './dto/migration-update.dto';
|
||||
import { MigrationQueryDto } from './dto/migration-query.dto';
|
||||
import { AiValidationService } from './ai-validation.service';
|
||||
import { CreateAiJobDto } from './dto/create-ai-job.dto';
|
||||
import {
|
||||
QUEUE_AI_BATCH,
|
||||
QUEUE_AI_REALTIME,
|
||||
} from '../common/constants/queue.constants';
|
||||
import { AiRealtimeJobData } from './processors/ai-realtime.processor';
|
||||
import { AiBatchJobData } from './processors/ai-batch.processor';
|
||||
import { AuditLog } from '../../common/entities/audit-log.entity';
|
||||
|
||||
// ผลลัพธ์ของ Real-time Extraction
|
||||
export interface ExtractionResult {
|
||||
@@ -45,6 +55,14 @@ export interface PaginatedResult<T> {
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
interface AnalyticsQueryResult {
|
||||
documentType: string | null;
|
||||
avgConfidence: string | number;
|
||||
total: string | number;
|
||||
overrides: string | number;
|
||||
rejections: string | number;
|
||||
}
|
||||
|
||||
// Context สำหรับส่งไปยัง n8n
|
||||
interface N8nWebhookPayload {
|
||||
migrationLogPublicId: string;
|
||||
@@ -65,6 +83,20 @@ interface N8nWebhookResponse {
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export interface AiQueueResult {
|
||||
success: boolean;
|
||||
jobId?: string;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export interface AiJobStatusResult {
|
||||
jobId: string;
|
||||
queue: 'ai-realtime' | 'ai-batch';
|
||||
status: string;
|
||||
result?: unknown;
|
||||
failedReason?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AiService {
|
||||
private readonly logger = new Logger(AiService.name);
|
||||
@@ -82,7 +114,15 @@ export class AiService {
|
||||
@InjectRepository(MigrationLog)
|
||||
private readonly migrationLogRepo: Repository<MigrationLog>,
|
||||
@InjectRepository(AiAuditLog)
|
||||
private readonly aiAuditLogRepo: Repository<AiAuditLog>
|
||||
private readonly aiAuditLogRepo: Repository<AiAuditLog>,
|
||||
@InjectRepository(AuditLog)
|
||||
private readonly auditLogRepo: Repository<AuditLog>,
|
||||
@Optional()
|
||||
@InjectQueue(QUEUE_AI_REALTIME)
|
||||
private readonly aiRealtimeQueue?: Queue<AiRealtimeJobData>,
|
||||
@Optional()
|
||||
@InjectQueue(QUEUE_AI_BATCH)
|
||||
private readonly aiBatchQueue?: Queue<AiBatchJobData>
|
||||
) {
|
||||
this.n8nWebhookUrl =
|
||||
this.configService.get<string>('AI_N8N_WEBHOOK_URL') ?? '';
|
||||
@@ -95,6 +135,87 @@ export class AiService {
|
||||
this.configService.get<string>('APP_BASE_URL') ?? 'http://localhost:3001';
|
||||
}
|
||||
|
||||
// --- ADR-023A BullMQ Job Queueing ---
|
||||
|
||||
/** ส่งงาน AI Suggest เข้า ai-realtime queue แบบไม่ block request thread */
|
||||
async queueSuggestJob(dto: CreateAiJobDto): Promise<AiQueueResult> {
|
||||
if (!this.aiRealtimeQueue) {
|
||||
const error = new Error('AI realtime queue is not registered');
|
||||
this.logger.error('AI job queue failed', {
|
||||
documentPublicId: dto.documentPublicId,
|
||||
error,
|
||||
});
|
||||
return { success: false, error };
|
||||
}
|
||||
|
||||
try {
|
||||
const job = await this.aiRealtimeQueue.add(
|
||||
'ai-suggest',
|
||||
{
|
||||
jobType: 'ai-suggest',
|
||||
documentPublicId: dto.documentPublicId,
|
||||
projectPublicId: dto.projectPublicId,
|
||||
payload: dto.payload ?? {},
|
||||
idempotencyKey: dto.idempotencyKey,
|
||||
},
|
||||
{ jobId: dto.idempotencyKey }
|
||||
);
|
||||
return { success: true, jobId: String(job.id) };
|
||||
} catch (err: unknown) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
this.logger.error('AI job queue failed', {
|
||||
documentPublicId: dto.documentPublicId,
|
||||
error,
|
||||
});
|
||||
return { success: false, error };
|
||||
}
|
||||
}
|
||||
|
||||
/** ส่งงาน embedding เข้า ai-batch queue แบบ best-effort */
|
||||
async queueEmbedJob(dto: CreateAiJobDto): Promise<AiQueueResult> {
|
||||
if (!this.aiBatchQueue) {
|
||||
const error = new Error('AI batch queue is not registered');
|
||||
this.logger.error('AI job queue failed', {
|
||||
documentPublicId: dto.documentPublicId,
|
||||
error,
|
||||
});
|
||||
return { success: false, error };
|
||||
}
|
||||
|
||||
try {
|
||||
const job = await this.aiBatchQueue.add(
|
||||
'embed-document',
|
||||
{
|
||||
jobType: 'embed-document',
|
||||
documentPublicId: dto.documentPublicId,
|
||||
projectPublicId: dto.projectPublicId,
|
||||
payload: dto.payload ?? {},
|
||||
idempotencyKey: dto.idempotencyKey,
|
||||
},
|
||||
{ jobId: dto.idempotencyKey }
|
||||
);
|
||||
return { success: true, jobId: String(job.id) };
|
||||
} catch (err: unknown) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
this.logger.error('AI job queue failed', {
|
||||
documentPublicId: dto.documentPublicId,
|
||||
error,
|
||||
});
|
||||
return { success: false, error };
|
||||
}
|
||||
}
|
||||
|
||||
/** อ่านสถานะ job จาก ai-realtime หรือ ai-batch เพื่อให้ frontend polling ได้ */
|
||||
async getAiJobStatus(jobId: string): Promise<AiJobStatusResult> {
|
||||
const realtimeJob = await this.aiRealtimeQueue?.getJob(jobId);
|
||||
if (realtimeJob) return this.toJobStatus(jobId, 'ai-realtime', realtimeJob);
|
||||
|
||||
const batchJob = await this.aiBatchQueue?.getJob(jobId);
|
||||
if (batchJob) return this.toJobStatus(jobId, 'ai-batch', batchJob);
|
||||
|
||||
return { jobId, queue: 'ai-realtime', status: 'not_found' };
|
||||
}
|
||||
|
||||
// --- Real-time Extraction (สำหรับ User Upload ใหม่) ---
|
||||
|
||||
async extractRealtime(
|
||||
@@ -438,4 +559,136 @@ export class AiService {
|
||||
this.logger.error(`Failed to save AI audit log: ${errMsg}`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Phase 6: AI Analytics Summary (T036) ---
|
||||
|
||||
/**
|
||||
* สรุปสถิติ AI Audit Logs แยกตาม document type และ status
|
||||
* @returns ข้อมูลสรุป avgConfidence, overrideRate, rejectedRate แยกตาม type
|
||||
*/
|
||||
async getAnalyticsSummary(): Promise<{
|
||||
byDocumentType: Array<{
|
||||
documentType: string;
|
||||
avgConfidence: number;
|
||||
overrideRate: number;
|
||||
rejectedRate: number;
|
||||
total: number;
|
||||
}>;
|
||||
overall: {
|
||||
avgConfidence: number;
|
||||
overrideRate: number;
|
||||
rejectedRate: number;
|
||||
total: number;
|
||||
};
|
||||
}> {
|
||||
// Query ai_audit_logs GROUP BY document type จาก ai_suggestion_json
|
||||
const qb = this.aiAuditLogRepo.createQueryBuilder('log');
|
||||
|
||||
// ดึง document type จาก JSON field
|
||||
const results = await qb
|
||||
.select([
|
||||
"JSON_UNQUOTE(JSON_EXTRACT(log.aiSuggestionJson, '$.documentType')) as documentType",
|
||||
'AVG(log.confidenceScore) as avgConfidence',
|
||||
'COUNT(*) as total',
|
||||
'SUM(CASE WHEN log.humanOverrideJson IS NOT NULL THEN 1 ELSE 0 END) as overrides',
|
||||
'SUM(CASE WHEN log.status = :rejectedStatus THEN 1 ELSE 0 END) as rejections',
|
||||
])
|
||||
.where('log.aiSuggestionJson IS NOT NULL')
|
||||
.andWhere('log.confidenceScore IS NOT NULL')
|
||||
.setParameter('rejectedStatus', AiAuditStatus.FAILED)
|
||||
.groupBy('documentType')
|
||||
.getRawMany<AnalyticsQueryResult>();
|
||||
|
||||
const byDocumentType = results.map((row) => ({
|
||||
documentType: row.documentType || 'UNKNOWN',
|
||||
avgConfidence: Number(row.avgConfidence) || 0,
|
||||
overrideRate:
|
||||
Number(row.total) > 0
|
||||
? (Number(row.overrides) / Number(row.total)) * 100
|
||||
: 0,
|
||||
rejectedRate:
|
||||
Number(row.total) > 0
|
||||
? (Number(row.rejections) / Number(row.total)) * 100
|
||||
: 0,
|
||||
total: Number(row.total),
|
||||
}));
|
||||
|
||||
// คำนวณ overall stats จาก raw results เพื่อความแม่นยำ
|
||||
const totalDocs = results.reduce((sum, row) => sum + Number(row.total), 0);
|
||||
const totalOverrides = results.reduce(
|
||||
(sum, row) => sum + Number(row.overrides),
|
||||
0
|
||||
);
|
||||
const totalRejections = results.reduce(
|
||||
(sum, row) => sum + Number(row.rejections),
|
||||
0
|
||||
);
|
||||
const totalConfidence = results.reduce(
|
||||
(sum, row) => sum + Number(row.avgConfidence) * Number(row.total),
|
||||
0
|
||||
);
|
||||
|
||||
return {
|
||||
byDocumentType,
|
||||
overall: {
|
||||
avgConfidence: totalDocs > 0 ? totalConfidence / totalDocs : 0,
|
||||
overrideRate: totalDocs > 0 ? (totalOverrides / totalDocs) * 100 : 0,
|
||||
rejectedRate: totalDocs > 0 ? (totalRejections / totalDocs) * 100 : 0,
|
||||
total: totalDocs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// --- Phase 6: Single Audit Log Delete (T037) ---
|
||||
|
||||
/**
|
||||
* ลบ AiAuditLog แบบ single record โดย publicId
|
||||
* @param publicId UUID ของ audit log ที่ต้องการลบ
|
||||
* @param userId ID ของผู้ทำการลบ (สำหรับ audit trail)
|
||||
*/
|
||||
async deleteAuditLogByPublicId(
|
||||
publicId: string,
|
||||
userId: number
|
||||
): Promise<{ deleted: boolean; publicId: string }> {
|
||||
const auditLog = await this.aiAuditLogRepo.findOne({
|
||||
where: { publicId },
|
||||
});
|
||||
|
||||
if (!auditLog) {
|
||||
throw new NotFoundException('AiAuditLog', publicId);
|
||||
}
|
||||
|
||||
await this.aiAuditLogRepo.remove(auditLog);
|
||||
|
||||
// บันทึกใน audit_logs table (T037 requirement)
|
||||
const auditEntry = this.auditLogRepo.create({
|
||||
userId,
|
||||
action: 'AI_AUDIT_LOG_DELETED',
|
||||
entityType: 'AiAuditLog',
|
||||
entityId: publicId,
|
||||
severity: 'INFO',
|
||||
detailsJson: { deletedAuditLogPublicId: publicId },
|
||||
});
|
||||
await this.auditLogRepo.save(auditEntry);
|
||||
|
||||
this.logger.log(
|
||||
`AI audit log deleted — publicId=${publicId}, deletedBy=${userId}`
|
||||
);
|
||||
|
||||
return { deleted: true, publicId };
|
||||
}
|
||||
|
||||
private async toJobStatus(
|
||||
jobId: string,
|
||||
queue: 'ai-realtime' | 'ai-batch',
|
||||
job: Job<AiRealtimeJobData | AiBatchJobData>
|
||||
): Promise<AiJobStatusResult> {
|
||||
return {
|
||||
jobId,
|
||||
queue,
|
||||
status: await job.getState(),
|
||||
result: job.returnvalue,
|
||||
failedReason: job.failedReason,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// File: src/modules/ai/dto/create-ai-job.dto.ts
|
||||
// Change Log
|
||||
// - 2026-05-15: เพิ่ม DTO สำหรับ enqueue AI jobs ตาม ADR-023A US1.
|
||||
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
} from 'class-validator';
|
||||
|
||||
export const AI_JOB_TYPES = [
|
||||
'ai-suggest',
|
||||
'rag-query',
|
||||
'ocr',
|
||||
'extract-metadata',
|
||||
'embed-document',
|
||||
] as const;
|
||||
|
||||
export type CreateAiJobType = (typeof AI_JOB_TYPES)[number];
|
||||
|
||||
/** DTO สำหรับส่งงาน AI เข้า BullMQ โดยใช้ publicId เท่านั้นตาม ADR-019 */
|
||||
export class CreateAiJobDto {
|
||||
@ApiProperty({ description: 'Attachment/document publicId สำหรับงาน AI' })
|
||||
@IsUUID()
|
||||
documentPublicId!: string;
|
||||
|
||||
@ApiProperty({ description: 'Project publicId สำหรับ project isolation' })
|
||||
@IsUUID()
|
||||
projectPublicId!: string;
|
||||
|
||||
@ApiProperty({
|
||||
enum: AI_JOB_TYPES,
|
||||
description: 'ชนิดงาน AI ที่ต้อง enqueue',
|
||||
})
|
||||
@IsIn(AI_JOB_TYPES)
|
||||
jobType!: CreateAiJobType;
|
||||
|
||||
@ApiProperty({ description: 'Idempotency key จาก request header/body' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
idempotencyKey!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Payload เพิ่มเติม เช่น pdfPath, extractedText, question',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
payload?: Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// File: backend/src/modules/ai/dto/migration-queue-item.dto.ts
|
||||
// บันทึกการแก้ไข: สร้าง DTO สำหรับ Legacy Migration (T029) ตาม ADR-023A
|
||||
|
||||
import { IsString, IsNotEmpty, IsUUID, IsOptional } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class MigrationQueueItemDto {
|
||||
@ApiProperty({
|
||||
description: 'n8n batch identifier',
|
||||
example: 'batch-2026-05-15',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
batchId!: string;
|
||||
|
||||
@ApiProperty({ description: 'ชื่อไฟล์ต้นฉบับ', example: 'INV-2026-001.pdf' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
filename!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'เส้นทางไฟล์ชั่วคราวใน storage',
|
||||
example: 'temp/migration/batch-1/INV-001.pdf',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
tempPath!: string;
|
||||
|
||||
@ApiProperty({ description: 'UUID ของโครงการ (ถ้าทราบ)', required: false })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
projectPublicId?: string;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// File: src/modules/ai/entities/migration-review-queue.entity.ts
|
||||
// Change Log
|
||||
// - 2026-05-15: เพิ่ม re-export สำหรับชื่อ entity ตาม ADR-023A tasks.md โดยไม่สร้าง metadata ซ้ำ.
|
||||
|
||||
export {
|
||||
MigrationReviewRecord as MigrationReviewQueueEntity,
|
||||
MigrationReviewRecord,
|
||||
MigrationReviewRecordStatus,
|
||||
} from './migration-review.entity';
|
||||
@@ -1,6 +1,7 @@
|
||||
// File: src/modules/ai/entities/migration-review.entity.ts
|
||||
// Change Log
|
||||
// - 2026-05-14: เพิ่ม entity staging queue สำหรับ Unified AI Architecture.
|
||||
// - 2026-05-15: เพิ่ม column สำหรับ ADR-023A migration_review_queue schema.
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
@@ -28,9 +29,34 @@ export class MigrationReviewRecord extends UuidBaseEntity {
|
||||
@Column({ name: 'batch_id', type: 'varchar', length: 100 })
|
||||
batchId!: string;
|
||||
|
||||
@Index('uq_migration_review_idempotency', { unique: true })
|
||||
@Column({
|
||||
name: 'idempotency_key',
|
||||
type: 'varchar',
|
||||
length: 200,
|
||||
nullable: true,
|
||||
})
|
||||
idempotencyKey?: string;
|
||||
|
||||
@Column({ name: 'original_file_name', type: 'varchar', length: 255 })
|
||||
originalFileName!: string;
|
||||
|
||||
@Column({
|
||||
name: 'original_filename',
|
||||
type: 'varchar',
|
||||
length: 500,
|
||||
nullable: true,
|
||||
})
|
||||
originalFilename?: string;
|
||||
|
||||
@Column({
|
||||
name: 'storage_temp_path',
|
||||
type: 'varchar',
|
||||
length: 1000,
|
||||
nullable: true,
|
||||
})
|
||||
storageTempPath?: string;
|
||||
|
||||
@Column({ name: 'source_attachment_public_id', type: 'uuid', nullable: true })
|
||||
sourceAttachmentPublicId?: string;
|
||||
|
||||
@@ -40,6 +66,9 @@ export class MigrationReviewRecord extends UuidBaseEntity {
|
||||
@Column({ name: 'extracted_metadata', type: 'json', nullable: true })
|
||||
extractedMetadata?: Record<string, unknown>;
|
||||
|
||||
@Column({ name: 'ai_metadata_json', type: 'json', nullable: true })
|
||||
aiMetadataJson?: Record<string, unknown>;
|
||||
|
||||
@Column({
|
||||
name: 'confidence_score',
|
||||
type: 'decimal',
|
||||
@@ -49,6 +78,9 @@ export class MigrationReviewRecord extends UuidBaseEntity {
|
||||
})
|
||||
confidenceScore?: number;
|
||||
|
||||
@Column({ name: 'ocr_used', type: 'boolean', default: false })
|
||||
ocrUsed!: boolean;
|
||||
|
||||
@Index('idx_migration_review_status')
|
||||
@Column({
|
||||
type: 'enum',
|
||||
@@ -60,6 +92,20 @@ export class MigrationReviewRecord extends UuidBaseEntity {
|
||||
@Column({ name: 'error_reason', type: 'text', nullable: true })
|
||||
errorReason?: string;
|
||||
|
||||
@Column({ name: 'reviewed_by', type: 'int', nullable: true })
|
||||
reviewedBy?: number;
|
||||
|
||||
@Column({ name: 'reviewed_at', type: 'datetime', nullable: true })
|
||||
reviewedAt?: Date;
|
||||
|
||||
@Column({
|
||||
name: 'rejection_reason',
|
||||
type: 'varchar',
|
||||
length: 500,
|
||||
nullable: true,
|
||||
})
|
||||
rejectionReason?: string;
|
||||
|
||||
@VersionColumn({ name: 'version' })
|
||||
version!: number;
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// File: src/modules/ai/processors/ai-batch.processor.ts
|
||||
// Change Log
|
||||
// - 2026-05-15: เพิ่ม processor สำหรับ ai-batch queue ตาม ADR-023A.
|
||||
// - 2026-05-15: เพิ่ม EmbeddingService สำหรับ embed-document logic (T022).
|
||||
|
||||
import { Processor, WorkerHost } from '@nestjs/bullmq';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Job } from 'bullmq';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Attachment } from '../../../common/file-storage/entities/attachment.entity';
|
||||
import { QUEUE_AI_BATCH } from '../../common/constants/queue.constants';
|
||||
import { EmbeddingService } from '../services/embedding.service';
|
||||
|
||||
export type AiBatchJobType = 'ocr' | 'extract-metadata' | 'embed-document';
|
||||
|
||||
export interface AiBatchJobData {
|
||||
jobType: AiBatchJobType;
|
||||
documentPublicId: string;
|
||||
projectPublicId: string;
|
||||
payload: Record<string, unknown>;
|
||||
batchId?: string;
|
||||
idempotencyKey: string;
|
||||
}
|
||||
|
||||
/** Processor สำหรับงาน AI batch ที่รันทีละงานเพื่อคุม VRAM */
|
||||
@Processor(QUEUE_AI_BATCH, { concurrency: 1 })
|
||||
export class AiBatchProcessor extends WorkerHost {
|
||||
private readonly logger = new Logger(AiBatchProcessor.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Attachment)
|
||||
private readonly attachmentRepo: Repository<Attachment>,
|
||||
private readonly embeddingService: EmbeddingService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
/** Dispatch งาน batch ตาม jobType */
|
||||
async process(job: Job<AiBatchJobData>): Promise<void> {
|
||||
await this.setAiProcessingStatus(job.data.documentPublicId, 'PROCESSING');
|
||||
try {
|
||||
switch (job.data.jobType) {
|
||||
case 'ocr':
|
||||
this.logger.log(`OCR batch job processing — jobId=${String(job.id)}`);
|
||||
// OCR logic handled by OcrService in ai-realtime processor
|
||||
await this.setAiProcessingStatus(job.data.documentPublicId, 'DONE');
|
||||
return;
|
||||
case 'extract-metadata':
|
||||
this.logger.log(
|
||||
`Metadata extraction job processing — jobId=${String(job.id)}`
|
||||
);
|
||||
// Metadata extraction handled in ai-realtime processor
|
||||
await this.setAiProcessingStatus(job.data.documentPublicId, 'DONE');
|
||||
return;
|
||||
case 'embed-document':
|
||||
this.logger.log(`Embedding job processing — jobId=${String(job.id)}`);
|
||||
await this.processEmbedDocument(job.data);
|
||||
await this.setAiProcessingStatus(job.data.documentPublicId, 'DONE');
|
||||
return;
|
||||
default: {
|
||||
const unreachable: never = job.data.jobType;
|
||||
throw new Error(
|
||||
`Unsupported ai-batch jobType: ${String(unreachable)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Batch job failed — jobType=${job.data.jobType}, documentPublicId=${job.data.documentPublicId}`,
|
||||
err instanceof Error ? err.stack : String(err)
|
||||
);
|
||||
await this.setAiProcessingStatus(job.data.documentPublicId, 'FAILED');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** ประมวลผล embed-document job ด้วย EmbeddingService (T022) */
|
||||
private async processEmbedDocument(data: AiBatchJobData): Promise<void> {
|
||||
const { documentPublicId, projectPublicId, payload } = data;
|
||||
const pdfPath = payload.pdfPath as string;
|
||||
const extractedText = payload.extractedText as string | undefined;
|
||||
|
||||
if (!pdfPath) {
|
||||
throw new Error('pdfPath is required for embed-document job');
|
||||
}
|
||||
|
||||
const result = await this.embeddingService.embedDocument(
|
||||
pdfPath,
|
||||
documentPublicId,
|
||||
projectPublicId,
|
||||
extractedText
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(`Embedding failed: ${result.error ?? 'Unknown error'}`);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Embedding completed for document ${documentPublicId} — ${result.chunksEmbedded} chunks embedded`
|
||||
);
|
||||
}
|
||||
|
||||
private async setAiProcessingStatus(
|
||||
documentPublicId: string,
|
||||
status: 'PENDING' | 'PROCESSING' | 'DONE' | 'FAILED'
|
||||
): Promise<void> {
|
||||
await this.attachmentRepo.update(
|
||||
{ publicId: documentPublicId },
|
||||
{ aiProcessingStatus: status }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
// File: src/modules/ai/processors/ai-realtime.processor.ts
|
||||
// Change Log
|
||||
// - 2026-05-15: เพิ่ม processor สำหรับ ai-realtime queue และ pause/resume ai-batch ตาม ADR-023A.
|
||||
|
||||
import {
|
||||
Processor,
|
||||
WorkerHost,
|
||||
OnWorkerEvent,
|
||||
InjectQueue,
|
||||
} from '@nestjs/bullmq';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Job, Queue } from 'bullmq';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import {
|
||||
QUEUE_AI_BATCH,
|
||||
QUEUE_AI_REALTIME,
|
||||
} from '../../common/constants/queue.constants';
|
||||
import { AiAuditLog, AiAuditStatus } from '../entities/ai-audit-log.entity';
|
||||
import { Attachment } from '../../../common/file-storage/entities/attachment.entity';
|
||||
import { OcrService } from '../services/ocr.service';
|
||||
import { OllamaService } from '../services/ollama.service';
|
||||
|
||||
export type AiRealtimeJobType = 'ai-suggest' | 'rag-query';
|
||||
|
||||
export interface AiRealtimeJobData {
|
||||
jobType: AiRealtimeJobType;
|
||||
documentPublicId?: string;
|
||||
projectPublicId: string;
|
||||
userId?: number;
|
||||
payload: Record<string, unknown>;
|
||||
idempotencyKey: string;
|
||||
}
|
||||
|
||||
/** Processor สำหรับงาน AI interactive ที่ต้องกัน batch job ระหว่างใช้ GPU */
|
||||
@Processor(QUEUE_AI_REALTIME, { concurrency: 1 })
|
||||
export class AiRealtimeProcessor extends WorkerHost {
|
||||
private readonly logger = new Logger(AiRealtimeProcessor.name);
|
||||
|
||||
constructor(
|
||||
@InjectQueue(QUEUE_AI_BATCH)
|
||||
private readonly aiBatchQueue: Queue,
|
||||
private readonly ocrService: OcrService,
|
||||
private readonly ollamaService: OllamaService,
|
||||
@InjectRepository(AiAuditLog)
|
||||
private readonly aiAuditLogRepo: Repository<AiAuditLog>,
|
||||
@InjectRepository(Attachment)
|
||||
private readonly attachmentRepo: Repository<Attachment>
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
/** Dispatch งาน ai-realtime ตาม jobType */
|
||||
async process(job: Job<AiRealtimeJobData>): Promise<unknown> {
|
||||
switch (job.data.jobType) {
|
||||
case 'ai-suggest':
|
||||
return this.processSuggest(job);
|
||||
case 'rag-query':
|
||||
this.logger.log(`RAG query queued — jobId=${String(job.id)}`);
|
||||
return;
|
||||
default: {
|
||||
const unreachable: never = job.data.jobType;
|
||||
throw new Error(
|
||||
`Unsupported ai-realtime jobType: ${String(unreachable)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async processSuggest(
|
||||
job: Job<AiRealtimeJobData>
|
||||
): Promise<Record<string, unknown>> {
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
if (job.data.documentPublicId) {
|
||||
await this.setAiProcessingStatus(
|
||||
job.data.documentPublicId,
|
||||
'PROCESSING'
|
||||
);
|
||||
}
|
||||
const extractedText =
|
||||
typeof job.data.payload['extractedText'] === 'string'
|
||||
? job.data.payload['extractedText']
|
||||
: '';
|
||||
const pdfPath =
|
||||
typeof job.data.payload['pdfPath'] === 'string'
|
||||
? job.data.payload['pdfPath']
|
||||
: undefined;
|
||||
const extractedChars =
|
||||
typeof job.data.payload['extractedChars'] === 'number'
|
||||
? job.data.payload['extractedChars']
|
||||
: extractedText.length;
|
||||
|
||||
const textResult = await this.ocrService.detectAndExtract({
|
||||
extractedText,
|
||||
extractedChars,
|
||||
pdfPath,
|
||||
});
|
||||
|
||||
const prompt = [
|
||||
'Extract concise DMS metadata from this engineering document.',
|
||||
'Return only JSON with fields: title, documentType, category, confidenceScore.',
|
||||
textResult.text.slice(0, 6000),
|
||||
].join('\n');
|
||||
|
||||
const rawOutput = await this.ollamaService.generate(prompt);
|
||||
const suggestion = this.parseSuggestion(rawOutput);
|
||||
const normalizedSuggestion = this.flagUnknownCategories(
|
||||
suggestion,
|
||||
job.data.payload['masterDataCategories']
|
||||
);
|
||||
|
||||
await this.aiAuditLogRepo.save(
|
||||
this.aiAuditLogRepo.create({
|
||||
documentPublicId: job.data.documentPublicId,
|
||||
aiModel: 'gemma4',
|
||||
modelName: this.ollamaService.getMainModelName(),
|
||||
aiSuggestionJson: normalizedSuggestion,
|
||||
confidenceScore: this.extractConfidence(normalizedSuggestion),
|
||||
processingTimeMs: Date.now() - startTime,
|
||||
status: AiAuditStatus.SUCCESS,
|
||||
})
|
||||
);
|
||||
if (job.data.documentPublicId) {
|
||||
await this.setAiProcessingStatus(job.data.documentPublicId, 'DONE');
|
||||
}
|
||||
return {
|
||||
suggestion: normalizedSuggestion,
|
||||
ocrUsed: textResult.ocrUsed,
|
||||
};
|
||||
} catch (err) {
|
||||
if (job.data.documentPublicId) {
|
||||
await this.setAiProcessingStatus(job.data.documentPublicId, 'FAILED');
|
||||
}
|
||||
await this.aiAuditLogRepo.save(
|
||||
this.aiAuditLogRepo.create({
|
||||
documentPublicId: job.data.documentPublicId,
|
||||
aiModel: 'gemma4',
|
||||
modelName: this.ollamaService.getMainModelName(),
|
||||
processingTimeMs: Date.now() - startTime,
|
||||
status: AiAuditStatus.FAILED,
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private parseSuggestion(rawOutput: string): Record<string, unknown> {
|
||||
try {
|
||||
const parsed = JSON.parse(rawOutput) as unknown;
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
this.logger.warn('AI suggestion output was not valid JSON');
|
||||
}
|
||||
return {
|
||||
title: rawOutput.slice(0, 250),
|
||||
confidenceScore: 0,
|
||||
is_unknown: true,
|
||||
};
|
||||
}
|
||||
|
||||
private flagUnknownCategories(
|
||||
suggestion: Record<string, unknown>,
|
||||
masterDataCategories: unknown
|
||||
): Record<string, unknown> {
|
||||
if (!Array.isArray(masterDataCategories)) return suggestion;
|
||||
const knownValues = new Set(
|
||||
masterDataCategories
|
||||
.filter((value): value is string => typeof value === 'string')
|
||||
.map((value) => value.toLowerCase())
|
||||
);
|
||||
const category = suggestion['category'];
|
||||
if (
|
||||
typeof category === 'string' &&
|
||||
!knownValues.has(category.toLowerCase())
|
||||
) {
|
||||
return { ...suggestion, is_unknown: true };
|
||||
}
|
||||
return suggestion;
|
||||
}
|
||||
|
||||
private extractConfidence(
|
||||
suggestion: Record<string, unknown>
|
||||
): number | undefined {
|
||||
const confidence = suggestion['confidenceScore'];
|
||||
return typeof confidence === 'number' ? confidence : undefined;
|
||||
}
|
||||
|
||||
private async setAiProcessingStatus(
|
||||
documentPublicId: string,
|
||||
status: 'PENDING' | 'PROCESSING' | 'DONE' | 'FAILED'
|
||||
): Promise<void> {
|
||||
await this.attachmentRepo.update(
|
||||
{ publicId: documentPublicId },
|
||||
{ aiProcessingStatus: status }
|
||||
);
|
||||
}
|
||||
|
||||
/** เมื่อ interactive job เริ่ม ให้ pause batch queue เพื่อกัน GPU contention */
|
||||
@OnWorkerEvent('active')
|
||||
async onActive(job: Job<AiRealtimeJobData>): Promise<void> {
|
||||
await this.aiBatchQueue.pause();
|
||||
this.logger.warn(
|
||||
`ai-batch paused while ai-realtime job is active — jobId=${String(job.id)}`
|
||||
);
|
||||
}
|
||||
|
||||
/** เมื่อ interactive job เสร็จ ให้ resume batch queue */
|
||||
@OnWorkerEvent('completed')
|
||||
async onCompleted(job: Job<AiRealtimeJobData>): Promise<void> {
|
||||
await this.aiBatchQueue.resume();
|
||||
this.logger.log(
|
||||
`ai-batch resumed after ai-realtime completion — jobId=${String(job.id)}`
|
||||
);
|
||||
}
|
||||
|
||||
/** เมื่อ interactive job fail ให้ resume batch queue เช่นกัน */
|
||||
@OnWorkerEvent('failed')
|
||||
async onFailed(job: Job<AiRealtimeJobData> | undefined): Promise<void> {
|
||||
await this.aiBatchQueue.resume();
|
||||
this.logger.warn(
|
||||
`ai-batch resumed after ai-realtime failure — jobId=${String(job?.id ?? 'unknown')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -66,11 +66,11 @@ export class AiQdrantService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
/** ค้นหา vector โดยบังคับ projectPublicId เพื่อป้องกันข้อมูลข้ามโครงการ */
|
||||
async searchByProject(
|
||||
vector: number[],
|
||||
/** ค้นหา vector โดยบังคับ projectPublicId เป็น parameter แรกตาม ADR-023A */
|
||||
async search(
|
||||
projectPublicId: string,
|
||||
limit: number
|
||||
vector: number[],
|
||||
topK = 5
|
||||
): Promise<AiVectorSearchResult[]> {
|
||||
if (!projectPublicId) {
|
||||
throw new ServiceUnavailableException('AI_QDRANT_PROJECT_SCOPE_REQUIRED');
|
||||
@@ -78,7 +78,7 @@ export class AiQdrantService implements OnModuleInit {
|
||||
|
||||
const results = await this.client.search(AI_COLLECTION_NAME, {
|
||||
vector,
|
||||
limit,
|
||||
limit: topK,
|
||||
filter: {
|
||||
must: [{ key: 'project_public_id', match: { value: projectPublicId } }],
|
||||
},
|
||||
@@ -92,6 +92,15 @@ export class AiQdrantService implements OnModuleInit {
|
||||
}));
|
||||
}
|
||||
|
||||
/** Compatibility wrapper สำหรับ code เดิมระหว่าง transition ไป contract ใหม่ */
|
||||
async searchByProject(
|
||||
vector: number[],
|
||||
projectPublicId: string,
|
||||
limit: number
|
||||
): Promise<AiVectorSearchResult[]> {
|
||||
return this.search(projectPublicId, vector, limit);
|
||||
}
|
||||
|
||||
/** ลบ vector ของเอกสารด้วย publicId ผ่าน queue processor ในขั้นถัดไป */
|
||||
async deleteByDocumentPublicId(documentPublicId: string): Promise<void> {
|
||||
await this.client.delete(AI_COLLECTION_NAME, {
|
||||
@@ -101,4 +110,32 @@ export class AiQdrantService implements OnModuleInit {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Upsert vectors ไป Qdrant พร้อม project isolation (T021) */
|
||||
async upsert(
|
||||
projectPublicId: string,
|
||||
points: Array<{
|
||||
id: string;
|
||||
vector: number[];
|
||||
payload: Record<string, unknown>;
|
||||
}>
|
||||
): Promise<void> {
|
||||
if (!projectPublicId) {
|
||||
throw new ServiceUnavailableException('AI_QDRANT_PROJECT_SCOPE_REQUIRED');
|
||||
}
|
||||
|
||||
// เพิ่ม project_public_id ใน payload ทุก point เพื่อ isolation
|
||||
const pointsWithProject = points.map((point) => ({
|
||||
...point,
|
||||
payload: {
|
||||
...point.payload,
|
||||
project_public_id: projectPublicId,
|
||||
},
|
||||
}));
|
||||
|
||||
await this.client.upsert(AI_COLLECTION_NAME, {
|
||||
wait: true,
|
||||
points: pointsWithProject,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
// File: src/modules/ai/services/embedding.service.ts
|
||||
// Change Log
|
||||
// - 2026-05-15: เพิ่ม EmbeddingService สำหรับ full-document chunked embedding ตาม ADR-023A T021.
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { OllamaService } from './ollama.service';
|
||||
import { AiQdrantService } from '../qdrant.service';
|
||||
import { OcrService } from './ocr.service';
|
||||
|
||||
export interface EmbeddingChunk {
|
||||
chunkIndex: number;
|
||||
text: string;
|
||||
pageNumber?: number;
|
||||
}
|
||||
|
||||
export interface EmbeddingResult {
|
||||
success: boolean;
|
||||
chunksEmbedded: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** บริการสร้าง embedding สำหรับ full-document RAG (ADR-023A) */
|
||||
@Injectable()
|
||||
export class EmbeddingService {
|
||||
private readonly logger = new Logger(EmbeddingService.name);
|
||||
private readonly chunkSize: number;
|
||||
private readonly overlap: number;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly ollamaService: OllamaService,
|
||||
private readonly qdrantService: AiQdrantService,
|
||||
private readonly ocrService: OcrService
|
||||
) {
|
||||
this.chunkSize = this.configService.get<number>(
|
||||
'EMBEDDING_CHUNK_SIZE',
|
||||
512
|
||||
);
|
||||
this.overlap = this.configService.get<number>(
|
||||
'EMBEDDING_CHUNK_OVERLAP',
|
||||
64
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* สร้าง embedding สำหรับเอกสารทั้งฉบับ:
|
||||
* 1. ดึงข้อความ full-doc (ใช้ extractedText หรือ OCR)
|
||||
* 2. Chunk text 512 tokens / 64 overlap
|
||||
* 3. Generate embedding ต่อ chunk ด้วย nomic-embed-text
|
||||
* 4. Upsert ไป Qdrant พร้อม project isolation
|
||||
*/
|
||||
async embedDocument(
|
||||
pdfPath: string,
|
||||
documentPublicId: string,
|
||||
projectPublicId: string,
|
||||
extractedText?: string
|
||||
): Promise<EmbeddingResult> {
|
||||
try {
|
||||
// 1. ดึงข้อความจาก PDF (ใช้ extractedText ถ้ามี หรือเรียก OCR)
|
||||
let fullText = extractedText;
|
||||
if (!fullText) {
|
||||
const ocrResult = await this.ocrService.detectAndExtract({
|
||||
pdfPath,
|
||||
extractedText: '',
|
||||
extractedChars: 0,
|
||||
});
|
||||
fullText = ocrResult.text;
|
||||
}
|
||||
|
||||
if (!fullText || fullText.trim().length === 0) {
|
||||
this.logger.warn(`No text extracted from document ${documentPublicId}`);
|
||||
return {
|
||||
success: false,
|
||||
chunksEmbedded: 0,
|
||||
error: 'No text extracted',
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Chunk text
|
||||
const chunks = this.chunkText(fullText);
|
||||
this.logger.log(
|
||||
`Document ${documentPublicId} split into ${chunks.length} chunks`
|
||||
);
|
||||
|
||||
// 3. Generate embedding และ upsert ไป Qdrant
|
||||
const points = [];
|
||||
for (const chunk of chunks) {
|
||||
try {
|
||||
const embedding = await this.ollamaService.generateEmbedding(
|
||||
chunk.text
|
||||
);
|
||||
points.push({
|
||||
id: `${documentPublicId}-${chunk.chunkIndex}`,
|
||||
vector: embedding,
|
||||
payload: {
|
||||
document_public_id: documentPublicId,
|
||||
chunk_index: chunk.chunkIndex,
|
||||
page_number: chunk.pageNumber,
|
||||
chunk_text: chunk.text,
|
||||
embedded_at: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to embed chunk ${chunk.chunkIndex} for document ${documentPublicId}`,
|
||||
err instanceof Error ? err.message : String(err)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (points.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
chunksEmbedded: 0,
|
||||
error: 'All chunks failed to embed',
|
||||
};
|
||||
}
|
||||
|
||||
// 4. Upsert ไป Qdrant พร้อม project isolation
|
||||
await this.qdrantService.upsert(projectPublicId, points);
|
||||
|
||||
this.logger.log(
|
||||
`Successfully embedded ${points.length} chunks for document ${documentPublicId} in project ${projectPublicId}`
|
||||
);
|
||||
|
||||
return { success: true, chunksEmbedded: points.length };
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(
|
||||
`Embedding failed for document ${documentPublicId}: ${errorMsg}`
|
||||
);
|
||||
return { success: false, chunksEmbedded: 0, error: errorMsg };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Chunk text ด้วย overlap
|
||||
* - chunkSize: 512 characters (approximate token equivalent)
|
||||
* - overlap: 64 characters
|
||||
*/
|
||||
private chunkText(text: string): EmbeddingChunk[] {
|
||||
const chunks: EmbeddingChunk[] = [];
|
||||
const cleanText = text.replace(/\s+/g, ' ').trim();
|
||||
const textLength = cleanText.length;
|
||||
|
||||
let startIndex = 0;
|
||||
let chunkIndex = 0;
|
||||
|
||||
while (startIndex < textLength) {
|
||||
const endIndex = Math.min(startIndex + this.chunkSize, textLength);
|
||||
const chunkText = cleanText.substring(startIndex, endIndex);
|
||||
|
||||
chunks.push({
|
||||
chunkIndex,
|
||||
text: chunkText,
|
||||
pageNumber: undefined, // TODO: Extract page numbers if available
|
||||
});
|
||||
|
||||
startIndex += this.chunkSize - this.overlap;
|
||||
chunkIndex += 1;
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// File: backend/src/modules/ai/services/migration.service.ts
|
||||
// บันทึกการแก้ไข: สร้าง MigrationService สำหรับ Legacy Migration (T030) ตาม ADR-023A
|
||||
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
BadRequestException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Queue } from 'bullmq';
|
||||
import {
|
||||
MigrationReviewRecord,
|
||||
MigrationReviewRecordStatus,
|
||||
} from '../entities/migration-review.entity';
|
||||
import { MigrationQueueItemDto } from '../dto/migration-queue-item.dto';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
|
||||
@Injectable()
|
||||
export class MigrationService {
|
||||
private readonly logger = new Logger(MigrationService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(MigrationReviewRecord)
|
||||
private readonly migrationRepo: Repository<MigrationReviewRecord>,
|
||||
@InjectQueue('ai-batch')
|
||||
private readonly aiBatchQueue: Queue,
|
||||
private readonly dataSource: DataSource
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Queue a legacy document for human review and AI extraction
|
||||
*/
|
||||
async queueForReview(dto: MigrationQueueItemDto, idempotencyKey: string) {
|
||||
this.logger.log(
|
||||
`📥 Queuing legacy document for review: ${dto.filename} (Batch: ${dto.batchId})`
|
||||
);
|
||||
|
||||
// 1. Check idempotency
|
||||
const existing = await this.migrationRepo.findOne({
|
||||
where: { idempotencyKey },
|
||||
});
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
// 2. Create pending record
|
||||
const record = this.migrationRepo.create({
|
||||
batchId: dto.batchId,
|
||||
idempotencyKey: idempotencyKey,
|
||||
originalFilename: dto.filename,
|
||||
storageTempPath: dto.tempPath,
|
||||
status: MigrationReviewRecordStatus.PENDING,
|
||||
aiMetadataJson: {}, // Will be updated by AI processor
|
||||
confidenceScore: 0,
|
||||
});
|
||||
|
||||
const saved = await this.migrationRepo.save(record);
|
||||
|
||||
// 3. Queue AI processing (OCR + Metadata Extraction)
|
||||
await this.aiBatchQueue.add('extract-metadata', {
|
||||
migrationQueuePublicId: saved.publicId,
|
||||
tempPath: dto.tempPath,
|
||||
filename: dto.filename,
|
||||
projectPublicId: dto.projectPublicId,
|
||||
});
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all migration queue items with pagination
|
||||
*/
|
||||
async findAll(page = 1, limit = 20, status?: string) {
|
||||
const query = this.migrationRepo
|
||||
.createQueryBuilder('q')
|
||||
.orderBy('q.createdAt', 'DESC')
|
||||
.skip((page - 1) * limit)
|
||||
.take(limit);
|
||||
|
||||
if (status) {
|
||||
query.andWhere('q.status = :status', { status });
|
||||
}
|
||||
|
||||
const [items, total] = await query.getManyAndCount();
|
||||
return { items, total, page, limit };
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve a migration item and import it as a real document
|
||||
*/
|
||||
async approve(publicId: string, user: User) {
|
||||
const item = await this.migrationRepo.findOne({ where: { publicId } });
|
||||
if (!item) throw new NotFoundException('Migration item not found');
|
||||
if (item.status !== MigrationReviewRecordStatus.PENDING)
|
||||
throw new BadRequestException(
|
||||
`Cannot approve item in status ${item.status}`
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`✅ Approving migration item: ${item.originalFilename} (uuid: ${publicId})`
|
||||
);
|
||||
|
||||
// TODO: Implement actual document import logic here in US3 Phase 5
|
||||
// This will involve calling FileStorageService, CorrespondenceService, etc.
|
||||
|
||||
item.status = MigrationReviewRecordStatus.IMPORTED;
|
||||
item.reviewedBy = user.user_id;
|
||||
item.reviewedAt = new Date();
|
||||
|
||||
return this.migrationRepo.save(item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a migration item
|
||||
*/
|
||||
async reject(publicId: string, user: User, reason: string) {
|
||||
const item = await this.migrationRepo.findOne({ where: { publicId } });
|
||||
if (!item) throw new NotFoundException('Migration item not found');
|
||||
|
||||
item.status = MigrationReviewRecordStatus.REJECTED;
|
||||
item.reviewedBy = user.user_id;
|
||||
item.reviewedAt = new Date();
|
||||
item.rejectionReason = reason;
|
||||
|
||||
return this.migrationRepo.save(item);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// File: src/modules/ai/services/ocr.service.ts
|
||||
// Change Log
|
||||
// - 2026-05-15: เพิ่ม OCR auto-detection service สำหรับ ADR-023A.
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios from 'axios';
|
||||
|
||||
export interface OcrDetectionInput {
|
||||
extractedText?: string;
|
||||
extractedChars?: number;
|
||||
pdfPath?: string;
|
||||
}
|
||||
|
||||
export interface OcrDetectionResult {
|
||||
text: string;
|
||||
ocrUsed: boolean;
|
||||
}
|
||||
|
||||
interface PaddleOcrResponse {
|
||||
text?: string;
|
||||
}
|
||||
|
||||
/** บริการเลือก fast path หรือ PaddleOCR sidecar ตามจำนวนตัวอักษรที่ extract ได้ */
|
||||
@Injectable()
|
||||
export class OcrService {
|
||||
private readonly logger = new Logger(OcrService.name);
|
||||
private readonly threshold: number;
|
||||
private readonly ocrApiUrl: string;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.threshold = this.configService.get<number>('OCR_CHAR_THRESHOLD', 100);
|
||||
this.ocrApiUrl = this.configService.get<string>(
|
||||
'OCR_API_URL',
|
||||
'http://localhost:8765'
|
||||
);
|
||||
}
|
||||
|
||||
/** ตรวจสอบ text layer ก่อนเลือก OCR slow path */
|
||||
async detectAndExtract(
|
||||
input: OcrDetectionInput
|
||||
): Promise<OcrDetectionResult> {
|
||||
const extractedText = input.extractedText ?? '';
|
||||
const extractedChars = input.extractedChars ?? extractedText.length;
|
||||
|
||||
if (extractedChars > this.threshold) {
|
||||
return { text: extractedText, ocrUsed: false };
|
||||
}
|
||||
|
||||
if (!input.pdfPath) {
|
||||
this.logger.warn('OCR slow path skipped because pdfPath is missing');
|
||||
return { text: extractedText, ocrUsed: false };
|
||||
}
|
||||
|
||||
const response = await axios.post<PaddleOcrResponse>(
|
||||
`${this.ocrApiUrl}/ocr`,
|
||||
{ pdfPath: input.pdfPath },
|
||||
{ timeout: 90000 }
|
||||
);
|
||||
|
||||
return {
|
||||
text: response.data.text ?? '',
|
||||
ocrUsed: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// File: src/modules/ai/services/ollama.service.ts
|
||||
// Change Log
|
||||
// - 2026-05-15: เพิ่ม Ollama service สำหรับ ADR-023A 2-model stack.
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios from 'axios';
|
||||
|
||||
export interface OllamaGenerateOptions {
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/** บริการเรียก Ollama local-only บน Admin Desktop ตาม ADR-023A */
|
||||
@Injectable()
|
||||
export class OllamaService {
|
||||
private readonly logger = new Logger(OllamaService.name);
|
||||
private readonly ollamaUrl: string;
|
||||
private readonly mainModel: string;
|
||||
private readonly embedModel: string;
|
||||
private readonly timeoutMs: number;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.ollamaUrl = this.configService.get<string>(
|
||||
'OLLAMA_URL',
|
||||
this.configService.get<string>('AI_HOST_URL', 'http://localhost:11434')
|
||||
);
|
||||
this.mainModel = this.configService.get<string>(
|
||||
'OLLAMA_MODEL_MAIN',
|
||||
'gemma4:e4b'
|
||||
);
|
||||
this.embedModel = this.configService.get<string>(
|
||||
'OLLAMA_MODEL_EMBED',
|
||||
this.configService.get<string>('OLLAMA_EMBED_MODEL', 'nomic-embed-text')
|
||||
);
|
||||
this.timeoutMs = this.configService.get<number>('AI_TIMEOUT_MS', 30000);
|
||||
}
|
||||
|
||||
/** สร้างข้อความตอบกลับจาก gemma4:e4b หรือค่า ENV ที่กำหนด */
|
||||
async generate(
|
||||
prompt: string,
|
||||
options: OllamaGenerateOptions = {}
|
||||
): Promise<string> {
|
||||
try {
|
||||
const response = await axios.post<{ response: string }>(
|
||||
`${this.ollamaUrl}/api/generate`,
|
||||
{
|
||||
model: this.mainModel,
|
||||
prompt,
|
||||
stream: false,
|
||||
},
|
||||
{
|
||||
timeout: options.timeoutMs ?? this.timeoutMs,
|
||||
signal: options.signal,
|
||||
}
|
||||
);
|
||||
return response.data.response ?? '';
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
'Ollama generate failed',
|
||||
err instanceof Error ? err.stack : String(err)
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** สร้าง embedding ด้วย nomic-embed-text หรือค่า ENV ที่กำหนด */
|
||||
async generateEmbedding(text: string): Promise<number[]> {
|
||||
try {
|
||||
const response = await axios.post<{ embedding: number[] }>(
|
||||
`${this.ollamaUrl}/api/embeddings`,
|
||||
{ model: this.embedModel, prompt: text },
|
||||
{ timeout: this.timeoutMs }
|
||||
);
|
||||
return response.data.embedding;
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
'Ollama embedding failed',
|
||||
err instanceof Error ? err.stack : String(err)
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** คืนชื่อ main model สำหรับ audit log */
|
||||
getMainModelName(): string {
|
||||
return this.mainModel;
|
||||
}
|
||||
|
||||
/** คืนชื่อ embedding model สำหรับ audit log */
|
||||
getEmbeddingModelName(): string {
|
||||
return this.embedModel;
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,12 @@ export const QUEUE_VETO_NOTIFICATIONS = 'veto-notifications';
|
||||
/** Queue สำหรับ Legacy Document Migration ผ่าน AI Pipeline (ADR-023) */
|
||||
export const QUEUE_AI_INGEST = 'ai-ingest';
|
||||
|
||||
/** Queue สำหรับ AI งาน interactive ที่ต้องมาก่อน batch jobs (ADR-023A) */
|
||||
export const QUEUE_AI_REALTIME = 'ai-realtime';
|
||||
|
||||
/** Queue สำหรับ AI งาน batch เช่น OCR, extract metadata และ embedding (ADR-023A) */
|
||||
export const QUEUE_AI_BATCH = 'ai-batch';
|
||||
|
||||
/** Queue สำหรับ RAG Query ที่ต้องจำกัด concurrency บน Desk-5439 (ADR-023) */
|
||||
export const QUEUE_AI_RAG = 'ai-rag-query';
|
||||
|
||||
|
||||
@@ -10,12 +10,12 @@ import {
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { ContractService } from './contract.service.js';
|
||||
import { CreateContractDto } from './dto/create-contract.dto.js';
|
||||
import { UpdateContractDto } from './dto/update-contract.dto.js';
|
||||
import { SearchContractDto } from './dto/search-contract.dto.js';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard.js';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator.js';
|
||||
import { ContractService } from './contract.service';
|
||||
import { CreateContractDto } from './dto/create-contract.dto';
|
||||
import { UpdateContractDto } from './dto/update-contract.dto';
|
||||
import { SearchContractDto } from './dto/search-contract.dto';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe';
|
||||
|
||||
@ApiTags('Contracts')
|
||||
|
||||
@@ -6,8 +6,8 @@ import {
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, Like, FindOptionsWhere, FindManyOptions } from 'typeorm';
|
||||
import { Contract } from './entities/contract.entity';
|
||||
import { CreateContractDto } from './dto/create-contract.dto.js';
|
||||
import { UpdateContractDto } from './dto/update-contract.dto.js';
|
||||
import { CreateContractDto } from './dto/create-contract.dto';
|
||||
import { UpdateContractDto } from './dto/update-contract.dto';
|
||||
import { UuidResolverService } from '../../common/services/uuid-resolver.service';
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateContractDto } from './create-contract.dto.js';
|
||||
import { CreateContractDto } from './create-contract.dto';
|
||||
|
||||
export class UpdateContractDto extends PartialType(CreateContractDto) {}
|
||||
|
||||
@@ -9,13 +9,16 @@ import {
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { PermissionsGuard } from '../../common/auth/guards/permissions.guard';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import { Audit } from '../../common/decorators/audit.decorator';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { User } from '../user/entities/user.entity';
|
||||
import { DelegationService } from './delegation.service';
|
||||
import { CreateDelegationDto } from './dto/create-delegation.dto';
|
||||
|
||||
@Controller('delegations')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(JwtAuthGuard, PermissionsGuard)
|
||||
export class DelegationController {
|
||||
constructor(private readonly delegationService: DelegationService) {}
|
||||
|
||||
@@ -24,6 +27,7 @@ export class DelegationController {
|
||||
* ดึง Delegations ของ User ที่ login อยู่
|
||||
*/
|
||||
@Get()
|
||||
@RequirePermission('document.view')
|
||||
findMyDelegations(@CurrentUser() user: User) {
|
||||
return this.delegationService.findByDelegator(user.publicId);
|
||||
}
|
||||
@@ -33,6 +37,8 @@ export class DelegationController {
|
||||
* สร้าง Delegation ใหม่ (FR-011)
|
||||
*/
|
||||
@Post()
|
||||
@RequirePermission('document.view')
|
||||
@Audit('delegation.create', 'delegation')
|
||||
create(@CurrentUser() user: User, @Body() dto: CreateDelegationDto) {
|
||||
return this.delegationService.create(user.publicId, dto);
|
||||
}
|
||||
@@ -42,7 +48,9 @@ export class DelegationController {
|
||||
* Revoke delegation
|
||||
*/
|
||||
@Delete(':publicId')
|
||||
revoke(@Param('publicId') publicId: string, @CurrentUser() user: User) {
|
||||
@RequirePermission('document.view')
|
||||
@Audit('delegation.revoke', 'delegation')
|
||||
async revoke(@Param('publicId') publicId: string, @CurrentUser() user: User) {
|
||||
return this.delegationService.revoke(publicId, user.publicId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateOrganizationDto } from './create-organization.dto.js';
|
||||
import { CreateOrganizationDto } from './create-organization.dto';
|
||||
|
||||
export class UpdateOrganizationDto extends PartialType(CreateOrganizationDto) {}
|
||||
|
||||
@@ -10,12 +10,12 @@ import {
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { OrganizationService } from './organization.service.js';
|
||||
import { CreateOrganizationDto } from './dto/create-organization.dto.js';
|
||||
import { UpdateOrganizationDto } from './dto/update-organization.dto.js';
|
||||
import { SearchOrganizationDto } from './dto/search-organization.dto.js';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard.js';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator.js';
|
||||
import { OrganizationService } from './organization.service';
|
||||
import { CreateOrganizationDto } from './dto/create-organization.dto';
|
||||
import { UpdateOrganizationDto } from './dto/update-organization.dto';
|
||||
import { SearchOrganizationDto } from './dto/search-organization.dto';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe';
|
||||
|
||||
@ApiTags('Organizations')
|
||||
|
||||
@@ -6,8 +6,8 @@ import {
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Organization } from './entities/organization.entity';
|
||||
import { CreateOrganizationDto } from './dto/create-organization.dto.js';
|
||||
import { UpdateOrganizationDto } from './dto/update-organization.dto.js';
|
||||
import { CreateOrganizationDto } from './dto/create-organization.dto';
|
||||
import { UpdateOrganizationDto } from './dto/update-organization.dto';
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationService {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ProjectService } from './project.service.js';
|
||||
import { ProjectController } from './project.controller.js';
|
||||
import { ProjectService } from './project.service';
|
||||
import { ProjectController } from './project.controller';
|
||||
|
||||
import { Project } from './entities/project.entity';
|
||||
import { ProjectOrganization } from './entities/project-organization.entity';
|
||||
|
||||
@@ -12,9 +12,9 @@ import { Project } from './entities/project.entity';
|
||||
import { OrganizationService } from '../organization/organization.service';
|
||||
|
||||
// DTOs
|
||||
import { CreateProjectDto } from './dto/create-project.dto.js';
|
||||
import { UpdateProjectDto } from './dto/update-project.dto.js';
|
||||
import { SearchProjectDto } from './dto/search-project.dto.js';
|
||||
import { CreateProjectDto } from './dto/create-project.dto';
|
||||
import { UpdateProjectDto } from './dto/update-project.dto';
|
||||
import { SearchProjectDto } from './dto/search-project.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ProjectService {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { getQueueToken } from '@nestjs/bullmq';
|
||||
import { RagService } from '../rag.service';
|
||||
import { QdrantService } from '../qdrant.service';
|
||||
import { EmbeddingService } from '../embedding.service';
|
||||
import { TyphoonService } from '../typhoon.service';
|
||||
import { LocalLlmService } from '../local-llm.service';
|
||||
import { IngestionService } from '../ingestion.service';
|
||||
import { DocumentChunk } from '../entities/document-chunk.entity';
|
||||
import { QUEUE_AI_VECTOR_DELETION } from '../../common/constants/queue.constants';
|
||||
@@ -23,7 +23,7 @@ const mockEmbedding = {
|
||||
embed: jest.fn(),
|
||||
};
|
||||
|
||||
const mockTyphoon = {
|
||||
const mockLocalLlm = {
|
||||
generate: jest.fn(),
|
||||
sanitizeInput: jest.fn((t: string) => t),
|
||||
};
|
||||
@@ -56,7 +56,7 @@ describe('RagService', () => {
|
||||
RagService,
|
||||
{ provide: QdrantService, useValue: mockQdrant },
|
||||
{ provide: EmbeddingService, useValue: mockEmbedding },
|
||||
{ provide: TyphoonService, useValue: mockTyphoon },
|
||||
{ provide: LocalLlmService, useValue: mockLocalLlm },
|
||||
{ provide: IngestionService, useValue: mockIngestion },
|
||||
{ provide: getRepositoryToken(DocumentChunk), useValue: mockChunkRepo },
|
||||
{ provide: DEFAULT_REDIS_TOKEN, useValue: mockRedis },
|
||||
@@ -95,7 +95,7 @@ describe('RagService', () => {
|
||||
score: 0.92,
|
||||
},
|
||||
]);
|
||||
mockTyphoon.generate.mockResolvedValue({
|
||||
mockLocalLlm.generate.mockResolvedValue({
|
||||
answer: 'คำตอบ',
|
||||
usedFallbackModel: false,
|
||||
});
|
||||
@@ -129,20 +129,17 @@ describe('RagService', () => {
|
||||
mockQdrant.isReady.mockReturnValue(true);
|
||||
mockEmbedding.embed.mockResolvedValue(new Array(768).fill(0.1));
|
||||
mockQdrant.hybridSearch.mockResolvedValue([]);
|
||||
mockTyphoon.generate.mockResolvedValue({
|
||||
mockLocalLlm.generate.mockResolvedValue({
|
||||
answer: 'ลับมาก',
|
||||
usedFallbackModel: true,
|
||||
usedFallbackModel: false,
|
||||
});
|
||||
|
||||
const result = await service.query(dto, adminPerms);
|
||||
|
||||
expect(mockRedis.get).not.toHaveBeenCalled();
|
||||
expect(mockRedis.setex).not.toHaveBeenCalled();
|
||||
expect(mockTyphoon.generate).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
true
|
||||
);
|
||||
expect(result.usedFallbackModel).toBe(true);
|
||||
expect(mockLocalLlm.generate).toHaveBeenCalledWith(expect.any(String));
|
||||
expect(result.usedFallbackModel).toBe(false);
|
||||
});
|
||||
|
||||
it('collectionReady=false → throw ServiceUnavailableException RAG_NOT_READY', async () => {
|
||||
@@ -158,7 +155,7 @@ describe('RagService', () => {
|
||||
mockRedis.get.mockResolvedValue(null);
|
||||
mockEmbedding.embed.mockResolvedValue(new Array(768).fill(0.1));
|
||||
mockQdrant.hybridSearch.mockResolvedValue([]);
|
||||
mockTyphoon.generate.mockResolvedValue({
|
||||
mockLocalLlm.generate.mockResolvedValue({
|
||||
answer: 'A',
|
||||
usedFallbackModel: false,
|
||||
});
|
||||
@@ -181,7 +178,7 @@ describe('RagService', () => {
|
||||
mockRedis.get.mockResolvedValue(null);
|
||||
mockEmbedding.embed.mockResolvedValue(new Array(768).fill(0.1));
|
||||
mockQdrant.hybridSearch.mockResolvedValue([]);
|
||||
mockTyphoon.generate.mockResolvedValue({
|
||||
mockLocalLlm.generate.mockResolvedValue({
|
||||
anwer: 'ok',
|
||||
usedFallbackModel: false,
|
||||
});
|
||||
@@ -199,9 +196,9 @@ describe('RagService', () => {
|
||||
mockRedis.get.mockResolvedValue(null);
|
||||
mockEmbedding.embed.mockResolvedValue(new Array(768).fill(0.1));
|
||||
mockQdrant.hybridSearch.mockResolvedValue([]);
|
||||
mockTyphoon.generate.mockResolvedValue({
|
||||
mockLocalLlm.generate.mockResolvedValue({
|
||||
answer: 'ok',
|
||||
usedFallbackModel: true,
|
||||
usedFallbackModel: false,
|
||||
});
|
||||
|
||||
await service.query(dto, adminPerms);
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// File: src/modules/rag/local-llm.service.ts
|
||||
// Change Log
|
||||
// - 2026-05-15: แทนที่ cloud LLM API ด้วย Ollama local-only ตาม ADR-023A.
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios from 'axios';
|
||||
|
||||
export interface LlmGenerateResult {
|
||||
answer: string;
|
||||
usedFallbackModel: boolean;
|
||||
}
|
||||
|
||||
/** บริการเรียก LLM ภายในองค์กรผ่าน Ollama เท่านั้น */
|
||||
@Injectable()
|
||||
export class LocalLlmService {
|
||||
private readonly logger = new Logger(LocalLlmService.name);
|
||||
private readonly ollamaUrl: string;
|
||||
private readonly ollamaModel: string;
|
||||
private readonly timeoutMs: number;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.ollamaUrl = this.configService.get<string>(
|
||||
'OLLAMA_URL',
|
||||
this.configService.get<string>('AI_HOST_URL', 'http://localhost:11434')
|
||||
);
|
||||
this.ollamaModel = this.configService.get<string>(
|
||||
'OLLAMA_MODEL_MAIN',
|
||||
this.configService.get<string>('OLLAMA_RAG_MODEL', 'gemma4:e4b')
|
||||
);
|
||||
this.timeoutMs = this.configService.get<number>('RAG_TIMEOUT_MS', 30000);
|
||||
}
|
||||
|
||||
/** สร้างคำตอบจากโมเดล local-only โดยไม่มี cloud fallback */
|
||||
async generate(prompt: string): Promise<LlmGenerateResult> {
|
||||
try {
|
||||
const response = await axios.post<{ response: string }>(
|
||||
`${this.ollamaUrl}/api/generate`,
|
||||
{
|
||||
model: this.ollamaModel,
|
||||
prompt,
|
||||
stream: false,
|
||||
},
|
||||
{ timeout: this.timeoutMs }
|
||||
);
|
||||
return {
|
||||
answer: response.data.response ?? '',
|
||||
usedFallbackModel: false,
|
||||
};
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
'Local Ollama generation failed',
|
||||
err instanceof Error ? err.stack : String(err)
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** ทำความสะอาด prompt injection pattern พื้นฐานก่อนส่งเข้าโมเดล */
|
||||
sanitizeInput(text: string): string {
|
||||
return text
|
||||
.replace(/<CONTEXT_START>|<CONTEXT_END>/gi, '')
|
||||
.replace(/ignore previous instructions/gi, '')
|
||||
.replace(/system:/gi, '')
|
||||
.slice(0, 1000);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { DocumentChunk } from './entities/document-chunk.entity';
|
||||
import { QUEUE_AI_VECTOR_DELETION } from '../common/constants/queue.constants';
|
||||
import { EmbeddingService } from './embedding.service';
|
||||
import { QdrantService } from './qdrant.service';
|
||||
import { TyphoonService } from './typhoon.service';
|
||||
import { LocalLlmService } from './local-llm.service';
|
||||
import { RagService } from './rag.service';
|
||||
import { RagController } from './rag.controller';
|
||||
import { IngestionService } from './ingestion.service';
|
||||
@@ -40,7 +40,7 @@ const DLQ_DEFAULTS = {
|
||||
providers: [
|
||||
EmbeddingService,
|
||||
QdrantService,
|
||||
TyphoonService,
|
||||
LocalLlmService,
|
||||
RagService,
|
||||
IngestionService,
|
||||
OcrProcessor,
|
||||
@@ -50,7 +50,7 @@ const DLQ_DEFAULTS = {
|
||||
exports: [
|
||||
EmbeddingService,
|
||||
QdrantService,
|
||||
TyphoonService,
|
||||
LocalLlmService,
|
||||
RagService,
|
||||
IngestionService,
|
||||
],
|
||||
|
||||
@@ -16,7 +16,7 @@ import { createHash } from 'crypto';
|
||||
|
||||
import { QdrantService } from './qdrant.service';
|
||||
import { EmbeddingService } from './embedding.service';
|
||||
import { TyphoonService } from './typhoon.service';
|
||||
import { LocalLlmService } from './local-llm.service';
|
||||
import { IngestionService } from './ingestion.service';
|
||||
import { DocumentChunk } from './entities/document-chunk.entity';
|
||||
import { RagQueryDto } from './dto/rag-query.dto';
|
||||
@@ -32,7 +32,7 @@ export class RagService {
|
||||
constructor(
|
||||
private readonly qdrant: QdrantService,
|
||||
private readonly embedding: EmbeddingService,
|
||||
private readonly typhoon: TyphoonService,
|
||||
private readonly localLlm: LocalLlmService,
|
||||
private readonly ingestionService: IngestionService,
|
||||
@InjectRepository(DocumentChunk)
|
||||
private readonly chunkRepo: Repository<DocumentChunk>,
|
||||
@@ -84,13 +84,10 @@ export class RagService {
|
||||
|
||||
const context = this.buildContext(reranked);
|
||||
|
||||
const safeQuestion = this.typhoon.sanitizeInput(question);
|
||||
const safeQuestion = this.localLlm.sanitizeInput(question);
|
||||
const prompt = this.buildPrompt(safeQuestion, context);
|
||||
|
||||
const { answer, usedFallbackModel } = await this.typhoon.generate(
|
||||
prompt,
|
||||
isConfidential
|
||||
);
|
||||
const { answer, usedFallbackModel } = await this.localLlm.generate(prompt);
|
||||
|
||||
const citations: RagCitation[] = reranked.map((r) => ({
|
||||
chunkId: r.chunkId,
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios from 'axios';
|
||||
|
||||
export interface LlmGenerateResult {
|
||||
answer: string;
|
||||
usedFallbackModel: boolean;
|
||||
}
|
||||
|
||||
interface TyphoonChatResponse {
|
||||
choices: Array<{ message: { content: string } }>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TyphoonService {
|
||||
private readonly logger = new Logger(TyphoonService.name);
|
||||
private readonly typhoonUrl: string;
|
||||
private readonly typhoonKey: string;
|
||||
private readonly ollamaUrl: string;
|
||||
private readonly ollamaModel: string;
|
||||
private readonly timeoutMs: number;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.typhoonUrl = this.configService.get<string>(
|
||||
'TYPHOON_API_URL',
|
||||
'https://api.opentyphoon.ai/v1'
|
||||
);
|
||||
this.typhoonKey = this.configService.get<string>('TYPHOON_API_KEY', '');
|
||||
this.ollamaUrl = this.configService.get<string>(
|
||||
'OLLAMA_URL',
|
||||
'http://localhost:11434'
|
||||
);
|
||||
this.ollamaModel = this.configService.get<string>(
|
||||
'OLLAMA_RAG_MODEL',
|
||||
'gemma3:12b'
|
||||
);
|
||||
this.timeoutMs = this.configService.get<number>('RAG_TIMEOUT_MS', 5000);
|
||||
}
|
||||
|
||||
async generate(
|
||||
prompt: string,
|
||||
forceLocal: boolean
|
||||
): Promise<LlmGenerateResult> {
|
||||
if (forceLocal) {
|
||||
const answer = await this.generateOllama(prompt);
|
||||
return { answer, usedFallbackModel: true };
|
||||
}
|
||||
|
||||
try {
|
||||
const answer = await Promise.race([
|
||||
this.generateTyphoon(prompt),
|
||||
this.delay(this.timeoutMs).then(() => {
|
||||
throw new Error('Typhoon timeout');
|
||||
}),
|
||||
]);
|
||||
return { answer, usedFallbackModel: false };
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Typhoon failed, falling back to Ollama: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
const answer = await this.generateOllama(prompt);
|
||||
return { answer, usedFallbackModel: true };
|
||||
}
|
||||
}
|
||||
|
||||
sanitizeInput(text: string): string {
|
||||
return text
|
||||
.replace(/<CONTEXT_START>|<CONTEXT_END>/gi, '')
|
||||
.replace(/ignore previous instructions/gi, '')
|
||||
.replace(/system:/gi, '')
|
||||
.slice(0, 1000);
|
||||
}
|
||||
|
||||
private async generateTyphoon(prompt: string): Promise<string> {
|
||||
const response = await axios.post<TyphoonChatResponse>(
|
||||
`${this.typhoonUrl}/chat/completions`,
|
||||
{
|
||||
model: 'typhoon-v2.1-12b-instruct',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: `<CONTEXT_START>\n${prompt}\n<CONTEXT_END>`,
|
||||
},
|
||||
],
|
||||
max_tokens: 1024,
|
||||
temperature: 0.1,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.typhoonKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: this.timeoutMs,
|
||||
}
|
||||
);
|
||||
return response.data.choices[0]?.message?.content ?? '';
|
||||
}
|
||||
|
||||
private async generateOllama(prompt: string): Promise<string> {
|
||||
const response = await axios.post<{ response: string }>(
|
||||
`${this.ollamaUrl}/api/generate`,
|
||||
{
|
||||
model: this.ollamaModel,
|
||||
prompt,
|
||||
stream: false,
|
||||
},
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
return response.data.response ?? '';
|
||||
}
|
||||
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import { ReminderProcessor } from './processors/reminder.processor';
|
||||
import { QUEUE_REMINDERS } from '../common/constants/queue.constants';
|
||||
import { NotificationModule } from '../notification/notification.module';
|
||||
import { Project } from '../project/entities/project.entity';
|
||||
import { UserAssignment } from '../user/entities/user-assignment.entity';
|
||||
import { Role } from '../user/entities/role.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -21,7 +23,10 @@ import { Project } from '../project/entities/project.entity';
|
||||
ReminderHistory,
|
||||
ReviewTask,
|
||||
Project,
|
||||
UserAssignment,
|
||||
Role,
|
||||
]),
|
||||
|
||||
BullModule.registerQueue({ name: QUEUE_REMINDERS }),
|
||||
NotificationModule,
|
||||
],
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
import { NotificationService } from '../../notification/notification.service';
|
||||
import { ReminderRule } from '../entities/reminder-rule.entity';
|
||||
import { ReminderHistory } from '../entities/reminder-history.entity';
|
||||
import { UserAssignment } from '../../user/entities/user-assignment.entity';
|
||||
import { CorrespondenceRevision } from '../../correspondence/entities/correspondence-revision.entity';
|
||||
|
||||
@Injectable()
|
||||
export class EscalationService {
|
||||
@@ -23,6 +25,8 @@ export class EscalationService {
|
||||
private readonly reminderRuleRepo: Repository<ReminderRule>,
|
||||
@InjectRepository(ReminderHistory)
|
||||
private readonly historyRepo: Repository<ReminderHistory>,
|
||||
@InjectRepository(UserAssignment)
|
||||
private readonly assignmentRepo: Repository<UserAssignment>,
|
||||
private readonly notificationService: NotificationService
|
||||
) {}
|
||||
|
||||
@@ -108,8 +112,55 @@ export class EscalationService {
|
||||
`Escalation L2 (Strike ${strikes + 1}): task ${taskPublicId} — escalating to PM`
|
||||
);
|
||||
|
||||
// TODO: ดึง PM user ID จาก project membership
|
||||
// สำหรับตอนนี้ แจ้งผู้รับผิดชอบเดิมแต่หัวเรื่องแรงขึ้น
|
||||
// ✅ [Fix] ดึง PM user ID จาก project membership (T068.5)
|
||||
let pmUserId: number | undefined = undefined;
|
||||
|
||||
try {
|
||||
const fullTask = (await this.reviewTaskRepo.findOne({
|
||||
where: { publicId: taskPublicId },
|
||||
relations: [
|
||||
'rfaRevision',
|
||||
'rfaRevision.correspondenceRevision',
|
||||
'rfaRevision.correspondenceRevision.correspondence',
|
||||
],
|
||||
})) as {
|
||||
rfaRevision?: {
|
||||
correspondenceRevision?: CorrespondenceRevision;
|
||||
};
|
||||
} | null;
|
||||
|
||||
const correspondence =
|
||||
fullTask?.rfaRevision?.correspondenceRevision?.correspondence;
|
||||
|
||||
if (correspondence?.projectId) {
|
||||
const pmAssignment = await this.assignmentRepo.findOne({
|
||||
where: {
|
||||
projectId: correspondence.projectId,
|
||||
role: { roleName: 'Project Manager' },
|
||||
},
|
||||
relations: ['role'],
|
||||
});
|
||||
pmUserId = pmAssignment?.userId;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
this.logger.error(
|
||||
`Failed to find PM for task ${taskPublicId}: ${String(err)}`
|
||||
);
|
||||
}
|
||||
|
||||
// แจ้ง PM (ถ้าหาเจอ)
|
||||
if (pmUserId) {
|
||||
await this.notificationService.send({
|
||||
userId: pmUserId,
|
||||
title: `🛑 ESCALATION L2: Review Task Overdue`,
|
||||
message: `Task ${task.publicId} (${task.discipline?.codeNameEn ?? ''}) assigned to ${task.assignedToUser?.firstName ?? ''} ${task.assignedToUser?.lastName ?? ''} is critically overdue.`,
|
||||
type: 'SYSTEM',
|
||||
entityType: 'review_task',
|
||||
entityId: task.id,
|
||||
});
|
||||
}
|
||||
|
||||
// แจ้งผู้รับผิดชอบเดิมด้วย
|
||||
if (task.assignedToUserId) {
|
||||
await this.notificationService.send({
|
||||
userId: task.assignedToUserId,
|
||||
|
||||
@@ -95,4 +95,8 @@ export class ReviewTask extends UuidBaseEntity {
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'delegated_from_user_id' })
|
||||
delegatedFromUser?: User;
|
||||
|
||||
@ManyToOne('RfaRevision')
|
||||
@JoinColumn({ name: 'rfa_revision_id' })
|
||||
rfaRevision?: unknown; // Use unknown to avoid circular dependency and satisfy linter
|
||||
}
|
||||
|
||||
@@ -11,7 +11,11 @@ import {
|
||||
ParseUUIDPipe,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { PermissionsGuard } from '../../common/auth/guards/permissions.guard';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import { Audit } from '../../common/decorators/audit.decorator';
|
||||
import { ReviewTaskService } from './review-task.service';
|
||||
|
||||
import { ConsensusService } from './services/consensus.service';
|
||||
import { VetoOverrideService } from './services/veto-override.service';
|
||||
import type { VetoOverrideDto } from './services/veto-override.service';
|
||||
@@ -23,7 +27,7 @@ import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { User } from '../user/entities/user.entity';
|
||||
|
||||
@Controller('review-tasks')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(JwtAuthGuard, PermissionsGuard)
|
||||
export class ReviewTaskController {
|
||||
constructor(
|
||||
private readonly reviewTaskService: ReviewTaskService,
|
||||
@@ -32,21 +36,27 @@ export class ReviewTaskController {
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermission('document.view')
|
||||
findAll(@Query() dto: SearchReviewTaskDto) {
|
||||
return this.reviewTaskService.findAll(dto);
|
||||
}
|
||||
|
||||
@Get(':publicId')
|
||||
@RequirePermission('document.view')
|
||||
findOne(@Param('publicId', ParseUUIDPipe) publicId: string) {
|
||||
return this.reviewTaskService.findByPublicId(publicId);
|
||||
}
|
||||
|
||||
@Patch(':publicId/start')
|
||||
@RequirePermission('workflow.action_review')
|
||||
@Audit('review_task.start', 'review_task')
|
||||
startReview(@Param('publicId', ParseUUIDPipe) publicId: string) {
|
||||
return this.reviewTaskService.startReview(publicId);
|
||||
}
|
||||
|
||||
@Patch(':publicId/complete')
|
||||
@RequirePermission('workflow.action_review')
|
||||
@Audit('review_task.complete', 'review_task')
|
||||
async completeReview(
|
||||
@Param('publicId', ParseUUIDPipe) publicId: string,
|
||||
@Body() dto: CompleteReviewTaskDto,
|
||||
@@ -102,6 +112,8 @@ export class ReviewTaskController {
|
||||
}
|
||||
|
||||
@Post('veto-override')
|
||||
@RequirePermission('document.admin_edit')
|
||||
@Audit('review_task.veto_override', 'review_task')
|
||||
async overrideVeto(@Body() dto: VetoOverrideDto, @CurrentUser() user: User) {
|
||||
return this.vetoOverrideService.executeOverride({
|
||||
...dto,
|
||||
|
||||
@@ -18,9 +18,12 @@ import {
|
||||
AddTeamMemberDto,
|
||||
SearchReviewTeamDto,
|
||||
} from './dto/shared/review-team.dto';
|
||||
import { PermissionsGuard } from '../../common/auth/guards/permissions.guard';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import { Audit } from '../../common/decorators/audit.decorator';
|
||||
|
||||
@Controller('review-teams')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(JwtAuthGuard, PermissionsGuard)
|
||||
export class ReviewTeamController {
|
||||
constructor(private readonly reviewTeamService: ReviewTeamService) {}
|
||||
|
||||
@@ -29,6 +32,7 @@ export class ReviewTeamController {
|
||||
* ดึงรายการ Review Teams ตาม project
|
||||
*/
|
||||
@Get()
|
||||
@RequirePermission('master_data.view')
|
||||
findAll(@Query() dto: SearchReviewTeamDto) {
|
||||
return this.reviewTeamService.findAll(dto);
|
||||
}
|
||||
@@ -38,6 +42,7 @@ export class ReviewTeamController {
|
||||
* ดึง Review Team เดียว (ADR-019)
|
||||
*/
|
||||
@Get(':publicId')
|
||||
@RequirePermission('master_data.view')
|
||||
findOne(@Param('publicId') publicId: string) {
|
||||
return this.reviewTeamService.findByPublicId(publicId);
|
||||
}
|
||||
@@ -47,6 +52,8 @@ export class ReviewTeamController {
|
||||
* สร้าง Review Team ใหม่
|
||||
*/
|
||||
@Post()
|
||||
@RequirePermission('master_data.manage')
|
||||
@Audit('review_team.create', 'review_team')
|
||||
create(@Body() dto: CreateReviewTeamDto) {
|
||||
return this.reviewTeamService.create(dto);
|
||||
}
|
||||
@@ -56,6 +63,8 @@ export class ReviewTeamController {
|
||||
* อัปเดต Review Team
|
||||
*/
|
||||
@Patch(':publicId')
|
||||
@RequirePermission('master_data.manage')
|
||||
@Audit('review_team.update', 'review_team')
|
||||
update(
|
||||
@Param('publicId') publicId: string,
|
||||
@Body() dto: UpdateReviewTeamDto
|
||||
@@ -68,6 +77,8 @@ export class ReviewTeamController {
|
||||
* เพิ่มสมาชิก
|
||||
*/
|
||||
@Post(':publicId/members')
|
||||
@RequirePermission('master_data.manage')
|
||||
@Audit('review_team.add_member', 'review_team')
|
||||
addMember(
|
||||
@Param('publicId') teamPublicId: string,
|
||||
@Body() dto: AddTeamMemberDto
|
||||
@@ -80,6 +91,8 @@ export class ReviewTeamController {
|
||||
* ลบสมาชิก
|
||||
*/
|
||||
@Delete(':publicId/members/:memberPublicId')
|
||||
@RequirePermission('master_data.manage')
|
||||
@Audit('review_team.remove_member', 'review_team')
|
||||
removeMember(
|
||||
@Param('publicId') teamPublicId: string,
|
||||
@Param('memberPublicId') memberPublicId: string
|
||||
@@ -92,6 +105,8 @@ export class ReviewTeamController {
|
||||
* Deactivate Review Team (soft delete)
|
||||
*/
|
||||
@Delete(':publicId')
|
||||
@RequirePermission('master_data.manage')
|
||||
@Audit('review_team.deactivate', 'review_team')
|
||||
deactivate(@Param('publicId') publicId: string) {
|
||||
return this.reviewTeamService.deactivate(publicId);
|
||||
}
|
||||
|
||||
@@ -118,4 +118,23 @@ export class AggregateStatusService {
|
||||
|
||||
return ConsensusDecision.APPROVED_WITH_COMMENTS;
|
||||
}
|
||||
|
||||
/**
|
||||
* คืนค่า Response Code ที่เข้มงวดที่สุดจาก Tasks ที่เสร็จแล้ว (T068 Improvement)
|
||||
* Code Priority: 3 > 2 > 1B > 1A
|
||||
*/
|
||||
async getMostRestrictiveResponseCode(rfaRevisionId: number): Promise<string> {
|
||||
const tasks = await this.taskRepo.find({
|
||||
where: { rfaRevisionId, status: ReviewTaskStatus.COMPLETED },
|
||||
relations: ['responseCode'],
|
||||
});
|
||||
|
||||
if (tasks.length === 0) return '1A';
|
||||
|
||||
const codes = tasks.map((t) => t.responseCode?.code ?? '').filter(Boolean);
|
||||
if (codes.includes('3')) return '3';
|
||||
if (codes.includes('2')) return '2';
|
||||
if (codes.includes('1B')) return '1B';
|
||||
return '1A';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,7 @@ import { Repository } from 'typeorm';
|
||||
import { ReviewTask } from '../entities/review-task.entity';
|
||||
import { AggregateStatusService } from './aggregate-status.service';
|
||||
import { ApprovalListenerService } from '../../distribution/services/approval-listener.service';
|
||||
import {
|
||||
ConsensusDecision,
|
||||
ReviewTaskStatus,
|
||||
} from '../../common/enums/review.enums';
|
||||
import { ConsensusDecision } from '../../common/enums/review.enums';
|
||||
|
||||
export interface ConsensusResult {
|
||||
decision: ConsensusDecision;
|
||||
@@ -72,15 +69,10 @@ export class ConsensusService {
|
||||
decision === ConsensusDecision.APPROVED ||
|
||||
decision === ConsensusDecision.APPROVED_WITH_COMMENTS
|
||||
) {
|
||||
// ดึง response code ที่ predominant
|
||||
const completedTasks = await this.taskRepo.find({
|
||||
where: { rfaRevisionId, status: ReviewTaskStatus.COMPLETED },
|
||||
relations: ['responseCode'],
|
||||
order: { completedAt: 'DESC' },
|
||||
take: 1,
|
||||
});
|
||||
|
||||
const responseCode = completedTasks[0]?.responseCode?.code ?? '1A';
|
||||
const responseCode =
|
||||
await this.aggregateStatusService.getMostRestrictiveResponseCode(
|
||||
rfaRevisionId
|
||||
);
|
||||
|
||||
await this.approvalListenerService.onConsensusReached({
|
||||
...context,
|
||||
|
||||
@@ -45,6 +45,7 @@ export class TaskCreationService {
|
||||
*/
|
||||
async createParallelTasks(
|
||||
rfaRevisionId: number,
|
||||
rfaPublicId: string,
|
||||
reviewTeamPublicId: string,
|
||||
dueDate: Date,
|
||||
manager: EntityManager,
|
||||
@@ -113,7 +114,7 @@ export class TaskCreationService {
|
||||
if (saved.assignedToUserId) {
|
||||
await this.schedulerService.scheduleForTask({
|
||||
taskPublicId: saved.publicId,
|
||||
rfaPublicId: rfaRevisionId.toString(), // ใช้ rfaRevisionId เป็น placeholder
|
||||
rfaPublicId: rfaPublicId, // ADR-019: Use actual UUID
|
||||
assigneeUserId: saved.assignedToUserId,
|
||||
dueDate: saved.dueDate ?? dueDate,
|
||||
reminderType: ReminderType.DUE_SOON, // Start type, scheduler will fetch rules
|
||||
|
||||
@@ -759,6 +759,7 @@ export class RfaService {
|
||||
if (reviewTeamPublicId) {
|
||||
await this.taskCreationService.createParallelTasks(
|
||||
currentRfaRev.id,
|
||||
currentCorrRev.publicId, // ADR-019: Pass UUID
|
||||
reviewTeamPublicId,
|
||||
routing.dueDate ?? new Date(),
|
||||
queryRunner.manager,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ValidationException } from '../../common/exceptions';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
import { UserAssignment } from './entities/user-assignment.entity';
|
||||
import { AssignRoleDto } from './dto/assign-role.dto.js';
|
||||
import { AssignRoleDto } from './dto/assign-role.dto';
|
||||
import { BulkAssignmentDto, ActionType } from './dto/bulk-assignment.dto';
|
||||
import { User } from './entities/user.entity';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user