From 91e9c714df295092ed570238404f3ab6f1aebacd Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 21 May 2026 21:42:25 +0700 Subject: [PATCH] feat(ai-admin-console): complete implementation and resolve lint compilation errors --- backend/package.json | 3 +- backend/src/modules/ai/ai-queue.service.ts | 54 +- .../modules/ai/ai-settings.service.spec.ts | 87 ++ backend/src/modules/ai/ai-settings.service.ts | 108 +++ backend/src/modules/ai/ai.controller.ts | 192 ++++- backend/src/modules/ai/ai.module.ts | 8 + backend/src/modules/ai/ai.service.spec.ts | 98 ++- backend/src/modules/ai/ai.service.ts | 123 ++- .../modules/ai/dto/ai-admin-settings.dto.ts | 13 + .../ai/entities/system-setting.entity.ts | 58 ++ .../ai/guards/ai-enabled.guard.spec.ts | 56 ++ .../src/modules/ai/guards/ai-enabled.guard.ts | 47 ++ .../ai/processors/ai-batch.processor.spec.ts | 151 ++++ .../ai/processors/ai-batch.processor.ts | 164 +++- backend/src/modules/ai/qdrant.service.ts | 35 + .../src/modules/ai/services/ollama.service.ts | 34 + docs/deployment-setup-guide.md | 221 +++++ frontend/app/(admin)/admin/ai/page.tsx | 769 ++++++++++++++++++ frontend/app/layout.tsx | 2 + frontend/components/admin/sidebar.tsx | 3 +- frontend/components/ai/AiStatusBanner.tsx | 12 +- .../__tests__/ai-suggestion-button.test.tsx | 29 + .../components/ai/ai-status-banner-host.tsx | 28 + .../components/ai/ai-suggestion-button.tsx | 59 ++ .../components/correspondences/form.test.tsx | 11 +- frontend/components/correspondences/form.tsx | 72 +- frontend/components/rfas/form.tsx | 40 +- frontend/hooks/use-ai-status.ts | 89 ++ frontend/lib/api/client.ts | 21 +- frontend/lib/services/admin-ai.service.ts | 113 +++ frontend/public/locales/en/common.json | 1 + frontend/public/locales/th/common.json | 1 + .../lcbp3-v1.9.0-schema-02-tables.sql | 37 + ...27-ai-admin-console-and-dynamic-control.md | 181 +++++ specs/06-Decision-Records/CONTEXT-ADR-027.md | 252 ++++++ .../227-ai-admin-console/plan.md | 180 ++++ .../227-ai-admin-console/spec.md | 146 ++++ .../227-ai-admin-console/tasks.md | 229 ++++++ .../227-ai-admin-console/walkthrough.md | 69 ++ 39 files changed, 3724 insertions(+), 72 deletions(-) create mode 100644 backend/src/modules/ai/ai-settings.service.spec.ts create mode 100644 backend/src/modules/ai/ai-settings.service.ts create mode 100644 backend/src/modules/ai/dto/ai-admin-settings.dto.ts create mode 100644 backend/src/modules/ai/entities/system-setting.entity.ts create mode 100644 backend/src/modules/ai/guards/ai-enabled.guard.spec.ts create mode 100644 backend/src/modules/ai/guards/ai-enabled.guard.ts create mode 100644 backend/src/modules/ai/processors/ai-batch.processor.spec.ts create mode 100644 docs/deployment-setup-guide.md create mode 100644 frontend/app/(admin)/admin/ai/page.tsx create mode 100644 frontend/components/ai/__tests__/ai-suggestion-button.test.tsx create mode 100644 frontend/components/ai/ai-status-banner-host.tsx create mode 100644 frontend/components/ai/ai-suggestion-button.tsx create mode 100644 frontend/hooks/use-ai-status.ts create mode 100644 frontend/lib/services/admin-ai.service.ts create mode 100644 specs/06-Decision-Records/ADR-027-ai-admin-console-and-dynamic-control.md create mode 100644 specs/06-Decision-Records/CONTEXT-ADR-027.md create mode 100644 specs/200-fullstacks/227-ai-admin-console/plan.md create mode 100644 specs/200-fullstacks/227-ai-admin-console/spec.md create mode 100644 specs/200-fullstacks/227-ai-admin-console/tasks.md create mode 100644 specs/200-fullstacks/227-ai-admin-console/walkthrough.md diff --git a/backend/package.json b/backend/package.json index affc85b7..f15eb729 100644 --- a/backend/package.json +++ b/backend/package.json @@ -20,12 +20,13 @@ "start:prod": "node dist/main", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "lint:ci": "node --max-old-space-size=4096 node_modules/eslint/bin/eslint.js \"{src,apps,libs,test}/**/*.ts\"", - "test": "jest --config jest.config.js --forceExit", + "test": "jest --config jest.config.js --forceExit --testPathIgnorePatterns=tests/performance", "test:debug-handles": "jest --config jest.config.js --detectOpenHandles", "test:watch": "jest --config jest.config.js --watch", "test:cov": "jest --config jest.config.js --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --config jest.config.js --runInBand", "test:e2e": "jest --config ./test/jest-e2e.json --forceExit", + "test:perf": "jest --config jest.config.js --forceExit tests/performance", "seed": "ts-node -r tsconfig-paths/register src/database/seeds/run-seed.ts" }, "dependencies": { diff --git a/backend/src/modules/ai/ai-queue.service.ts b/backend/src/modules/ai/ai-queue.service.ts index ce66a4be..ad076691 100644 --- a/backend/src/modules/ai/ai-queue.service.ts +++ b/backend/src/modules/ai/ai-queue.service.ts @@ -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 เป็น Queue สำหรับ 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, @InjectQueue(QUEUE_AI_VECTOR_DELETION) - private readonly vectorDeletionQueue: Queue + private readonly vectorDeletionQueue: Queue, + @InjectQueue(QUEUE_AI_BATCH) + private readonly batchQueue: Queue ) {} /** @@ -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 { + 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 { + const active = await this.batchQueue.getActiveCount(); + const waiting = await this.batchQueue.getWaitingCount(); + return active + waiting; + } } diff --git a/backend/src/modules/ai/ai-settings.service.spec.ts b/backend/src/modules/ai/ai-settings.service.spec.ts new file mode 100644 index 00000000..5ef43dc7 --- /dev/null +++ b/backend/src/modules/ai/ai-settings.service.spec.ts @@ -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); + }); + + 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 + ) => 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' + ); + }); +}); diff --git a/backend/src/modules/ai/ai-settings.service.ts b/backend/src/modules/ai/ai-settings.service.ts new file mode 100644 index 00000000..c9a2edff --- /dev/null +++ b/backend/src/modules/ai/ai-settings.service.ts @@ -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, + @InjectRedis() private readonly redis: Redis + ) {} + + /** อ่านสถานะเปิด/ปิด AI features โดยใช้ Redis cache ก่อน DB */ + async getAiFeaturesEnabled(): Promise { + 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 { + await this.settingRepo.manager.transaction( + async (manager: EntityManager): Promise => { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/backend/src/modules/ai/ai.controller.ts b/backend/src/modules/ai/ai.controller.ts index c1f8de27..f3d7cce3 100644 --- a/backend/src/modules/ai/ai.controller.ts +++ b/backend/src/modules/ai/ai.controller.ts @@ -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') diff --git a/backend/src/modules/ai/ai.module.ts b/backend/src/modules/ai/ai.module.ts index 15d8a092..921e3630 100644 --- a/backend/src/modules/ai/ai.module.ts +++ b/backend/src/modules/ai/ai.module.ts @@ -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, diff --git a/backend/src/modules/ai/ai.service.spec.ts b/backend/src/modules/ai/ai.service.spec.ts index b94cc95a..a2839cee 100644 --- a/backend/src/modules/ai/ai.service.spec.ts +++ b/backend/src/modules/ai/ai.service.spec.ts @@ -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 + ); + }); + }); }); diff --git a/backend/src/modules/ai/ai.service.ts b/backend/src/modules/ai/ai.service.ts index 9bba95df..2d143455 100644 --- a/backend/src/modules/ai/ai.service.ts +++ b/backend/src/modules/ai/ai.service.ts @@ -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, @Optional() @InjectQueue(QUEUE_AI_BATCH) - private readonly aiBatchQueue?: Queue + private readonly aiBatchQueue?: Queue, + @Optional() + private readonly ollamaService?: OllamaService, + @Optional() + private readonly qdrantService?: AiQdrantService, + @Optional() + @InjectRedis() + private readonly redis?: Redis ) { this.n8nWebhookUrl = this.configService.get('AI_N8N_WEBHOOK_URL') ?? ''; @@ -678,6 +727,76 @@ export class AiService { return { deleted: true, publicId }; } + /** ดึงสุขภาพของโครงสร้างพื้นฐานระบบ AI (Ollama, Qdrant, queues) */ + async getSystemHealth(): Promise { + 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', diff --git a/backend/src/modules/ai/dto/ai-admin-settings.dto.ts b/backend/src/modules/ai/dto/ai-admin-settings.dto.ts new file mode 100644 index 00000000..66ececb2 --- /dev/null +++ b/backend/src/modules/ai/dto/ai-admin-settings.dto.ts @@ -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; +} diff --git a/backend/src/modules/ai/entities/system-setting.entity.ts b/backend/src/modules/ai/entities/system-setting.entity.ts new file mode 100644 index 00000000..6725026a --- /dev/null +++ b/backend/src/modules/ai/entities/system-setting.entity.ts @@ -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; + + @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; +} diff --git a/backend/src/modules/ai/guards/ai-enabled.guard.spec.ts b/backend/src/modules/ai/guards/ai-enabled.guard.spec.ts new file mode 100644 index 00000000..cc1fec83 --- /dev/null +++ b/backend/src/modules/ai/guards/ai-enabled.guard.spec.ts @@ -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>; + const mockUserService = { + getUserPermissions: jest.fn(), + } as unknown as jest.Mocked>; + const guard = new AiEnabledGuard( + mockSettingsService as unknown as AiSettingsService, + mockUserService as unknown as UserService + ); + const createContext = (user?: Partial): 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); + }); +}); diff --git a/backend/src/modules/ai/guards/ai-enabled.guard.ts b/backend/src/modules/ai/guards/ai-enabled.guard.ts new file mode 100644 index 00000000..45af8873 --- /dev/null +++ b/backend/src/modules/ai/guards/ai-enabled.guard.ts @@ -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 { + 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 ไม่พร้อมใช้งานชั่วคราว กรุณากรอกข้อมูลด้วยตนเอง', + ['กรอกข้อมูลด้วยตนเอง', 'ติดต่อผู้ดูแลระบบหากต้องการความช่วยเหลือ'] + ); + } +} diff --git a/backend/src/modules/ai/processors/ai-batch.processor.spec.ts b/backend/src/modules/ai/processors/ai-batch.processor.spec.ts new file mode 100644 index 00000000..33c9b08e --- /dev/null +++ b/backend/src/modules/ai/processors/ai-batch.processor.spec.ts @@ -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 + +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; + let ragService: jest.Mocked; + let ocrService: jest.Mocked; + let ollamaService: jest.Mocked; + let redis: Record; + let attachmentRepo: jest.Mocked>; + 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); + 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; + 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; + 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; + 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') + ); + }); +}); diff --git a/backend/src/modules/ai/processors/ai-batch.processor.ts b/backend/src/modules/ai/processors/ai-batch.processor.ts index 035e88b5..cf60dd22 100644 --- a/backend/src/modules/ai/processors/ai-batch.processor.ts +++ b/backend/src/modules/ai/processors/ai-batch.processor.ts @@ -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(); constructor( @InjectRepository(Attachment) private readonly attachmentRepo: Repository, - 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): Promise { - 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 { + 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 { + 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; + 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; + } + } } diff --git a/backend/src/modules/ai/qdrant.service.ts b/backend/src/modules/ai/qdrant.service.ts index 91f9fdc0..ab9a64bf 100644 --- a/backend/src/modules/ai/qdrant.service.ts +++ b/backend/src/modules/ai/qdrant.service.ts @@ -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((_, 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, + }; + } + } } diff --git a/backend/src/modules/ai/services/ollama.service.ts b/backend/src/modules/ai/services/ollama.service.ts index fa18404c..1ad642b7 100644 --- a/backend/src/modules/ai/services/ollama.service.ts +++ b/backend/src/modules/ai/services/ollama.service.ts @@ -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, + }; + } + } } diff --git a/docs/deployment-setup-guide.md b/docs/deployment-setup-guide.md new file mode 100644 index 00000000..2df18a39 --- /dev/null +++ b/docs/deployment-setup-guide.md @@ -0,0 +1,221 @@ +// File: docs/deployment-setup-guide.md +# คู่มือการตั้งค่าและการ Deploy ระบบ (Deployment Setup Guide) + +> **Project:** NAP-DMS (LCBP3) +> **Version:** 1.9.5 +> **Last Updated:** 2026-05-21 +> **Stack:** NestJS + Next.js + MariaDB + Redis + Elasticsearch + Qdrant +> **Target Platform:** QNAP TS-473A (Container Station) + ASUSTOR AS5403T (Gitea Runner) + +--- + +## 🏗️ 1. สถาปัตยกรรมระบบการ Deploy (Deployment Architecture) + +ระบบ DMS แยกการทำงานออกเป็นเซิร์ฟเวอร์หลัก 2 เครื่องเพื่อความปลอดภัยและประสิทธิภาพสูงสุด (Server Role Separation): +- **QNAP Server (TS-473A) - `192.168.10.8` (VLAN 10):** เป็นเซิร์ฟเวอร์หลักที่รันฐานข้อมูล, Cache, Search Engine และรัน Application Containers (Frontend + Backend) รวมถึง Git Server (Gitea) และ Nginx Proxy Manager (NPM) +- **ASUSTOR Server (AS5403T) - `192.168.10.9` (VLAN 10):** เป็นเซิร์ฟเวอร์สำหรับรัน CI/CD Gitea Runner (`act_runner`) เพื่อแยกโหลดการ Build โค้ดออกจากโปรดักชันเซิร์ฟเวอร์ + +``` +[ ASUSTOR Runner ] (192.168.10.9) + │ + │ SSH (Via Private Key) + ▼ +[ QNAP TS-473A ] (192.168.10.8) + ├── Git Pull & Build (BuildKit) + └── Restart Stack (docker compose --force-recreate) +``` + +--- + +## 🔐 2. การตั้งค่า SSH Key Authentication (Persistent SSH Setup) + +เนื่องจาก QNAP จะรีเซ็ต Directory `/` ไปเป็น RAM หลังการ Reboot ทำให้เกิดปัญหา SSH Key หาย เราจำเป็นต้องตั้งค่าให้เป็น Persistent SSH: + +### 2.1 บน ASUSTOR (Gitea Runner) +สร้าง SSH Key Pair และเก็บไว้ในโฟลเดอร์ถาวร: +- **Private Key:** `/etc/config/ssh/gitea-runner` +- **Public Key:** `/etc/config/ssh/gitea-runner.pub` + +### 2.2 บน QNAP (Target Server) +1. นำเนื้อหา Public Key ไปเพิ่มในไฟล์ `authorized_keys`: + ```bash + mkdir -p /etc/config/ssh + # เพิ่ม public key ลงไป (ต้องอยู่ภายในบรรทัดเดียว ห้ามเว้นวรรคผิดพลาด) + nano /etc/config/ssh/authorized_keys + ``` +2. แก้ไขไฟล์คอนฟิก SSH ของ QNAP (สำคัญมาก: ต้องแก้ไฟล์ที่ **`/etc/config/ssh/sshd_config`** เท่านั้น ไม่ใช่ `/etc/ssh/sshd_config`): + ```ini + # ตั้งค่า AuthorizedKeysFile ชี้ไปที่ absolute path ของโฟลเดอร์คอนฟิกถาวร + AuthorizedKeysFile /etc/config/ssh/authorized_keys + ``` + *หมายเหตุ: ห้ามใช้ relative path เช่น `.ssh/authorized_keys` เด็ดขาด เพราะระบบจะไปหาที่ `/share/homes/admin/.ssh/` แทนที่จะเป็น `/root/.ssh/`* + +3. Reload QNAP SSH daemon (เนื่องจาก QNAP ไม่มี `pgrep` และ `systemctl` ให้ใช้คำสั่งนี้): + ```bash + kill -HUP $(ps | grep "/usr/sbin/sshd -f /etc/config" | grep -v grep | awk '{print $1}') + ``` + +--- + +## 📁 3. การเตรียมโครงสร้างโฟลเดอร์บน QNAP + +ล็อกอินเข้า QNAP ผ่าน SSH และรันคำสั่งเตรียม Directory โครงสร้างพื้นฐาน: + +```bash +# 1. โฟลเดอร์หลักสำหรับ App Source และ Build Script +mkdir -p /share/np-dms/app/source + +# Clone repository (ครั้งแรกครั้งเดียว) +cd /share/np-dms/app/source +git clone https://git.np-dms.work/np-dms/lcbp3.git + +# 2. โฟลเดอร์สำหรับเก็บไฟล์อัปโหลดและ Logs +mkdir -p /share/np-dms/data/uploads/temp +mkdir -p /share/np-dms/data/uploads/permanent +mkdir -p /share/np-dms/data/logs/backend + +# 3. ตั้งค่าสิทธิ์โฟลเดอร์ (UID 1001 คือ NestJS User ภายใน Container) +chown -R 1001:1001 /share/np-dms/data/uploads +chown -R 1001:1001 /share/np-dms/data/logs/backend +chmod -R 750 /share/np-dms/data/uploads + +# 4. โฟลเดอร์สำหรับ persistent volumes ของ DB/Services อื่นๆ +mkdir -p /volume1/lcbp3/volumes/mariadb-data +mkdir -p /volume1/lcbp3/volumes/redis-data +mkdir -p /volume1/lcbp3/volumes/elastic-data +``` + +--- + +## 📝 4. การจัดการ Environment Variables (`.env`) + +สร้างไฟล์ `.env` ที่ **`/share/np-dms/app/.env`** บน QNAP โดยตรง +⚠️ *กฎความปลอดภัย (Tier 1): ห้าม Commit ไฟล์นี้ขึ้น Git หรือเก็บในโฟลเดอร์ Source Code เด็ดขาด!* + +```dotenv +# File: /share/np-dms/app/.env + +# Application Configuration +NODE_ENV=production +APP_NAME=LCBP3-DMS +NEXT_PUBLIC_API_URL=https://backend.np-dms.work/api +AUTH_URL=https://lcbp3.np-dms.work + +# Database (MariaDB native UUID v7 - ADR-019) +DB_HOST=mariadb +DB_PORT=3306 +DB_USERNAME=lcbp3_user +DB_PASSWORD= +DB_DATABASE=lcbp3_dms +DB_POOL_SIZE=20 + +# Redis Cache & BullMQ (ADR-008) +REDIS_HOST=cache +REDIS_PORT=6379 +REDIS_PASSWORD= +REDIS_DB=0 + +# Security Credentials (Tier 1) +JWT_SECRET=<สร้างด้วย openssl rand -base64 32> +AUTH_SECRET=<สร้างด้วย openssl rand -base64 32> + +# File Upload Security (Tier 1) +UPLOAD_PATH=/app/uploads +MAX_FILE_SIZE=52428800 # 50MB +ALLOWED_FILE_TYPES=.pdf,.doc,.docx,.xls,.xlsx,.dwg,.zip + +# ClamAV Antivirus +CLAMAV_HOST=lcbp3-clamav +CLAMAV_PORT=3310 + +# AI Services Boundary (ADR-023/ADR-023A - Isolation on Admin Desktop) +OLLAMA_URL=http://192.168.10.100:11434 +AI_HOST_URL=http://192.168.10.100:11434 +AI_QDRANT_URL=http://192.168.10.100:6333 +``` + +--- + +## 🔄 5. การตั้งค่า CI/CD Gitea Actions + +ใน Gitea Web UI ไปที่ repository → **Settings** → **Actions** → **Secrets** เพื่อเพิ่มตัวแปรลับ (Secrets) ที่ใช้เชื่อมต่อ SSH ไปยัง QNAP: + +| Secret Name | Value | คำอธิบาย | +| :--- | :--- | :--- | +| `HOST` | `192.168.10.8` | IP Address ของ QNAP (VLAN 10) | +| `PORT` | `22` | พอร์ต SSH (Default: 22) | +| `USERNAME` | `admin` | สิทธิ์แอดมินในการควบคุม Container ของ QNAP | +| `SSH_KEY` | `-----BEGIN OPENSSH PRIVATE KEY-----...` | เนื้อหาในไฟล์คีย์ส่วนตัวจาก ASUSTOR (`/etc/config/ssh/gitea-runner`) | + +--- + +## 🚀 6. ขั้นตอนการ Deploy + +### 6.1 การ Deploy อัตโนมัติ (Automated CI/CD) +เมื่อมีการ Push โค้ดไปยังกิ่ง `main` ระบบ Gitea Actions จะรับงานไปรันบน ASUSTOR Runner: +1. เชื่อมต่อ SSH ไปยัง QNAP (สิทธิ์ `admin` ผ่าน SSH Key) +2. สั่ง `git pull` ดึงโค้ดล่าสุดลงโฟลเดอร์ `/share/np-dms/app/source/lcbp3` +3. เรียกใช้สคริปต์ `@/scripts/deploy.sh` ของโครงการเพื่อทำการ build และ deploy + +### 6.2 การ Deploy ด้วยตนเอง (Manual Deploy) +หากพบปัญหาเรื่องเน็ตเวิร์ก หรือต้องการรัน Deploy เองตรงจาก QNAP: +```bash +# 1. SSH เข้า QNAP +ssh admin@192.168.10.8 + +# 2. ไปที่โฟลเดอร์ Repository และ pull โค้ดล่าสุด +cd /share/np-dms/app/source/lcbp3 +git pull origin main + +# 3. รันสคริปต์ Deploy +bash scripts/deploy.sh +``` + +### รายละเอียดการทำงานของ `deploy.sh`: +- **Build Step (BuildKit):** รันคำสั่ง Build Image ในแบบขนานกัน (Parallel) เพื่อลดเวลาก่อสร้าง: + ```bash + docker build -f backend/Dockerfile -t lcbp3-backend:latest . & + docker build -f frontend/Dockerfile -t lcbp3-frontend:latest . & + ``` +- **Recreate Container:** ใช้ `--force-recreate` ควบคู่กับ Environment file โปรดักชัน: + ```bash + docker compose --env-file /share/np-dms/app/.env -f specs/04-Infrastructure-OPS/04-00-docker-compose/QNAP/app/docker-compose-app.yml up -d --force-recreate + ``` +- **Health Check:** ตรวจสอบความถูกต้องของการสตาร์ตระบบ (Timeout 60 วินาที) ก่อนรายงานผลความสำเร็จ + +--- + +## 🆘 7. การกู้คืนระบบและการแก้ปัญหา (Rollback & Troubleshooting) + +### 7.1 การ Rollback ระบบ +หากหลังจาก Deploy พบว่าระบบทำงานบกพร่อง (Critical Bug): +1. **ผ่าน Gitea UI:** ไปที่แท็บ **Actions** → เลือกกิ่งที่มีเสถียรภาพตัวล่าสุด (Stable Commit) → กดปุ่ม **Re-run Jobs** +2. **รันคำสั่งตรงบน QNAP (SSH):** + ```bash + cd /share/np-dms/app/source/lcbp3 + # ตรวจหาแฮชคอมมิตก่อนหน้าที่มีความเสถียร + git log --oneline -10 + # ย้อนกลับโค้ด + git checkout + # รันสร้างและดีพลอยใหม่ด้วยโค้ดเดิม + bash scripts/deploy.sh + ``` + +### 7.2 ปัญหาตู้คอนเทนเนอร์ค้าง (Container Removal Timeout) +หากตอน Deploy มีอาการค้างที่กระบวนการลบตู้อันเดิม: +```bash +# Force stop และลบตู้อันที่ค้าง +docker kill backend frontend 2>/dev/null || true +docker rm -f backend frontend 2>/dev/null || true + +# ทำความสะอาด Cache และ Prune ของตกค้าง +docker system prune -f --volumes + +# รีสตาร์ตตู้ Stack ทั้งหมดใหม่อีกครั้ง +bash scripts/deploy.sh +``` + +--- + +// Change Log: +// - 2026-05-21: จัดทำเอกสารคู่มือขั้นตอนการเซ็ตอัปการ Deploy สำหรับทีมปฏิบัติการและผู้พัฒนา (v1.9.5) diff --git a/frontend/app/(admin)/admin/ai/page.tsx b/frontend/app/(admin)/admin/ai/page.tsx new file mode 100644 index 00000000..9ca5ff43 --- /dev/null +++ b/frontend/app/(admin)/admin/ai/page.tsx @@ -0,0 +1,769 @@ +// File: frontend/app/(admin)/admin/ai/page.tsx +'use client'; +// Change Log +// - 2026-05-21: เพิ่มหน้า AI Admin Console สำหรับเปิด/ปิด AI features. +// - 2026-05-21: เพิ่มส่วนแสดงผลสถานะสุขภาพของระบบ AI (Ollama, Qdrant, queues) แบบ real-time polling 30s (T030, T031). +// - 2026-05-21: เพิ่ม RAG Playground Sandbox tab สำหรับ Superadmin (T037, T038). +// - 2026-05-21: เพิ่ม OCR Sandbox tab พร้อมการอัปเดตสถานะและการแสดงผล JSON แบบมีสีสำหรับ Superadmin (T043-T045). +// - 2026-05-21: แก้ไข ESLint error เกี่ยวกับ any type และ console.error statement ให้ตรงตามมาตรฐาน Tier 1/2 + +import { useState, useEffect } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Brain, Loader2, Power, ShieldCheck, Cpu, Database, Activity, Search, Info, HelpCircle, AlertCircle } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Switch } from '@/components/ui/switch'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Textarea } from '@/components/ui/textarea'; +import { Progress } from '@/components/ui/progress'; +import { useAiStatus, useToggleAiFeatures, useAiHealth } from '@/hooks/use-ai-status'; +import { projectService } from '@/lib/services/project.service'; +import { adminAiService, AiSandboxJobResult } from '@/lib/services/admin-ai.service'; +import { toast } from 'sonner'; + +interface SandboxProject { + publicId: string; + projectName: string; + projectCode: string; +} + +export default function AiAdminConsolePage() { + const { data, isLoading, isError, refetch, isFetching } = useAiStatus(); + const { data: health, isLoading: isHealthLoading, refetch: refetchHealth } = useAiHealth(); + const toggleMutation = useToggleAiFeatures(); + const aiEnabled = data?.aiFeaturesEnabled ?? false; + const busy = isLoading || toggleMutation.isPending; + const [selectedProject, setSelectedProject] = useState(''); + const [question, setQuestion] = useState(''); + const [sandboxJobId, setSandboxJobId] = useState(null); + const [sandboxJobResult, setSandboxJobResult] = useState(null); + const [isSandboxPolling, setIsSandboxPolling] = useState(false); + const [sandboxProgress, setSandboxProgress] = useState(0); + const [sandboxStatusText, setSandboxStatusText] = useState(''); + const [ocrFile, setOcrFile] = useState(null); + const [ocrJobId, setOcrJobId] = useState(null); + const [ocrJobResult, setOcrJobResult] = useState(null); + const [isOcrPolling, setIsOcrPolling] = useState(false); + const [ocrProgress, setOcrProgress] = useState(0); + const [ocrStatusText, setOcrStatusText] = useState(''); + const { data: projects = [], isLoading: isProjectsLoading } = useQuery({ + queryKey: ['admin-sandbox-projects'], + queryFn: async () => { + const res = await projectService.getAll({ isActive: true, limit: 100 }); + return res as SandboxProject[]; + }, + }); + const handleToggle = async (enabled: boolean): Promise => { + await toggleMutation.mutateAsync(enabled); + }; + const handleRefreshAll = async (): Promise => { + await Promise.all([refetch(), refetchHealth()]); + }; + const handleSubmitSandbox = async (e: React.FormEvent): Promise => { + e.preventDefault(); + if (!selectedProject) { + toast.error('กรุณาเลือกโครงการ'); + return; + } + if (!question.trim()) { + toast.error('กรุณากรอกคำถาม'); + return; + } + try { + setSandboxJobResult(null); + setSandboxProgress(10); + setSandboxStatusText('กำลังส่งคำถาม RAG เข้าสู่ระบบคิว...'); + const response = await adminAiService.submitSandboxRag(selectedProject, question); + setSandboxJobId(response.requestPublicId); + setIsSandboxPolling(true); + toast.success('ส่งคำถามเข้าสู่คิว sandbox สำเร็จ'); + } catch (err) { + const error = err as { response?: { data?: { message?: string } } }; + toast.error(error.response?.data?.message || 'เกิดข้อผิดพลาดในการส่งคำถาม RAG'); + setSandboxProgress(0); + setSandboxStatusText(''); + } + }; + useEffect(() => { + if (!sandboxJobId) return; + let timer: NodeJS.Timeout; + const pollSandboxJob = async () => { + try { + const res = await adminAiService.getSandboxJobStatus(sandboxJobId); + setSandboxJobResult(res); + if (res.status === 'pending') { + setSandboxProgress(20); + setSandboxStatusText('อยู่ระหว่างเข้าคิวรอประมวลผล (Pending in BullMQ)...'); + } else if (res.status === 'processing') { + setSandboxProgress(60); + setSandboxStatusText('กำลังค้นหาเอกสารผ่าน Qdrant และประมวลผล RAG ด้วย Local LLM...'); + } else if (res.status === 'completed') { + setSandboxProgress(100); + setSandboxStatusText('ประมวลผลคำตอบเสร็จสิ้น'); + setIsSandboxPolling(false); + setSandboxJobId(null); + toast.success('RAG Sandbox ตอบคำถามสำเร็จ'); + } else if (res.status === 'failed') { + setSandboxProgress(100); + setSandboxStatusText('การประมวลผลล้มเหลว'); + setIsSandboxPolling(false); + setSandboxJobId(null); + toast.error(res.errorMessage || 'เกิดข้อผิดพลาดในการรัน RAG Playground'); + } else if (res.status === 'cancelled') { + setSandboxProgress(100); + setSandboxStatusText('การประมวลผลถูกยกเลิก'); + setIsSandboxPolling(false); + setSandboxJobId(null); + toast.error('Sandbox job ถูกยกเลิก'); + } else if (res.status === 'not_found') { + setSandboxProgress(15); + setSandboxStatusText('กำลังเตรียมการจัดคิว...'); + } + } catch { + // เงียบข้อผิดพลาดตามนโยบาย UI + } + }; + pollSandboxJob(); + timer = setInterval(pollSandboxJob, 5000); + return () => { + clearInterval(timer); + }; + }, [sandboxJobId]); + const handleSubmitOcr = async (e: React.FormEvent): Promise => { + e.preventDefault(); + if (!ocrFile) { + toast.error('กรุณาเลือกไฟล์ PDF สำหรับทำ OCR'); + return; + } + if (ocrFile.size > 50 * 1024 * 1024) { + toast.error('ขนาดไฟล์เกินกว่า 50MB'); + return; + } + if (ocrFile.type !== 'application/pdf' && !ocrFile.name.toLowerCase().endsWith('.pdf')) { + toast.error('กรุณาอัปโหลดไฟล์ในรูปแบบ PDF เท่านั้น'); + return; + } + try { + setOcrJobResult(null); + setOcrProgress(10); + setOcrStatusText('กำลังอัปโหลดไฟล์ไปยังระบบเซิร์ฟเวอร์...'); + const response = await adminAiService.submitSandboxExtract(ocrFile); + setOcrJobId(response.requestPublicId); + setIsOcrPolling(true); + toast.success('อัปโหลดไฟล์สำเร็จและเข้าสู่คิว sandbox OCR'); + } catch (err) { + const error = err as { response?: { data?: { message?: string } } }; + toast.error(error.response?.data?.message || 'เกิดข้อผิดพลาดในการทำ OCR Sandbox'); + setOcrProgress(0); + setOcrStatusText(''); + } + }; + useEffect(() => { + if (!ocrJobId) return; + let timer: NodeJS.Timeout; + const pollOcrJob = async () => { + try { + const res = await adminAiService.getSandboxJobStatus(ocrJobId); + setOcrJobResult(res); + if (res.status === 'pending') { + setOcrProgress(30); + setOcrStatusText('อยู่ในคิวรอดำเนินการ (Pending in BullMQ)...'); + } else if (res.status === 'processing') { + setOcrProgress(70); + setOcrStatusText('กำลังอ่านไฟล์ PDF และสกัดข้อความด้วย OCR & LLM...'); + } else if (res.status === 'completed') { + setOcrProgress(100); + setOcrStatusText('การทำ OCR และสกัดข้อมูลเมตาดาต้าเสร็จสิ้น'); + setIsOcrPolling(false); + setOcrJobId(null); + toast.success('ทำ OCR Sandbox สำเร็จ'); + } else if (res.status === 'failed') { + setOcrProgress(100); + setOcrStatusText('การทำ OCR ล้มเหลว'); + setIsOcrPolling(false); + setOcrJobId(null); + toast.error(res.errorMessage || 'การทำ OCR Sandbox เกิดข้อผิดพลาด'); + } else if (res.status === 'cancelled') { + setOcrProgress(100); + setOcrStatusText('การทำ OCR ถูกยกเลิก'); + setIsOcrPolling(false); + setOcrJobId(null); + toast.error('OCR sandbox job ถูกยกเลิก'); + } else if (res.status === 'not_found') { + setOcrProgress(20); + setOcrStatusText('กำลังตรวจสอบสถานะคิวงาน...'); + } + } catch { + // เงียบข้อผิดพลาดตามนโยบาย UI + } + }; + pollOcrJob(); + timer = setInterval(pollOcrJob, 5000); + return () => { + clearInterval(timer); + }; + }, [ocrJobId]); + const renderStatusBadge = (status?: 'HEALTHY' | 'DEGRADED' | 'DOWN') => { + if (!status) return Unknown; + switch (status) { + case 'HEALTHY': + return Healthy; + case 'DEGRADED': + return Degraded; + default: + return Down; + } + }; + return ( +
+
+
+

