feat(ai-admin-console): complete implementation and resolve lint compilation errors

This commit is contained in:
2026-05-21 21:42:25 +07:00
parent 1580ab2c18
commit 91e9c714df
39 changed files with 3724 additions and 72 deletions
+53 -1
View File
@@ -2,6 +2,8 @@
// Change Log
// - 2026-05-14: เพิ่ม service กลางสำหรับส่งงาน AI เข้า BullMQ ตาม ADR-023.
// - 2026-05-14: เพิ่ม JSDoc idempotency contract สำหรับทุก enqueue method (💡 S3).
// - 2026-05-21: เพิ่มการลงทะเบียน QUEUE_AI_BATCH และ enqueueSandboxJob สำหรับ Superadmin sandbox.
// - 2026-05-21: แก้ไข ESLint error โดยการเปลี่ยน Queue<any> เป็น Queue<unknown> สำหรับ batchQueue
import { Injectable } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue, JobsOptions } from 'bullmq';
@@ -9,6 +11,7 @@ import {
QUEUE_AI_INGEST,
QUEUE_AI_RAG,
QUEUE_AI_VECTOR_DELETION,
QUEUE_AI_BATCH,
} from '../common/constants/queue.constants';
/** Payload สำหรับงาน ingest เอกสารเก่าเข้า AI Pipeline */
@@ -48,7 +51,9 @@ export class AiQueueService {
@InjectQueue(QUEUE_AI_RAG)
private readonly ragQueue: Queue<AiRagJobPayload>,
@InjectQueue(QUEUE_AI_VECTOR_DELETION)
private readonly vectorDeletionQueue: Queue<AiVectorDeletionJobPayload>
private readonly vectorDeletionQueue: Queue<AiVectorDeletionJobPayload>,
@InjectQueue(QUEUE_AI_BATCH)
private readonly batchQueue: Queue<unknown>
) {}
/**
@@ -92,4 +97,51 @@ export class AiQueueService {
);
return String(job.id);
}
/**
* ส่ง sandbox job เข้า queue ai-batch โดยกำหนด priority = 1 เพื่อความรวดเร็วสำหรับ Superadmin
* @idempotency `jobId = payload.idempotencyKey`
*/
async enqueueSandboxJob(
jobType: 'sandbox-rag' | 'sandbox-extract',
payload: {
idempotencyKey: string;
projectPublicId?: string;
query?: string;
userPublicId?: string;
filePublicId?: string;
pdfPath?: string;
}
): Promise<string> {
const job = await this.batchQueue.add(
jobType,
{
jobType,
documentPublicId: payload.idempotencyKey,
projectPublicId: payload.projectPublicId ?? '',
payload: {
query: payload.query,
userPublicId: payload.userPublicId,
filePublicId: payload.filePublicId,
pdfPath: payload.pdfPath,
},
idempotencyKey: payload.idempotencyKey,
},
{
...this.defaultOptions,
priority: 1,
jobId: payload.idempotencyKey,
}
);
return String(job.id);
}
/**
* ดึงจำนวนงานที่กำลังประมวลผลอยู่หรือกำลังรอคิวใน batchQueue เพื่อคำนวณ rate limiting แบบไดนามิก
*/
async getBatchQueueSize(): Promise<number> {
const active = await this.batchQueue.getActiveCount();
const waiting = await this.batchQueue.getWaitingCount();
return active + waiting;
}
}
@@ -0,0 +1,87 @@
// File: src/modules/ai/ai-settings.service.spec.ts
// Change Log
// - 2026-05-21: เพิ่ม regression tests สำหรับ AI feature toggle cache/DB behavior.
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { AiSettingsService } from './ai-settings.service';
import { SystemSetting } from './entities/system-setting.entity';
const DEFAULT_REDIS_TOKEN = 'default_IORedisModuleConnectionToken';
describe('AiSettingsService', () => {
const mockSettingRepo = {
findOne: jest.fn(),
save: jest.fn(),
manager: {
transaction: jest.fn(),
},
};
const mockRedis = {
get: jest.fn(),
set: jest.fn(),
del: jest.fn(),
};
let service: AiSettingsService;
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
AiSettingsService,
{
provide: getRepositoryToken(SystemSetting),
useValue: mockSettingRepo,
},
{ provide: DEFAULT_REDIS_TOKEN, useValue: mockRedis },
],
}).compile();
service = module.get<AiSettingsService>(AiSettingsService);
});
it('ควรอ่านค่า enabled จาก Redis cache เมื่อมีค่าอยู่แล้ว', async () => {
mockRedis.get.mockResolvedValue('false');
await expect(service.getAiFeaturesEnabled()).resolves.toBe(false);
expect(mockSettingRepo.findOne).not.toHaveBeenCalled();
});
it('ควร fallback ไป DB และเขียน cache เมื่อ Redis cache miss', async () => {
mockRedis.get.mockResolvedValue(null);
mockSettingRepo.findOne.mockResolvedValue({ settingValue: 'true' });
await expect(service.getAiFeaturesEnabled()).resolves.toBe(true);
expect(mockRedis.set).toHaveBeenCalledWith(
'system_settings:AI_FEATURES_ENABLED',
'true',
'EX',
30
);
});
it('ควรอัปเดต DB ใน transaction แล้ว invalid cache หลังสำเร็จ', async () => {
const transactionalRepo = {
findOne: jest.fn().mockResolvedValue({ settingValue: 'true' }),
save: jest.fn().mockResolvedValue({ settingValue: 'false' }),
create: jest.fn(),
};
mockSettingRepo.manager.transaction.mockImplementation(
async (
callback: (manager: {
getRepository: () => typeof transactionalRepo;
}) => Promise<void>
) => callback({ getRepository: () => transactionalRepo })
);
await service.setAiFeaturesEnabled(false, 7);
expect(transactionalRepo.save).toHaveBeenCalledWith(
expect.objectContaining({ settingValue: 'false', updatedBy: 7 })
);
expect(mockRedis.del).toHaveBeenCalledWith(
'system_settings:AI_FEATURES_ENABLED'
);
});
});
@@ -0,0 +1,108 @@
// File: src/modules/ai/ai-settings.service.ts
// Change Log
// - 2026-05-21: เพิ่ม service สำหรับอ่าน/เขียน AI feature toggle พร้อม Redis cache.
import { Injectable, Logger } from '@nestjs/common';
import { InjectRedis } from '@nestjs-modules/ioredis';
import { InjectRepository } from '@nestjs/typeorm';
import type Redis from 'ioredis';
import { EntityManager, Repository } from 'typeorm';
import { SystemSetting } from './entities/system-setting.entity';
const AI_FEATURES_ENABLED_KEY = 'AI_FEATURES_ENABLED';
const AI_FEATURES_ENABLED_CACHE_KEY = 'system_settings:AI_FEATURES_ENABLED';
const AI_FEATURES_ENABLED_TTL_SECONDS = 30;
/** Service สำหรับจัดการ system_settings ที่เกี่ยวข้องกับ AI Admin Console */
@Injectable()
export class AiSettingsService {
private readonly logger = new Logger(AiSettingsService.name);
constructor(
@InjectRepository(SystemSetting)
private readonly settingRepo: Repository<SystemSetting>,
@InjectRedis() private readonly redis: Redis
) {}
/** อ่านสถานะเปิด/ปิด AI features โดยใช้ Redis cache ก่อน DB */
async getAiFeaturesEnabled(): Promise<boolean> {
const cachedValue = await this.getCachedValue();
if (cachedValue !== null) return cachedValue === 'true';
const setting = await this.settingRepo.findOne({
where: { settingKey: AI_FEATURES_ENABLED_KEY },
});
const enabled = setting ? setting.settingValue === 'true' : true;
await this.setCachedValue(enabled);
return enabled;
}
/** อัปเดตสถานะ AI features ใน transaction แล้ว invalid cache หลัง DB สำเร็จ */
async setAiFeaturesEnabled(
enabled: boolean,
userId: number
): Promise<boolean> {
await this.settingRepo.manager.transaction(
async (manager: EntityManager): Promise<void> => {
const repo = manager.getRepository(SystemSetting);
const existing = await repo.findOne({
where: { settingKey: AI_FEATURES_ENABLED_KEY },
});
const setting =
existing ??
repo.create({
settingKey: AI_FEATURES_ENABLED_KEY,
dataType: 'boolean',
category: 'ai',
description:
'สถานะเปิด/ปิดการใช้งานฟีเจอร์ AI ทั้งระบบ สำหรับผู้ใช้ทั่วไป',
isPublic: true,
});
setting.settingValue = String(enabled);
setting.updatedBy = userId;
await repo.save(setting);
}
);
await this.deleteCachedValue();
return enabled;
}
private async getCachedValue(): Promise<string | null> {
try {
return await this.redis.get(AI_FEATURES_ENABLED_CACHE_KEY);
} catch (error: unknown) {
this.logger.warn(
`AI settings cache read failed: ${this.toMessage(error)}`
);
return null;
}
}
private async setCachedValue(enabled: boolean): Promise<void> {
try {
await this.redis.set(
AI_FEATURES_ENABLED_CACHE_KEY,
String(enabled),
'EX',
AI_FEATURES_ENABLED_TTL_SECONDS
);
} catch (error: unknown) {
this.logger.warn(
`AI settings cache write failed: ${this.toMessage(error)}`
);
}
}
private async deleteCachedValue(): Promise<void> {
try {
await this.redis.del(AI_FEATURES_ENABLED_CACHE_KEY);
} catch (error: unknown) {
this.logger.warn(
`AI settings cache invalidation failed: ${this.toMessage(error)}`
);
}
}
private toMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
}
+186 -6
View File
@@ -3,6 +3,10 @@
// - 2026-05-14: เพิ่ม Legacy Migration staging endpoints ตาม ADR-023.
// - 2026-05-14: ย้าย DeleteAuditLogsQueryDto ไป dto/ folder; ลบ authHeader passthrough (🟢 LOW-1/LOW-2).
// - 2026-05-19: เพิ่ม POST /ai/intent endpoint สำหรับ AI Tool Layer (ADR-025).
// - 2026-05-21: เพิ่ม AI Admin settings endpoints และ AiEnabledGuard สำหรับ ADR-027.
// - 2026-05-21: เพิ่ม GET /ai/admin/health สำหรับดึงสถานะสุขภาพ AI Infrastructure (T028).
// - 2026-05-21: เพิ่ม POST /ai/admin/sandbox/extract endpoint สำหรับ Superadmin OCR sandbox (T041 & T042)
// - 2026-05-21: แก้ไขข้อห้ามใช้ parseInt โดยการใช้ Number แทนตามกฎ Tier 1
// Controller สำหรับ AI Gateway Endpoints (ADR-023)
import {
@@ -20,8 +24,13 @@ import {
UseGuards,
UseInterceptors,
UploadedFiles,
UploadedFile,
HttpException,
ParseFilePipe,
MaxFileSizeValidator,
FileTypeValidator,
} from '@nestjs/common';
import { FilesInterceptor } from '@nestjs/platform-express';
import { FilesInterceptor, FileInterceptor } from '@nestjs/platform-express';
import { Throttle } from '@nestjs/throttler';
import {
ApiTags,
@@ -32,6 +41,7 @@ import {
ApiQuery,
} from '@nestjs/swagger';
import { AiService, ExtractionResult, PaginatedResult } from './ai.service';
import { AiSettingsService } from './ai-settings.service';
import {
AiIngestService,
MigrationReviewResponse,
@@ -62,6 +72,11 @@ import { v7 as uuidv7 } from 'uuid';
import { DeleteAuditLogsQueryDto } from './dto/delete-audit-logs.dto';
import { AiToolRegistryService } from './tool/ai-tool-registry.service';
import { AiIntentRequestDto } from './dto/ai-intent-request.dto';
import { ToggleAiFeaturesDto } from './dto/ai-admin-settings.dto';
import { AiEnabledGuard } from './guards/ai-enabled.guard';
import { InjectRedis } from '@nestjs-modules/ioredis';
import Redis from 'ioredis';
import { FileStorageService } from '../../common/file-storage/file-storage.service';
@ApiTags('AI Gateway')
@Controller('ai')
@@ -71,7 +86,10 @@ export class AiController {
private readonly aiIngestService: AiIngestService,
private readonly aiRagService: AiRagService,
private readonly aiQueueService: AiQueueService,
private readonly aiToolRegistryService: AiToolRegistryService
private readonly aiSettingsService: AiSettingsService,
private readonly aiToolRegistryService: AiToolRegistryService,
private readonly fileStorageService: FileStorageService,
@InjectRedis() private readonly redis: Redis
) {}
// --- Real-time Extraction (User Upload) ---
@@ -79,7 +97,7 @@ export class AiController {
// ─── AI Tool Layer Endpoint (ADR-025) ──────────────────────────────────────
@Post('intent')
@UseGuards(JwtAuthGuard, RbacGuard)
@UseGuards(JwtAuthGuard, AiEnabledGuard, RbacGuard)
@ApiBearerAuth()
@RequirePermission('ai.suggest')
@HttpCode(HttpStatus.OK)
@@ -111,7 +129,7 @@ export class AiController {
// ---------------------------------------------------------------------------
@Post('suggest')
@UseGuards(JwtAuthGuard, RbacGuard)
@UseGuards(JwtAuthGuard, AiEnabledGuard, RbacGuard)
@ApiBearerAuth()
@RequirePermission('ai.suggest')
@HttpCode(HttpStatus.ACCEPTED)
@@ -154,7 +172,7 @@ export class AiController {
}
@Post('extract')
@UseGuards(JwtAuthGuard, RbacGuard)
@UseGuards(JwtAuthGuard, AiEnabledGuard, RbacGuard)
@ApiBearerAuth()
@RequirePermission('ai.extract')
@Throttle({ default: { limit: 5, ttl: 60000 } }) // Rate limit: 5 requests/minute (ADR-020)
@@ -171,6 +189,168 @@ export class AiController {
return this.aiService.extractRealtime(dto, user.user_id);
}
@Get('status')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({
summary: 'AI Status — อ่านสถานะเปิด/ปิด AI features สำหรับผู้ใช้ที่ล็อกอิน',
})
async getAiStatus(): Promise<{ aiFeaturesEnabled: boolean }> {
const aiFeaturesEnabled =
await this.aiSettingsService.getAiFeaturesEnabled();
return { aiFeaturesEnabled };
}
// --- AI Admin Console Settings (ADR-027) ---
@Get('admin/settings')
@UseGuards(JwtAuthGuard, RbacGuard)
@ApiBearerAuth()
@RequirePermission('system.manage_all')
@ApiOperation({
summary: 'AI Admin Settings — อ่านสถานะเปิด/ปิด AI features',
})
async getAiAdminSettings(): Promise<{ aiFeaturesEnabled: boolean }> {
const aiFeaturesEnabled =
await this.aiSettingsService.getAiFeaturesEnabled();
return { aiFeaturesEnabled };
}
@Post('admin/toggle')
@UseGuards(JwtAuthGuard, RbacGuard)
@ApiBearerAuth()
@RequirePermission('system.manage_all')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'AI Admin Toggle — เปิด/ปิด AI features สำหรับผู้ใช้ทั่วไป',
})
async toggleAiFeatures(
@Body() dto: ToggleAiFeaturesDto,
@CurrentUser() user: User
): Promise<{ aiFeaturesEnabled: boolean }> {
const aiFeaturesEnabled = await this.aiSettingsService.setAiFeaturesEnabled(
dto.enabled,
user.user_id
);
return { aiFeaturesEnabled };
}
@Get('admin/health')
@UseGuards(JwtAuthGuard, RbacGuard)
@ApiBearerAuth()
@RequirePermission('system.manage_all')
@ApiOperation({
summary:
'AI System Health — ดึงสถานะสุขภาพ Ollama, Qdrant และ BullMQ queues',
})
async getAiSystemHealth() {
return this.aiService.getSystemHealth();
}
@Post('admin/sandbox/rag')
@UseGuards(JwtAuthGuard, AiEnabledGuard, RbacGuard)
@ApiBearerAuth()
@RequirePermission('system.manage_all')
@HttpCode(HttpStatus.ACCEPTED)
@ApiOperation({
summary:
'AI Admin Sandbox RAG Query — ส่ง sandbox RAG เข้า queue ai-batch (T035)',
description:
'รัน RAG query สำหรับ Superadmin ใน sandbox environment เพื่อคุมทรัพยากร',
})
async submitSandboxRagQuery(
@Body() dto: AiRagQueryDto,
@CurrentUser() user: User
): Promise<{ requestPublicId: string; jobId: string; status: string }> {
const userPublicId = String(user.publicId ?? user.user_id);
const activeJob = await this.aiRagService.getActiveJob(userPublicId);
if (activeJob) {
return { requestPublicId: activeJob, jobId: activeJob, status: 'queued' };
}
const requestPublicId = uuidv7();
await this.aiRagService.registerActiveJob(userPublicId, requestPublicId);
const jobId = await this.aiQueueService.enqueueSandboxJob('sandbox-rag', {
idempotencyKey: requestPublicId,
projectPublicId: dto.projectPublicId,
query: dto.question,
userPublicId,
});
return { requestPublicId, jobId, status: 'queued' };
}
@Get('admin/sandbox/job/:id')
@UseGuards(JwtAuthGuard, RbacGuard)
@ApiBearerAuth()
@RequirePermission('system.manage_all')
@ApiOperation({
summary:
'AI Admin Sandbox Job Status — ตรวจสอบสถานะ RAG sandbox job (T036)',
})
@ApiParam({
name: 'id',
description: 'requestPublicId (UUID) ของ sandbox job ที่ส่งคำขอ',
})
async getSandboxJobStatus(@Param('id', ParseUuidPipe) id: string) {
const result = await this.aiRagService.getJobResult(id);
if (!result) {
return { requestPublicId: id, status: 'not_found' };
}
return result;
}
@Post('admin/sandbox/extract')
@UseGuards(JwtAuthGuard, AiEnabledGuard, RbacGuard)
@ApiBearerAuth()
@RequirePermission('system.manage_all')
@UseInterceptors(FileInterceptor('file'))
@HttpCode(HttpStatus.ACCEPTED)
@ApiOperation({
summary:
'AI Admin Sandbox OCR Extract — อัปโหลดไฟล์เพื่อทำ OCR Sandbox (T041 & T042)',
description:
'รัน OCR Sandbox สำหรับ Superadmin โดยคิว batchQueue ควบคุมอัตราการใช้งาน',
})
async submitSandboxExtract(
@UploadedFile(
new ParseFilePipe({
validators: [
new MaxFileSizeValidator({ maxSize: 50 * 1024 * 1024 }),
new FileTypeValidator({ fileType: 'pdf' }),
],
})
)
file: Express.Multer.File,
@CurrentUser() user: User
): Promise<{ requestPublicId: string; jobId: string; status: string }> {
const queueSize = await this.aiQueueService.getBatchQueueSize();
if (queueSize >= 3) {
const rateKey = `ai:sandbox:rate:${String(user.user_id)}`;
const countStr = await this.redis.get(rateKey);
const count = countStr ? Number(countStr) : 0;
if (count >= 10) {
throw new HttpException(
'Rate limit exceeded. Capped at 10 requests per hour when the queue is busy.',
HttpStatus.TOO_MANY_REQUESTS
);
}
if (!countStr) {
await this.redis.setex(rateKey, 3600, '1');
} else {
await this.redis.incr(rateKey);
}
}
const attachment = await this.fileStorageService.upload(file, user.user_id);
const requestPublicId = uuidv7();
const jobId = await this.aiQueueService.enqueueSandboxJob(
'sandbox-extract',
{
idempotencyKey: requestPublicId,
pdfPath: attachment.filePath,
}
);
return { requestPublicId, jobId, status: 'queued' };
}
// --- Webhook Callback จาก n8n (Service Account) ---
@Post('callback')
@@ -324,7 +504,7 @@ export class AiController {
// ─── RAG Query Endpoints (Phase 4 — FR-009, FR-010, FR-011) ────────────────
@Post('rag/query')
@UseGuards(JwtAuthGuard, RbacGuard)
@UseGuards(JwtAuthGuard, AiEnabledGuard, RbacGuard)
@ApiBearerAuth()
@Throttle({ default: { limit: 5, ttl: 60000 } }) // Rate limit: 5 requests/minute per user (FR-010)
@RequirePermission('rag.query')
+8
View File
@@ -4,6 +4,7 @@
// - 2026-05-15: เพิ่ม ai-realtime/ai-batch foundation และ stale paused recovery ตาม ADR-023A.
// - 2026-05-19: เพิ่ม IntentClassifierModule (ADR-024 Intent Classification).
// - 2026-05-19: เพิ่ม AiToolModule (ADR-025 AI Tool Layer).
// - 2026-05-21: ลงทะเบียน SystemSetting, AiSettingsService และ AiEnabledGuard สำหรับ ADR-027.
// Module สำหรับ AI Gateway — ลงทะเบียน Services และ Controllers (ADR-023)
import { Logger, Module, OnModuleInit } from '@nestjs/common';
@@ -15,6 +16,7 @@ import { RedisModule } from '@nestjs-modules/ioredis';
import { Queue } from 'bullmq';
import { AiController } from './ai.controller';
import { AiService } from './ai.service';
import { AiSettingsService } from './ai-settings.service';
import { AiIngestService } from './ai-ingest.service';
import { AiQueueService } from './ai-queue.service';
import { AiQdrantService } from './qdrant.service';
@@ -30,6 +32,8 @@ 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 { SystemSetting } from './entities/system-setting.entity';
import { AiEnabledGuard } from './guards/ai-enabled.guard';
import { UserModule } from '../user/user.module';
import { MigrationModule } from '../migration/migration.module';
import { FileStorageModule } from '../../common/file-storage/file-storage.module';
@@ -58,6 +62,7 @@ import {
AiAuditLog,
AuditLog,
MigrationReviewRecord,
SystemSetting,
Attachment,
Project,
Organization,
@@ -114,6 +119,7 @@ import {
controllers: [AiController],
providers: [
AiService,
AiSettingsService,
AiIngestService,
AiQueueService,
AiQdrantService,
@@ -130,9 +136,11 @@ import {
AiVectorDeletionProcessor,
// RbacGuard ต้องการ UserService จาก UserModule
RbacGuard,
AiEnabledGuard,
],
exports: [
AiService,
AiSettingsService,
AiIngestService,
AiQueueService,
AiQdrantService,
+97 -1
View File
@@ -1,5 +1,7 @@
// File: src/modules/ai/ai.service.spec.ts
// Unit Tests สำหรับ AiService — ทดสอบ Business Logic สำคัญ: Callback, Update, Status Transitions
// Change Log
// - 2026-05-21: เพิ่ม unit tests สำหรับ getSystemHealth (T026) ทั้งกรณี cache hit/miss และ queue metrics.
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
@@ -21,6 +23,10 @@ import {
QUEUE_AI_BATCH,
QUEUE_AI_REALTIME,
} from '../common/constants/queue.constants';
import { OllamaService } from './services/ollama.service';
import { AiQdrantService } from './qdrant.service';
const DEFAULT_REDIS_TOKEN = 'default_IORedisModuleConnectionToken';
describe('AiService', () => {
let service: AiService;
@@ -52,11 +58,35 @@ describe('AiService', () => {
const mockQueue = {
add: jest.fn(),
isPaused: jest.fn().mockResolvedValue(false),
getActiveCount: jest.fn().mockResolvedValue(0),
getActiveCount: jest.fn().mockResolvedValue(1),
getWaitingCount: jest.fn().mockResolvedValue(2),
getFailedCount: jest.fn().mockResolvedValue(3),
getCompletedCount: jest.fn().mockResolvedValue(4),
resume: jest.fn(),
getState: jest.fn().mockResolvedValue('completed'),
};
const mockOllamaService = {
checkHealth: jest.fn().mockResolvedValue({
status: 'HEALTHY',
latencyMs: 120,
models: ['gemma4:e4b', 'nomic-embed-text'],
}),
};
const mockQdrantService = {
checkHealth: jest.fn().mockResolvedValue({
status: 'HEALTHY',
latencyMs: 45,
collections: ['lcbp3_vectors'],
}),
};
const mockRedis = {
get: jest.fn(),
set: jest.fn(),
};
// Mock ConfigService — คืนค่า Config ตาม Key
const mockConfigService = {
get: jest.fn((key: string) => {
@@ -119,6 +149,9 @@ describe('AiService', () => {
{ provide: ConfigService, useValue: mockConfigService },
{ provide: HttpService, useValue: mockHttpService },
{ provide: AiValidationService, useValue: mockValidationService },
{ provide: OllamaService, useValue: mockOllamaService },
{ provide: AiQdrantService, useValue: mockQdrantService },
{ provide: DEFAULT_REDIS_TOKEN, useValue: mockRedis },
],
}).compile();
@@ -321,4 +354,67 @@ describe('AiService', () => {
expect(result).toHaveProperty('totalPages');
});
});
// --- getSystemHealth ---
describe('getSystemHealth', () => {
it('ควรอ่านข้อมูลสุขภาพจาก Redis cache หากมีข้อมูลอยู่แล้ว (Cache Hit)', async () => {
const mockCachedData = {
ollama: { status: 'HEALTHY', latencyMs: 50, models: ['model1'] },
qdrant: { status: 'HEALTHY', latencyMs: 20, collections: ['col1'] },
queues: {
realtime: {
active: 1,
waiting: 2,
failed: 3,
completed: 4,
isPaused: false,
},
batch: {
active: 1,
waiting: 2,
failed: 3,
completed: 4,
isPaused: false,
},
},
timestamp: '2026-05-21T12:00:00.000Z',
};
mockRedis.get.mockResolvedValue(JSON.stringify(mockCachedData));
const result = await service.getSystemHealth();
expect(result).toEqual(mockCachedData);
expect(mockRedis.get).toHaveBeenCalledWith('system_health:cache');
expect(mockOllamaService.checkHealth).not.toHaveBeenCalled();
});
it('ควรดึงข้อมูลจาก Service และบันทึกลง Redis cache เมื่อไม่มีข้อมูลใน cache (Cache Miss)', async () => {
mockRedis.get.mockResolvedValue(null);
mockOllamaService.checkHealth.mockResolvedValue({
status: 'HEALTHY',
latencyMs: 120,
models: ['gemma4:e4b', 'nomic-embed-text'],
});
mockQdrantService.checkHealth.mockResolvedValue({
status: 'HEALTHY',
latencyMs: 45,
collections: ['lcbp3_vectors'],
});
const result = await service.getSystemHealth();
expect(result.ollama.status).toBe('HEALTHY');
expect(result.qdrant.status).toBe('HEALTHY');
expect(result.queues.realtime).toEqual({
active: 1,
waiting: 2,
failed: 3,
completed: 4,
isPaused: false,
});
expect(mockRedis.set).toHaveBeenCalledWith(
'system_health:cache',
expect.any(String),
'EX',
30
);
});
});
});
+121 -2
View File
@@ -1,11 +1,15 @@
// File: src/modules/ai/ai.service.ts
// Service หลักของ AI Gateway — เชื่อมต่อระหว่าง DMS กับ n8n/Ollama Pipeline (ADR-018, ADR-020)
// Change Log
// - 2026-05-21: เพิ่ม getSystemHealth พร้อมระบบแคช Redis 30 วินาทีตาม ADR-027.
// - 2026-05-21: แก้ไข ESLint unsafe return error ใน getSystemHealth โดยใช้ interface SystemHealthResponse
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 { InjectRedis } from '@nestjs-modules/ioredis';
import type Redis from 'ioredis';
import { Repository } from 'typeorm';
import { Job, Queue } from 'bullmq';
import { firstValueFrom, timeout, catchError } from 'rxjs';
@@ -35,6 +39,8 @@ import {
import { AiRealtimeJobData } from './processors/ai-realtime.processor';
import { AiBatchJobData } from './processors/ai-batch.processor';
import { AuditLog } from '../../common/entities/audit-log.entity';
import { OllamaService } from './services/ollama.service';
import { AiQdrantService } from './qdrant.service';
// ผลลัพธ์ของ Real-time Extraction
export interface ExtractionResult {
@@ -97,6 +103,42 @@ export interface AiJobStatusResult {
failedReason?: string;
}
export interface SystemHealthResponse {
ollama: {
status: string;
latencyMs: number;
models: string[];
error?: string;
};
qdrant: {
status: string;
latencyMs: number;
collections?: string[];
error?: string;
};
queues: {
realtime:
| {
active: number;
waiting: number;
failed: number;
completed: number;
isPaused: boolean;
}
| { error: string };
batch:
| {
active: number;
waiting: number;
failed: number;
completed: number;
isPaused: boolean;
}
| { error: string };
};
timestamp: string;
}
@Injectable()
export class AiService {
private readonly logger = new Logger(AiService.name);
@@ -122,7 +164,14 @@ export class AiService {
private readonly aiRealtimeQueue?: Queue<AiRealtimeJobData>,
@Optional()
@InjectQueue(QUEUE_AI_BATCH)
private readonly aiBatchQueue?: Queue<AiBatchJobData>
private readonly aiBatchQueue?: Queue<AiBatchJobData>,
@Optional()
private readonly ollamaService?: OllamaService,
@Optional()
private readonly qdrantService?: AiQdrantService,
@Optional()
@InjectRedis()
private readonly redis?: Redis
) {
this.n8nWebhookUrl =
this.configService.get<string>('AI_N8N_WEBHOOK_URL') ?? '';
@@ -678,6 +727,76 @@ export class AiService {
return { deleted: true, publicId };
}
/** ดึงสุขภาพของโครงสร้างพื้นฐานระบบ AI (Ollama, Qdrant, queues) */
async getSystemHealth(): Promise<SystemHealthResponse> {
const cacheKey = 'system_health:cache';
if (this.redis) {
try {
const cached = await this.redis.get(cacheKey);
if (cached) return JSON.parse(cached) as SystemHealthResponse;
} catch (err: unknown) {
this.logger.warn(
`Failed to read system health cache: ${err instanceof Error ? err.message : String(err)}`
);
}
}
const [ollama, qdrant, realtimeQueueMetrics, batchQueueMetrics] =
await Promise.all([
this.ollamaService
? this.ollamaService.checkHealth()
: Promise.resolve({
status: 'DOWN',
latencyMs: 0,
models: [],
error: 'OllamaService not injected',
}),
this.qdrantService
? this.qdrantService.checkHealth()
: Promise.resolve({
status: 'DOWN',
latencyMs: 0,
error: 'AiQdrantService not injected',
}),
this.getQueueMetrics(this.aiRealtimeQueue),
this.getQueueMetrics(this.aiBatchQueue),
]);
const health = {
ollama,
qdrant,
queues: {
realtime: realtimeQueueMetrics,
batch: batchQueueMetrics,
},
timestamp: new Date().toISOString(),
};
if (this.redis) {
try {
await this.redis.set(cacheKey, JSON.stringify(health), 'EX', 30);
} catch (err: unknown) {
this.logger.warn(
`Failed to write system health cache: ${err instanceof Error ? err.message : String(err)}`
);
}
}
return health;
}
private async getQueueMetrics(queue?: Queue) {
if (!queue) return { error: 'Queue not registered' };
try {
const [active, waiting, failed, completed, isPaused] = await Promise.all([
queue.getActiveCount(),
queue.getWaitingCount(),
queue.getFailedCount(),
queue.getCompletedCount(),
queue.isPaused(),
]);
return { active, waiting, failed, completed, isPaused };
} catch (err: unknown) {
return { error: err instanceof Error ? err.message : String(err) };
}
}
private async toJobStatus(
jobId: string,
queue: 'ai-realtime' | 'ai-batch',
@@ -0,0 +1,13 @@
// File: src/modules/ai/dto/ai-admin-settings.dto.ts
// Change Log
// - 2026-05-21: เพิ่ม DTO สำหรับ AI Admin toggle endpoint.
import { ApiProperty } from '@nestjs/swagger';
import { IsBoolean } from 'class-validator';
/** DTO สำหรับสลับสถานะเปิด/ปิด AI features ทั้งระบบ */
export class ToggleAiFeaturesDto {
@ApiProperty({ description: 'สถานะเปิด/ปิด AI features สำหรับผู้ใช้ทั่วไป' })
@IsBoolean()
enabled!: boolean;
}
@@ -0,0 +1,58 @@
// File: src/modules/ai/entities/system-setting.entity.ts
// Change Log
// - 2026-05-21: สร้าง Entity SystemSetting สำหรับ AI Admin Console settings.
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
export type SystemSettingDataType = 'string' | 'number' | 'boolean' | 'json';
/** Entity สำหรับเก็บค่าตั้งค่าระบบแบบไดนามิก */
@Entity('system_settings')
export class SystemSetting {
@PrimaryGeneratedColumn()
id!: number;
@Column({ name: 'setting_key', unique: true, length: 100 })
settingKey!: string;
@Column({ name: 'setting_value', type: 'text' })
settingValue!: string;
@Column({
name: 'data_type',
type: 'enum',
enum: ['string', 'number', 'boolean', 'json'],
default: 'string',
})
dataType!: SystemSettingDataType;
@Column({ length: 50, nullable: true })
category?: string;
@Column({ name: 'is_encrypted', type: 'boolean', default: false })
isEncrypted!: boolean;
@Column({ name: 'validation_rules', type: 'json', nullable: true })
validationRules?: Record<string, unknown>;
@Column({ type: 'text', nullable: true })
description?: string;
@Column({ name: 'is_public', type: 'boolean', default: false })
isPublic!: boolean;
@Column({ name: 'updated_by', nullable: true })
updatedBy?: number;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date;
}
@@ -0,0 +1,56 @@
// File: src/modules/ai/guards/ai-enabled.guard.spec.ts
// Change Log
// - 2026-05-21: เพิ่ม unit tests สำหรับ AiEnabledGuard soft-block behavior.
import { ExecutionContext } from '@nestjs/common';
import { AiEnabledGuard } from './ai-enabled.guard';
import { AiSettingsService } from '../ai-settings.service';
import { UserService } from '../../user/user.service';
import { ServiceUnavailableException } from '../../../common/exceptions';
import { User } from '../../user/entities/user.entity';
describe('AiEnabledGuard', () => {
const mockSettingsService = {
getAiFeaturesEnabled: jest.fn(),
} as unknown as jest.Mocked<Pick<AiSettingsService, 'getAiFeaturesEnabled'>>;
const mockUserService = {
getUserPermissions: jest.fn(),
} as unknown as jest.Mocked<Pick<UserService, 'getUserPermissions'>>;
const guard = new AiEnabledGuard(
mockSettingsService as unknown as AiSettingsService,
mockUserService as unknown as UserService
);
const createContext = (user?: Partial<User>): ExecutionContext =>
({
switchToHttp: () => ({
getRequest: () => ({ user }),
}),
}) as ExecutionContext;
beforeEach(() => {
jest.clearAllMocks();
});
it('ควร allow เมื่อ AI features เปิดอยู่', async () => {
mockSettingsService.getAiFeaturesEnabled.mockResolvedValue(true);
await expect(
guard.canActivate(createContext({ user_id: 3 }))
).resolves.toBe(true);
expect(mockUserService.getUserPermissions).not.toHaveBeenCalled();
});
it('ควร block regular user ด้วย HTTP 503 เมื่อ AI features ปิด', async () => {
mockSettingsService.getAiFeaturesEnabled.mockResolvedValue(false);
mockUserService.getUserPermissions.mockResolvedValue(['ai.suggest']);
await expect(
guard.canActivate(createContext({ user_id: 3 }))
).rejects.toBeInstanceOf(ServiceUnavailableException);
});
it('ควร allow superadmin ที่มีสิทธิ์ AI เมื่อ AI features ปิด', async () => {
mockSettingsService.getAiFeaturesEnabled.mockResolvedValue(false);
mockUserService.getUserPermissions.mockResolvedValue([
'system.manage_all',
'ai.suggest',
]);
await expect(
guard.canActivate(createContext({ user_id: 1 }))
).resolves.toBe(true);
});
});
@@ -0,0 +1,47 @@
// File: src/modules/ai/guards/ai-enabled.guard.ts
// Change Log
// - 2026-05-21: เพิ่ม Guard สำหรับ soft-block AI endpoints เมื่อระบบ AI ถูกปิด.
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { ServiceUnavailableException } from '../../../common/exceptions';
import { User } from '../../user/entities/user.entity';
import { UserService } from '../../user/user.service';
import { AiSettingsService } from '../ai-settings.service';
const AI_BYPASS_PERMISSIONS = [
'ai.suggest',
'ai.rag_query',
'rag.query',
'ai.extract',
];
/** Guard สำหรับบล็อก AI endpoints ของผู้ใช้ทั่วไปเมื่อ Superadmin ปิด AI */
@Injectable()
export class AiEnabledGuard implements CanActivate {
constructor(
private readonly aiSettingsService: AiSettingsService,
private readonly userService: UserService
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const enabled = await this.aiSettingsService.getAiFeaturesEnabled();
if (enabled) return true;
const request = context.switchToHttp().getRequest<{ user?: User }>();
const user = request.user;
const userId = user?.user_id;
if (userId) {
const permissions = await this.userService.getUserPermissions(userId);
const isSuperadmin = permissions.includes('system.manage_all');
const hasAiPermission = AI_BYPASS_PERMISSIONS.some((permission) =>
permissions.includes(permission)
);
if (isSuperadmin && hasAiPermission) return true;
}
throw new ServiceUnavailableException(
'AI_FEATURES_UNAVAILABLE',
'AI features are temporarily unavailable',
'ระบบ AI ไม่พร้อมใช้งานชั่วคราว กรุณากรอกข้อมูลด้วยตนเอง',
['กรอกข้อมูลด้วยตนเอง', 'ติดต่อผู้ดูแลระบบหากต้องการความช่วยเหลือ']
);
}
}
@@ -0,0 +1,151 @@
// File: src/modules/ai/processors/ai-batch.processor.spec.ts
// Change Log
// - 2026-05-21: สร้าง Unit Test สำหรับ AiBatchProcessor ครอบคลุม embed-document และ sandbox-rag (T032).
// - 2026-05-21: เพิ่มการทดสอบ sandbox-extract พร้อม mock OcrService, OllamaService และ Redis (T039).
// - 2026-05-21: แก้ไข ESLint unexpected any และ unsafe member access โดยกำหนด type ให้ redis เป็น Record<string, jest.Mock>
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Job } from 'bullmq';
import { AiBatchProcessor, AiBatchJobData } from './ai-batch.processor';
import { EmbeddingService } from '../services/embedding.service';
import { AiRagService } from '../ai-rag.service';
import { Attachment } from '../../../common/file-storage/entities/attachment.entity';
import { OcrService } from '../services/ocr.service';
import { OllamaService } from '../services/ollama.service';
describe('AiBatchProcessor', () => {
let processor: AiBatchProcessor;
let embeddingService: jest.Mocked<EmbeddingService>;
let ragService: jest.Mocked<AiRagService>;
let ocrService: jest.Mocked<OcrService>;
let ollamaService: jest.Mocked<OllamaService>;
let redis: Record<string, jest.Mock>;
let attachmentRepo: jest.Mocked<Repository<Attachment>>;
const DEFAULT_REDIS_TOKEN = 'default_IORedisModuleConnectionToken';
const mockEmbeddingService = {
embedDocument: jest
.fn()
.mockResolvedValue({ success: true, chunksEmbedded: 5 }),
};
const mockRagService = {
processQuery: jest.fn().mockResolvedValue(undefined),
};
const mockOcrService = {
detectAndExtract: jest
.fn()
.mockResolvedValue({ text: 'OCR text LCBP3-CIV-001 Civil' }),
};
const mockOllamaService = {
generate: jest.fn().mockResolvedValue(
JSON.stringify({
documentNumber: 'LCBP3-CIV-001',
subject: 'Foundation Inspection Report',
discipline: 'Civil',
date: '2026-05-20',
confidence: 0.95,
})
),
};
const mockRedis = {
setex: jest.fn().mockResolvedValue('OK'),
};
const mockAttachmentRepo = {
update: jest.fn().mockResolvedValue({ affected: 1 }),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AiBatchProcessor,
{ provide: EmbeddingService, useValue: mockEmbeddingService },
{ provide: AiRagService, useValue: mockRagService },
{ provide: OcrService, useValue: mockOcrService },
{ provide: OllamaService, useValue: mockOllamaService },
{ provide: DEFAULT_REDIS_TOKEN, useValue: mockRedis },
{
provide: getRepositoryToken(Attachment),
useValue: mockAttachmentRepo,
},
],
}).compile();
processor = module.get<AiBatchProcessor>(AiBatchProcessor);
embeddingService = module.get(EmbeddingService);
ragService = module.get(AiRagService);
ocrService = module.get(OcrService);
ollamaService = module.get(OllamaService);
redis = module.get(DEFAULT_REDIS_TOKEN);
attachmentRepo = module.get(getRepositoryToken(Attachment));
jest.clearAllMocks();
});
it('ควรสามารถเรียก process embed-document และอัปเดตสถานะใน database', async () => {
const job = {
id: 'job-embed',
data: {
jobType: 'embed-document',
documentPublicId: 'doc-uuid-123',
projectPublicId: 'proj-uuid-456',
payload: { pdfPath: '/files/test.pdf' },
idempotencyKey: 'idem-123',
},
} as unknown as Job<AiBatchJobData>;
await processor.process(job);
expect(embeddingService.embedDocument).toHaveBeenCalledTimes(1);
expect(attachmentRepo.update).toHaveBeenCalledWith(
{ publicId: 'doc-uuid-123' },
{ aiProcessingStatus: 'PROCESSING' }
);
expect(attachmentRepo.update).toHaveBeenCalledWith(
{ publicId: 'doc-uuid-123' },
{ aiProcessingStatus: 'DONE' }
);
});
it('ควรประมวลผล sandbox-rag โดยการเรียก ragService.processQuery และข้ามการอัปเดต database', async () => {
const job = {
id: 'job-sandbox',
data: {
jobType: 'sandbox-rag',
documentPublicId: 'idem-sandbox-123',
projectPublicId: 'proj-uuid-456',
payload: {
query: 'ทดสอบคำถาม sandbox RAG',
userPublicId: 'user-uuid-789',
},
idempotencyKey: 'idem-sandbox-123',
},
} as unknown as Job<AiBatchJobData>;
await processor.process(job);
expect(ragService.processQuery).toHaveBeenCalledTimes(1);
expect(ragService.processQuery).toHaveBeenCalledWith(
'idem-sandbox-123',
'ทดสอบคำถาม sandbox RAG',
'proj-uuid-456',
'user-uuid-789',
expect.any(AbortSignal)
);
expect(attachmentRepo.update).not.toHaveBeenCalled();
});
it('ควรประมวลผล sandbox-extract โดยใช้ OcrService, OllamaService และเก็บค่าลง Redis', async () => {
const job = {
id: 'job-extract',
data: {
jobType: 'sandbox-extract',
documentPublicId: 'idem-extract-123',
projectPublicId: 'proj-uuid-456',
payload: { pdfPath: '/files/test.pdf' },
idempotencyKey: 'idem-extract-123',
},
} as unknown as Job<AiBatchJobData>;
await processor.process(job);
expect(ocrService.detectAndExtract).toHaveBeenCalledWith({
pdfPath: '/files/test.pdf',
});
expect(ollamaService.generate).toHaveBeenCalledTimes(1);
expect(redis.setex).toHaveBeenCalledTimes(2);
expect(redis.setex).toHaveBeenLastCalledWith(
'ai:rag:result:idem-extract-123',
3600,
expect.stringContaining('completed')
);
});
});
@@ -2,17 +2,30 @@
// Change Log
// - 2026-05-15: เพิ่ม processor สำหรับ ai-batch queue ตาม ADR-023A.
// - 2026-05-15: เพิ่ม EmbeddingService สำหรับ embed-document logic (T022).
// - 2026-05-21: เพิ่มการรองรับ sandbox-rag และ sandbox-extract สำหรับ Superadmin sandbox.
// - 2026-05-21: พัฒนาระบบประมวลผล sandbox-extract พร้อมเชื่อมต่อ OcrService, OllamaService และ Redis cache
// - 2026-05-21: แก้ไข ESLint unused variable สำหรับ parseError ใน catch block
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 { InjectRedis } from '@nestjs-modules/ioredis';
import Redis from 'ioredis';
import { Attachment } from '../../../common/file-storage/entities/attachment.entity';
import { QUEUE_AI_BATCH } from '../../common/constants/queue.constants';
import { EmbeddingService } from '../services/embedding.service';
import { AiRagService } from '../ai-rag.service';
import { OcrService } from '../services/ocr.service';
import { OllamaService } from '../services/ollama.service';
export type AiBatchJobType = 'ocr' | 'extract-metadata' | 'embed-document';
export type AiBatchJobType =
| 'ocr'
| 'extract-metadata'
| 'embed-document'
| 'sandbox-rag'
| 'sandbox-extract';
export interface AiBatchJobData {
jobType: AiBatchJobType;
@@ -27,36 +40,62 @@ export interface AiBatchJobData {
@Processor(QUEUE_AI_BATCH, { concurrency: 1 })
export class AiBatchProcessor extends WorkerHost {
private readonly logger = new Logger(AiBatchProcessor.name);
private readonly abortControllers = new Map<string, AbortController>();
constructor(
@InjectRepository(Attachment)
private readonly attachmentRepo: Repository<Attachment>,
private readonly embeddingService: EmbeddingService
private readonly embeddingService: EmbeddingService,
private readonly ragService: AiRagService,
private readonly ocrService: OcrService,
private readonly ollamaService: OllamaService,
@InjectRedis() private readonly redis: Redis
) {
super();
}
/** Dispatch งาน batch ตาม jobType */
async process(job: Job<AiBatchJobData>): Promise<void> {
await this.setAiProcessingStatus(job.data.documentPublicId, 'PROCESSING');
const isSandbox =
job.data.jobType === 'sandbox-rag' ||
job.data.jobType === 'sandbox-extract';
if (!isSandbox) {
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');
if (!isSandbox) {
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');
if (!isSandbox) {
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');
if (!isSandbox) {
await this.setAiProcessingStatus(job.data.documentPublicId, 'DONE');
}
return;
case 'sandbox-rag':
this.logger.log(
`Sandbox RAG job processing — jobId=${String(job.id)}`
);
await this.processSandboxRag(job.data);
return;
case 'sandbox-extract':
this.logger.log(
`Sandbox Extract job processing — jobId=${String(job.id)}`
);
await this.processSandboxExtract(job.data);
return;
default: {
const unreachable: never = job.data.jobType;
@@ -70,7 +109,9 @@ export class AiBatchProcessor extends WorkerHost {
`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');
if (!isSandbox) {
await this.setAiProcessingStatus(job.data.documentPublicId, 'FAILED');
}
throw err;
}
}
@@ -80,27 +121,43 @@ export class AiBatchProcessor extends WorkerHost {
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`
);
}
/** ประมวลผล sandbox RAG query */
private async processSandboxRag(data: AiBatchJobData): Promise<void> {
const { projectPublicId, idempotencyKey, payload } = data;
const query = payload.query as string;
const userPublicId = payload.userPublicId as string;
const controller = new AbortController();
this.abortControllers.set(idempotencyKey, controller);
try {
await this.ragService.processQuery(
idempotencyKey,
query,
projectPublicId,
userPublicId,
controller.signal
);
} finally {
this.abortControllers.delete(idempotencyKey);
}
}
private async setAiProcessingStatus(
documentPublicId: string,
status: 'PENDING' | 'PROCESSING' | 'DONE' | 'FAILED'
@@ -110,4 +167,85 @@ export class AiBatchProcessor extends WorkerHost {
{ aiProcessingStatus: status }
);
}
/** ประมวลผล sandbox OCR + Metadata extraction โดยไม่บันทึกลง database */
private async processSandboxExtract(data: AiBatchJobData): Promise<void> {
const { idempotencyKey, payload } = data;
const pdfPath = payload.pdfPath as string;
if (!pdfPath) {
throw new Error('pdfPath is required for sandbox-extract job');
}
await this.redis.setex(
`ai:rag:result:${idempotencyKey}`,
3600,
JSON.stringify({
requestPublicId: idempotencyKey,
status: 'processing',
})
);
try {
const ocrResult = await this.ocrService.detectAndExtract({ pdfPath });
const prompt = `You are an expert document extraction system.
Analyze the following OCR text extracted from a project document and extract the metadata fields.
OCR TEXT:
${ocrResult.text}
Extract these fields:
1. documentNumber: The official document number or code. If not found, return null.
2. subject: The main subject, title, or topic of the document. If not found, return null.
3. discipline: Must be exactly one of: "Civil", "Mechanical", "Electrical", "Architectural", or null if not specified.
4. date: The issue date in YYYY-MM-DD format. If not found, return null.
5. confidence: A float between 0.0 and 1.0 indicating your confidence in this extraction.
Return ONLY a valid JSON object matching this schema. Do NOT include markdown code blocks, HTML, or any conversational text. Example:
{
"documentNumber": "LCBP3-CIV-001",
"subject": "Foundation Inspection Report",
"discipline": "Civil",
"date": "2026-05-20",
"confidence": 0.95
}`;
const response = await this.ollamaService.generate(prompt);
const cleanedResponse = response
.replace(/```json/g, '')
.replace(/```/g, '')
.trim();
let extractedMetadata: Record<string, unknown>;
try {
extractedMetadata = JSON.parse(cleanedResponse) as Record<
string,
unknown
>;
} catch {
throw new Error(
`Failed to parse LLM response as JSON: ${cleanedResponse}`
);
}
await this.redis.setex(
`ai:rag:result:${idempotencyKey}`,
3600,
JSON.stringify({
requestPublicId: idempotencyKey,
status: 'completed',
answer: JSON.stringify(extractedMetadata, null, 2),
completedAt: new Date().toISOString(),
})
);
} catch (err: unknown) {
const errMsg = err instanceof Error ? err.message : String(err);
this.logger.error(`Sandbox extract failed: ${errMsg}`);
await this.redis.setex(
`ai:rag:result:${idempotencyKey}`,
3600,
JSON.stringify({
requestPublicId: idempotencyKey,
status: 'failed',
errorMessage: errMsg,
completedAt: new Date().toISOString(),
})
);
throw err;
}
}
}
+35
View File
@@ -2,6 +2,8 @@
// Change Log
// - 2026-05-14: เพิ่ม Qdrant gateway สำหรับ AI Module พร้อม project payload filter.
// - 2026-05-14: เพิ่ม OnModuleInit เพื่อ auto-call ensureCollection() (💡 S2).
// - 2026-05-21: เพิ่ม checkHealth สำหรับตรวจสอบสุขภาพและความเร็วของ Qdrant
import {
Injectable,
Logger,
@@ -138,4 +140,37 @@ export class AiQdrantService implements OnModuleInit {
points: pointsWithProject,
});
}
/** ตรวจสอบสุขภาพและความเร็ว (Latency) ของ Qdrant */
async checkHealth(): Promise<{
status: 'HEALTHY' | 'DEGRADED' | 'DOWN';
latencyMs: number;
collections?: string[];
error?: string;
}> {
const startTime = Date.now();
try {
const collections = await Promise.race([
this.client.getCollections(),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Qdrant request timeout')), 5000)
),
]);
const latencyMs = Date.now() - startTime;
return {
status: 'HEALTHY',
latencyMs,
collections: collections.collections.map((c) => c.name),
};
} catch (err: unknown) {
const latencyMs = Date.now() - startTime;
const error = err instanceof Error ? err.message : String(err);
const isTimeout = err instanceof Error && error.includes('timeout');
return {
status: isTimeout ? 'DEGRADED' : 'DOWN',
latencyMs,
error,
};
}
}
}
@@ -1,6 +1,7 @@
// File: src/modules/ai/services/ollama.service.ts
// Change Log
// - 2026-05-15: เพิ่ม Ollama service สำหรับ ADR-023A 2-model stack.
// - 2026-05-21: เพิ่ม checkHealth สำหรับตรวจสอบสุขภาพและความเร็ว (Latency) ของ Ollama
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@@ -91,4 +92,37 @@ export class OllamaService {
getEmbeddingModelName(): string {
return this.embedModel;
}
/** ตรวจสอบสุขภาพและความเร็ว (Latency) ของระบบ Ollama */
async checkHealth(): Promise<{
status: 'HEALTHY' | 'DEGRADED' | 'DOWN';
latencyMs: number;
models: string[];
error?: string;
}> {
const startTime = Date.now();
try {
await axios.get(`${this.ollamaUrl}/api/tags`, { timeout: 5000 });
const latencyMs = Date.now() - startTime;
return {
status: 'HEALTHY',
latencyMs,
models: [this.mainModel, this.embedModel],
};
} catch (err: unknown) {
const latencyMs = Date.now() - startTime;
const error = err instanceof Error ? err.message : String(err);
const isTimeout =
err instanceof Error &&
(err.message.includes('timeout') ||
err.message.includes('504') ||
err.message.includes('code ECONNABORTED'));
return {
status: isTimeout ? 'DEGRADED' : 'DOWN',
latencyMs,
models: [this.mainModel, this.embedModel],
error,
};
}
}
}