feat(rfa-ai): Complete RFA Approval Refactor and AI Model Revision
CI / CD Pipeline / build (push) Successful in 4m54s
CI / CD Pipeline / deploy (push) Failing after 12m9s

This commit is contained in:
2026-05-16 10:59:53 +07:00
parent 6cb3ae10ee
commit 1a162bf320
105 changed files with 5088 additions and 1083 deletions
+4 -8
View File
@@ -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],
+6 -6
View File
@@ -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 โดยตรง
+21
View File
@@ -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: '',
+81
View File
@@ -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')
+62 -3
View File
@@ -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)');
}
}
}
+27
View File
@@ -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 },
+255 -2
View File
@@ -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')}`
);
}
}
+42 -5
View File
@@ -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);
}
}
+3 -3
View File
@@ -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,
],
+4 -7
View File
@@ -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,
-115
View File
@@ -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
+1
View File
@@ -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';