+ + AI Console +

+

ควบคุมสถานะ AI features สำหรับผู้ใช้ทั่วไป

+
+ + {aiEnabled ? 'AI Enabled' : 'AI Disabled'} + +
+ + + Overview & Health + RAG Playground + OCR Sandbox + + +
+ + + + + Ollama AI Engine + + {isHealthLoading ? : renderStatusBadge(health?.ollama?.status)} + + +
+ ความเร็วตอบสนอง + {health?.ollama?.latencyMs !== undefined ? `${health.ollama.latencyMs} ms` : '-'} +
+
+ โมเดลที่โหลดอยู่: +
+ {health?.ollama?.models && health.ollama.models.length > 0 ? ( + health.ollama.models.map((m) => ( + + {m} + + )) + ) : ( + ไม่มีโมเดลที่โหลดอยู่ + )} +
+
+ {health?.ollama?.error && ( +

{health.ollama.error}

+ )} +
+
+ + + + + Qdrant Vector DB + + {isHealthLoading ? : renderStatusBadge(health?.qdrant?.status)} + + +
+ ความเร็วตอบสนอง + {health?.qdrant?.latencyMs !== undefined ? `${health.qdrant.latencyMs} ms` : '-'} +
+
+ คอลเลกชัน: +
+ {health?.qdrant?.collections && health.qdrant.collections.length > 0 ? ( + health.qdrant.collections.map((c) => ( + + {c} + + )) + ) : ( + ไม่มีคอลเลกชัน + )} +
+
+ {health?.qdrant?.error && ( +

{health.qdrant.error}

+ )} +
+
+ + + + + BullMQ Queue Health + + {isHealthLoading ? ( + + ) : ( + + {health?.timestamp ? new Date(health.timestamp).toLocaleTimeString() : 'N/A'} + + )} + + +
+
+ คิว / สถานะงาน + Active / Waiting / Failed +
+
+ + realtime + {health?.queues?.realtime?.isPaused && (Paused)} + + + {health?.queues?.realtime?.active ?? 0} / {health?.queues?.realtime?.waiting ?? 0} /{' '} + 0 ? 'text-destructive' : ''}> + {health?.queues?.realtime?.failed ?? 0} + + +
+
+ + batch + {health?.queues?.batch?.isPaused && (Paused)} + + + {health?.queues?.batch?.active ?? 0} / {health?.queues?.batch?.waiting ?? 0} /{' '} + 0 ? 'text-destructive' : ''}> + {health?.queues?.batch?.failed ?? 0} + + +
+
+ {(health?.queues?.realtime?.error || health?.queues?.batch?.error) && ( +

+ {health.queues.realtime.error || health.queues.batch.error} +

+ )} +
+
+
+ + + + + System Toggle + + + +
+
+
+ {aiEnabled ? 'AI พร้อมให้ผู้ใช้ทั่วไปใช้งาน' : 'AI ถูกปิดสำหรับผู้ใช้ทั่วไป'} +
+
+ Superadmin ยังสามารถเข้าถึงส่วนทดสอบและดูแลระบบได้ตามสิทธิ์ +
+
+
+ {busy && } + +
+
+ {isError && ( +
+ ไม่สามารถโหลดสถานะ AI ได้ กรุณาลองใหม่อีกครั้ง +
+ )} +
+
+
+ + + + + Protection + + + + เมื่อปิด AI ระบบจะบล็อก AI inference endpoints สำหรับผู้ใช้ทั่วไปด้วย HTTP 503 + และให้ผู้ใช้กรอกข้อมูลเองชั่วคราว + + + + + Polling + + + + อัปเดตสถานะทุก 30 วินาที + {(isFetching || isHealthLoading) && !(isLoading || isHealthLoading) ? ' (กำลังรีเฟรช)' : ''} + + + + +
+
+ + + + + + RAG Sandbox Playground (isolated) + +

+ พื้นที่ทดสอบสืบค้นเอกสารและสรุปผลด้วย Retrieval-Augmented Generation (RAG) คิวงานใช้ระดับความสำคัญพิเศษ (Priority 1) +

+
+ +
+
+ + {isProjectsLoading ? ( +
+ + กำลังโหลดรายการโครงการ... +
+ ) : ( + + )} +
+
+ +