feat(ai-admin-console): complete implementation and resolve lint compilation errors
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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=<STRONG_DATABASE_PASSWORD>
|
||||
DB_DATABASE=lcbp3_dms
|
||||
DB_POOL_SIZE=20
|
||||
|
||||
# Redis Cache & BullMQ (ADR-008)
|
||||
REDIS_HOST=cache
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=<STRONG_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 <stable-commit-hash>
|
||||
# รันสร้างและดีพลอยใหม่ด้วยโค้ดเดิม
|
||||
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)
|
||||
@@ -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<string>('');
|
||||
const [question, setQuestion] = useState<string>('');
|
||||
const [sandboxJobId, setSandboxJobId] = useState<string | null>(null);
|
||||
const [sandboxJobResult, setSandboxJobResult] = useState<AiSandboxJobResult | null>(null);
|
||||
const [isSandboxPolling, setIsSandboxPolling] = useState<boolean>(false);
|
||||
const [sandboxProgress, setSandboxProgress] = useState<number>(0);
|
||||
const [sandboxStatusText, setSandboxStatusText] = useState<string>('');
|
||||
const [ocrFile, setOcrFile] = useState<File | null>(null);
|
||||
const [ocrJobId, setOcrJobId] = useState<string | null>(null);
|
||||
const [ocrJobResult, setOcrJobResult] = useState<AiSandboxJobResult | null>(null);
|
||||
const [isOcrPolling, setIsOcrPolling] = useState<boolean>(false);
|
||||
const [ocrProgress, setOcrProgress] = useState<number>(0);
|
||||
const [ocrStatusText, setOcrStatusText] = useState<string>('');
|
||||
const { data: projects = [], isLoading: isProjectsLoading } = useQuery<SandboxProject[]>({
|
||||
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<void> => {
|
||||
await toggleMutation.mutateAsync(enabled);
|
||||
};
|
||||
const handleRefreshAll = async (): Promise<void> => {
|
||||
await Promise.all([refetch(), refetchHealth()]);
|
||||
};
|
||||
const handleSubmitSandbox = async (e: React.FormEvent): Promise<void> => {
|
||||
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<void> => {
|
||||
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 <Badge variant="outline">Unknown</Badge>;
|
||||
switch (status) {
|
||||
case 'HEALTHY':
|
||||
return <Badge className="border-emerald-500/20 bg-emerald-500/10 text-emerald-500 hover:bg-emerald-500/20">Healthy</Badge>;
|
||||
case 'DEGRADED':
|
||||
return <Badge className="border-amber-500/20 bg-amber-500/10 text-amber-500 hover:bg-amber-500/20">Degraded</Badge>;
|
||||
default:
|
||||
return <Badge variant="destructive">Down</Badge>;
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="flex items-center gap-2 text-2xl font-bold">
|
||||
<Brain className="h-6 w-6" />
|
||||
AI Console
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">ควบคุมสถานะ AI features สำหรับผู้ใช้ทั่วไป</p>
|
||||
</div>
|
||||
<Badge variant={aiEnabled ? 'default' : 'destructive'} className="w-fit">
|
||||
{aiEnabled ? 'AI Enabled' : 'AI Disabled'}
|
||||
</Badge>
|
||||
</div>
|
||||
<Tabs defaultValue="overview" className="w-full space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-3 max-w-[500px]">
|
||||
<TabsTrigger value="overview">Overview & Health</TabsTrigger>
|
||||
<TabsTrigger value="playground">RAG Playground</TabsTrigger>
|
||||
<TabsTrigger value="ocr">OCR Sandbox</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="overview" className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card className="relative overflow-hidden border border-border/50 bg-background/50 backdrop-blur-md">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-sm font-medium">
|
||||
<Cpu className="h-4 w-4 text-primary" />
|
||||
Ollama AI Engine
|
||||
</CardTitle>
|
||||
{isHealthLoading ? <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" /> : renderStatusBadge(health?.ollama?.status)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>ความเร็วตอบสนอง</span>
|
||||
<span className="font-semibold text-foreground">{health?.ollama?.latencyMs !== undefined ? `${health.ollama.latencyMs} ms` : '-'}</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">โมเดลที่โหลดอยู่:</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{health?.ollama?.models && health.ollama.models.length > 0 ? (
|
||||
health.ollama.models.map((m) => (
|
||||
<Badge key={m} variant="secondary" className="text-[10px] py-0 px-1">
|
||||
{m}
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<span className="text-[10px] text-muted-foreground italic">ไม่มีโมเดลที่โหลดอยู่</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{health?.ollama?.error && (
|
||||
<p className="mt-1 text-[10px] text-destructive line-clamp-2">{health.ollama.error}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="relative overflow-hidden border border-border/50 bg-background/50 backdrop-blur-md">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-sm font-medium">
|
||||
<Database className="h-4 w-4 text-primary" />
|
||||
Qdrant Vector DB
|
||||
</CardTitle>
|
||||
{isHealthLoading ? <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" /> : renderStatusBadge(health?.qdrant?.status)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>ความเร็วตอบสนอง</span>
|
||||
<span className="font-semibold text-foreground">{health?.qdrant?.latencyMs !== undefined ? `${health.qdrant.latencyMs} ms` : '-'}</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">คอลเลกชัน:</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{health?.qdrant?.collections && health.qdrant.collections.length > 0 ? (
|
||||
health.qdrant.collections.map((c) => (
|
||||
<Badge key={c} variant="outline" className="text-[10px] py-0 px-1 bg-background/30">
|
||||
{c}
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<span className="text-[10px] text-muted-foreground italic">ไม่มีคอลเลกชัน</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{health?.qdrant?.error && (
|
||||
<p className="mt-1 text-[10px] text-destructive line-clamp-2">{health.qdrant.error}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="relative overflow-hidden border border-border/50 bg-background/50 backdrop-blur-md">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-sm font-medium">
|
||||
<Activity className="h-4 w-4 text-primary" />
|
||||
BullMQ Queue Health
|
||||
</CardTitle>
|
||||
{isHealthLoading ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{health?.timestamp ? new Date(health.timestamp).toLocaleTimeString() : 'N/A'}
|
||||
</Badge>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="space-y-1 text-xs">
|
||||
<div className="flex items-center justify-between font-medium text-[11px] border-b pb-1 mb-1">
|
||||
<span>คิว / สถานะงาน</span>
|
||||
<span>Active / Waiting / Failed</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-muted-foreground">
|
||||
<span className="flex items-center gap-1 font-mono">
|
||||
realtime
|
||||
{health?.queues?.realtime?.isPaused && <span className="text-[9px] text-amber-500 font-sans">(Paused)</span>}
|
||||
</span>
|
||||
<span className="font-semibold text-foreground">
|
||||
{health?.queues?.realtime?.active ?? 0} / {health?.queues?.realtime?.waiting ?? 0} /{' '}
|
||||
<span className={(health?.queues?.realtime?.failed ?? 0) > 0 ? 'text-destructive' : ''}>
|
||||
{health?.queues?.realtime?.failed ?? 0}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-muted-foreground">
|
||||
<span className="flex items-center gap-1 font-mono">
|
||||
batch
|
||||
{health?.queues?.batch?.isPaused && <span className="text-[9px] text-amber-500 font-sans">(Paused)</span>}
|
||||
</span>
|
||||
<span className="font-semibold text-foreground">
|
||||
{health?.queues?.batch?.active ?? 0} / {health?.queues?.batch?.waiting ?? 0} /{' '}
|
||||
<span className={(health?.queues?.batch?.failed ?? 0) > 0 ? 'text-destructive' : ''}>
|
||||
{health?.queues?.batch?.failed ?? 0}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{(health?.queues?.realtime?.error || health?.queues?.batch?.error) && (
|
||||
<p className="mt-1 text-[10px] text-destructive line-clamp-1">
|
||||
{health.queues.realtime.error || health.queues.batch.error}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Power className="h-5 w-5" />
|
||||
System Toggle
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-base font-medium">
|
||||
{aiEnabled ? 'AI พร้อมให้ผู้ใช้ทั่วไปใช้งาน' : 'AI ถูกปิดสำหรับผู้ใช้ทั่วไป'}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Superadmin ยังสามารถเข้าถึงส่วนทดสอบและดูแลระบบได้ตามสิทธิ์
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
<Switch
|
||||
checked={aiEnabled}
|
||||
disabled={busy || isError}
|
||||
aria-label="Toggle AI features"
|
||||
onCheckedChange={handleToggle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{isError && (
|
||||
<div className="mt-4 rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
ไม่สามารถโหลดสถานะ AI ได้ กรุณาลองใหม่อีกครั้ง
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ShieldCheck className="h-5 w-5" />
|
||||
Protection
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground">
|
||||
เมื่อปิด AI ระบบจะบล็อก AI inference endpoints สำหรับผู้ใช้ทั่วไปด้วย HTTP 503
|
||||
และให้ผู้ใช้กรอกข้อมูลเองชั่วคราว
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Polling</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-between gap-3 text-sm text-muted-foreground">
|
||||
<span>
|
||||
อัปเดตสถานะทุก 30 วินาที
|
||||
{(isFetching || isHealthLoading) && !(isLoading || isHealthLoading) ? ' (กำลังรีเฟรช)' : ''}
|
||||
</span>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => void handleRefreshAll()}>
|
||||
Refresh
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="playground" className="space-y-6">
|
||||
<Card className="border border-border/50 bg-background/50 backdrop-blur-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Search className="h-5 w-5 text-primary" />
|
||||
RAG Sandbox Playground (isolated)
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
พื้นที่ทดสอบสืบค้นเอกสารและสรุปผลด้วย Retrieval-Augmented Generation (RAG) คิวงานใช้ระดับความสำคัญพิเศษ (Priority 1)
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmitSandbox} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="project-select" className="text-sm font-medium text-foreground">
|
||||
เลือกโครงการ
|
||||
</label>
|
||||
{isProjectsLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
กำลังโหลดรายการโครงการ...
|
||||
</div>
|
||||
) : (
|
||||
<Select value={selectedProject} onValueChange={setSelectedProject} disabled={isSandboxPolling}>
|
||||
<SelectTrigger id="project-select" className="w-full">
|
||||
<SelectValue placeholder="-- กรุณาเลือกโครงการ --" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{projects.map((proj) => (
|
||||
<SelectItem key={proj.publicId} value={proj.publicId}>
|
||||
{proj.projectName} ({proj.projectCode})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="rag-question" className="text-sm font-medium text-foreground">
|
||||
คำถามเพื่อการสืบค้น
|
||||
</label>
|
||||
<Textarea
|
||||
id="rag-question"
|
||||
placeholder="ตัวอย่าง: ค้นหาเอกสาร RFA ล่าสุดที่อนุมัติเกี่ยวกับ Shop Drawing ของงานระบบไฟฟ้า"
|
||||
value={question}
|
||||
onChange={(e) => setQuestion(e.target.value)}
|
||||
disabled={isSandboxPolling}
|
||||
rows={4}
|
||||
className="resize-none border border-input bg-background/50"
|
||||
/>
|
||||
<div className="text-right text-[11px] text-muted-foreground">
|
||||
{question.length} ตัวอักษร
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSandboxPolling || !selectedProject || !question.trim()}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{isSandboxPolling ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
กำลังประมวลผล Sandbox...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Search className="h-4 w-4" />
|
||||
ส่งคำถาม Sandbox RAG
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{isSandboxPolling && (
|
||||
<Card className="border border-amber-500/20 bg-amber-500/5">
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
<div className="flex items-center justify-between text-sm font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-amber-500" />
|
||||
<span>{sandboxStatusText}</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{sandboxProgress}%</span>
|
||||
</div>
|
||||
<Progress value={sandboxProgress} className="h-2" />
|
||||
<div className="rounded bg-background/50 p-2 text-[11px] text-muted-foreground font-mono flex items-center gap-2">
|
||||
<Info className="h-3 w-3" />
|
||||
ID คำขอ: {sandboxJobId}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{sandboxJobResult && (
|
||||
<div className="space-y-6">
|
||||
{sandboxJobResult.status === 'completed' && (
|
||||
<>
|
||||
<Card className="border border-emerald-500/20 bg-background/50 backdrop-blur-md">
|
||||
<CardHeader className="border-b border-border/30 pb-3 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base text-emerald-600 dark:text-emerald-400 flex items-center gap-2">
|
||||
<Brain className="h-4 w-4" />
|
||||
คำตอบที่ประมวลผลได้ (RAG Sandbox Answer)
|
||||
</CardTitle>
|
||||
{sandboxJobResult.usedFallbackModel && (
|
||||
<Badge variant="outline" className="text-[10px] text-amber-500 border-amber-500/20 bg-amber-500/5">
|
||||
โมเดลสำรอง (Fallback)
|
||||
</Badge>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4">
|
||||
<div className="whitespace-pre-wrap text-sm leading-relaxed text-foreground select-text font-sans">
|
||||
{sandboxJobResult.answer}
|
||||
</div>
|
||||
{sandboxJobResult.completedAt && (
|
||||
<div className="mt-4 text-right text-[10px] text-muted-foreground">
|
||||
เสร็จสิ้นเมื่อ: {new Date(sandboxJobResult.completedAt).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border border-border/50 bg-background/50 backdrop-blur-md">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2 text-muted-foreground">
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
เอกสารที่อ้างอิง ({sandboxJobResult.citations?.length ?? 0} รายการ)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{sandboxJobResult.citations && sandboxJobResult.citations.length > 0 ? (
|
||||
<div className="grid gap-3 sm:grid-cols-1">
|
||||
{sandboxJobResult.citations.map((cite, index) => (
|
||||
<div
|
||||
key={cite.pointId || index}
|
||||
className="rounded-lg border border-border/40 bg-background/30 p-3 hover:bg-background/60 transition-colors space-y-2"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className="bg-primary/10 text-primary hover:bg-primary/20 text-[10px] border-none py-0">
|
||||
{cite.docType || 'Document'}
|
||||
</Badge>
|
||||
<span className="text-xs font-semibold text-foreground">
|
||||
{cite.docNumber || 'ไม่มีเลขที่เอกสาร'}
|
||||
</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-[10px] py-0 border-border/50 text-muted-foreground">
|
||||
Score Match: {(cite.score * 100).toFixed(1)}%
|
||||
</Badge>
|
||||
</div>
|
||||
{cite.snippet && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-3 bg-background/50 p-2 rounded border border-border/20 italic font-sans leading-relaxed">
|
||||
"{cite.snippet}"
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-6 text-xs text-muted-foreground italic">
|
||||
ไม่มีการสกัดเอกสารอ้างอิงสำหรับคำถามนี้
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
{sandboxJobResult.status === 'failed' && (
|
||||
<Card className="border border-destructive/20 bg-destructive/5">
|
||||
<CardHeader className="flex flex-row items-center gap-2 pb-2 text-destructive">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
<CardTitle className="text-sm font-medium">ประมวลผล Sandbox ล้มเหลว</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{sandboxJobResult.errorMessage || 'เกิดข้อผิดพลาดในการเรียกใช้ Local LLM หรือ Vector DB ใน Sandbox Sandbox process ล้มเหลว กรุณาตรวจสอบสถานะสุขภาพของ Ollama Engine/Qdrant DB ใน Overview Tab'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="ocr" className="space-y-6">
|
||||
<Card className="border border-border/50 bg-background/50 backdrop-blur-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Brain className="h-5 w-5 text-primary" />
|
||||
OCR Sandbox Playground (isolated)
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
พื้นที่อัปโหลดไฟล์ PDF เพื่อทำการทดสอบทำ OCR และจำลองการดึง Metadata ออกมาในรูปแบบโครงสร้าง JSON โดยไม่บันทึกข้อมูลลงฐานข้อมูลจริง
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmitOcr} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
อัปโหลดเอกสาร PDF (ขนาดไม่เกิน 50MB)
|
||||
</label>
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center rounded-lg border border-dashed p-8 transition-colors ${
|
||||
ocrFile
|
||||
? 'border-primary/50 bg-primary/5'
|
||||
: 'border-muted-foreground/20 hover:bg-muted/10'
|
||||
}`}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
if (isOcrPolling) return;
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file) {
|
||||
if (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf')) {
|
||||
setOcrFile(file);
|
||||
} else {
|
||||
toast.error('กรุณาเลือกไฟล์ PDF เท่านั้น');
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Activity className="h-10 w-10 text-muted-foreground/60 mb-2" />
|
||||
{ocrFile ? (
|
||||
<div className="text-center space-y-1">
|
||||
<p className="text-sm font-medium text-foreground">{ocrFile.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
({(ocrFile.size / 1024 / 1024).toFixed(2)} MB)
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={isOcrPolling}
|
||||
onClick={() => setOcrFile(null)}
|
||||
className="mt-2 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
ลบไฟล์
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center space-y-1">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
ลากและวางไฟล์ PDF หรือคลิกเพื่ออัปโหลด
|
||||
</p>
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
disabled={isOcrPolling}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) setOcrFile(file);
|
||||
}}
|
||||
className="hidden"
|
||||
id="ocr-file-upload"
|
||||
/>
|
||||
<label
|
||||
htmlFor="ocr-file-upload"
|
||||
className="mt-2 inline-flex h-8 items-center justify-center rounded-md bg-secondary px-3 text-xs font-medium text-secondary-foreground ring-offset-background transition-colors hover:bg-secondary/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
เลือกไฟล์
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isOcrPolling || !ocrFile}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{isOcrPolling ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
กำลังประมวลผล OCR...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Power className="h-4 w-4" />
|
||||
เริ่มทำ OCR Sandbox
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{isOcrPolling && (
|
||||
<Card className="border border-amber-500/20 bg-amber-500/5">
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
<div className="flex items-center justify-between text-sm font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-amber-500" />
|
||||
<span>{ocrStatusText}</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{ocrProgress}%</span>
|
||||
</div>
|
||||
<Progress value={ocrProgress} className="h-2" />
|
||||
<div className="rounded bg-background/50 p-2 text-[11px] text-muted-foreground font-mono flex items-center gap-2">
|
||||
<Info className="h-3 w-3" />
|
||||
ID คำขอ: {ocrJobId}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{ocrJobResult && (
|
||||
<div className="space-y-6">
|
||||
{ocrJobResult.status === 'completed' && (
|
||||
<Card className="border border-emerald-500/20 bg-background/50 backdrop-blur-md">
|
||||
<CardHeader className="border-b border-border/30 pb-3 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base text-emerald-600 dark:text-emerald-400 flex items-center gap-2">
|
||||
<Brain className="h-4 w-4" />
|
||||
ผลลัพธ์การสกัด Metadata แบบโครงสร้าง (JSON Output)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4">
|
||||
<div className="relative rounded-md bg-muted p-4 font-mono text-xs overflow-auto max-h-[400px]">
|
||||
<pre className="text-emerald-600 dark:text-emerald-400 select-text">
|
||||
{ocrJobResult.answer}
|
||||
</pre>
|
||||
</div>
|
||||
{ocrJobResult.completedAt && (
|
||||
<div className="mt-4 text-right text-[10px] text-muted-foreground">
|
||||
เสร็จสิ้นเมื่อ: {new Date(ocrJobResult.completedAt).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{ocrJobResult.status === 'failed' && (
|
||||
<Card className="border border-destructive/20 bg-destructive/5">
|
||||
<CardHeader className="flex flex-row items-center gap-2 pb-2 text-destructive">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
<CardTitle className="text-sm font-medium">ประมวลผล OCR Sandbox ล้มเหลว</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{ocrJobResult.errorMessage || 'เกิดข้อผิดพลาดขึ้นระหว่างการอ่านไฟล์เอกสาร PDF หรือการเรียก LLM Sandbox สำหรับถอดความเมตาดาต้า กรุณาตรวจสอบสถานะสุขภาพของตัวบริการ'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import SessionProvider from '@/providers/session-provider'; // ✅ Import เข
|
||||
import ThemeProvider from '@/providers/theme-provider';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import { headers } from 'next/headers';
|
||||
import { AiStatusBannerHost } from '@/components/ai/ai-status-banner-host';
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
|
||||
@@ -30,6 +31,7 @@ export default async function RootLayout({ children }: RootLayoutProps) {
|
||||
<SessionProvider>
|
||||
<ThemeProvider nonce={nonce}>
|
||||
<QueryProvider>
|
||||
<AiStatusBannerHost />
|
||||
{children}
|
||||
<Toaster />
|
||||
</QueryProvider>
|
||||
|
||||
@@ -4,7 +4,7 @@ import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Settings, Activity, Shield, FileStack, ChevronDown, ChevronRight, Database } from 'lucide-react';
|
||||
import { Settings, Activity, Shield, FileStack, ChevronDown, ChevronRight, Database, Brain } from 'lucide-react';
|
||||
|
||||
interface MenuItem {
|
||||
href?: string;
|
||||
@@ -62,6 +62,7 @@ export const menuItems: MenuItem[] = [
|
||||
{ href: '/admin/migration/errors', label: 'Error Logs' },
|
||||
],
|
||||
},
|
||||
{ href: '/admin/ai', label: 'AI Console', icon: Brain },
|
||||
{ href: '/admin/settings', label: 'Settings', icon: Settings },
|
||||
];
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// File: components/ai/AiStatusBanner.tsx
|
||||
// Change Log
|
||||
// - 2026-05-14: เพิ่ม banner สำหรับ graceful degradation ของ AI staging.
|
||||
// - 2026-05-21: รองรับ global banner เมื่อ Superadmin ปิด AI features.
|
||||
'use client';
|
||||
|
||||
import { AlertTriangle, CheckCircle2 } from 'lucide-react';
|
||||
@@ -8,19 +9,20 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { useTranslations } from '@/hooks/use-translations';
|
||||
|
||||
interface AiStatusBannerProps {
|
||||
isOffline: boolean;
|
||||
isOffline?: boolean;
|
||||
aiEnabled?: boolean;
|
||||
queuePaused?: boolean;
|
||||
}
|
||||
|
||||
export function AiStatusBanner({ isOffline, queuePaused = false }: AiStatusBannerProps) {
|
||||
export function AiStatusBanner({ isOffline = false, aiEnabled = true, queuePaused = false }: AiStatusBannerProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
if (isOffline) {
|
||||
if (isOffline || !aiEnabled) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>{t('ai.service_unavailable')}</AlertTitle>
|
||||
<AlertDescription>{t('ai.status.offlineDescription')}</AlertDescription>
|
||||
<AlertTitle>{t('ai.status.offlineTitle')}</AlertTitle>
|
||||
<AlertDescription>{t('ai.status.disabledDescription')}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// File: components/ai/__tests__/ai-suggestion-button.test.tsx
|
||||
// Change Log
|
||||
// - 2026-05-21: เพิ่ม unit tests สำหรับ soft fallback ของปุ่ม AI suggestion.
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { AiSuggestionButton } from '../ai-suggestion-button';
|
||||
|
||||
describe('AiSuggestionButton', () => {
|
||||
it('ควร disable และแสดงข้อความ fallback เมื่อ AI ถูกปิด', () => {
|
||||
const onClick = vi.fn();
|
||||
render(<AiSuggestionButton aiEnabled={false} onClick={onClick} />);
|
||||
|
||||
const button = screen.getByRole('button', { name: /AI Suggestion/i });
|
||||
expect(button).toBeDisabled();
|
||||
expect(screen.getByText('ระบบ AI ไม่พร้อมใช้งานชั่วคราว กรุณากรอกข้อมูลด้วยตนเอง')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(button);
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ควรเรียก onClick เมื่อ AI เปิดใช้งาน', () => {
|
||||
const onClick = vi.fn();
|
||||
render(<AiSuggestionButton aiEnabled={true} onClick={onClick} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /AI Suggestion/i }));
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
// File: components/ai/ai-status-banner-host.tsx
|
||||
// Change Log
|
||||
// - 2026-05-21: เพิ่ม host สำหรับ global AI disabled banner เฉพาะผู้ใช้ที่มีสิทธิ์ AI.
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AiStatusBanner } from './AiStatusBanner';
|
||||
import { useCurrentUserAiStatus } from '@/hooks/use-ai-status';
|
||||
import { AI_FEATURES_UNAVAILABLE_EVENT } from '@/lib/api/client';
|
||||
|
||||
/** แสดง global banner เมื่อ AI ถูกปิดสำหรับผู้ใช้ที่มีสิทธิ์ AI */
|
||||
export function AiStatusBannerHost() {
|
||||
const [serviceUnavailable, setServiceUnavailable] = useState(false);
|
||||
const { data, isLoading } = useCurrentUserAiStatus();
|
||||
|
||||
useEffect(() => {
|
||||
const handleAiUnavailable = () => setServiceUnavailable(true);
|
||||
window.addEventListener(AI_FEATURES_UNAVAILABLE_EVENT, handleAiUnavailable);
|
||||
return () => window.removeEventListener(AI_FEATURES_UNAVAILABLE_EVENT, handleAiUnavailable);
|
||||
}, []);
|
||||
|
||||
if (isLoading || (data?.shouldShowBanner !== true && !serviceUnavailable)) return null;
|
||||
return (
|
||||
<div className="sticky top-0 z-40 border-b bg-background px-4 py-2">
|
||||
<AiStatusBanner aiEnabled={serviceUnavailable ? false : data?.aiFeaturesEnabled} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// File: components/ai/ai-suggestion-button.tsx
|
||||
// Change Log
|
||||
// - 2026-05-21: เพิ่มปุ่ม AI Suggestion พร้อม soft fallback เมื่อ AI ถูกปิด.
|
||||
'use client';
|
||||
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
|
||||
|
||||
const DEFAULT_DISABLED_MESSAGE = 'ระบบ AI ไม่พร้อมใช้งานชั่วคราว กรุณากรอกข้อมูลด้วยตนเอง';
|
||||
|
||||
interface AiSuggestionButtonProps {
|
||||
aiEnabled: boolean;
|
||||
isLoading?: boolean;
|
||||
label?: string;
|
||||
disabledMessage?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
/** ปุ่มเรียก AI suggestion ที่แสดง fallback ชัดเจนเมื่อระบบ AI ปิด */
|
||||
export function AiSuggestionButton({
|
||||
aiEnabled,
|
||||
isLoading = false,
|
||||
label = 'AI Suggestion',
|
||||
disabledMessage = DEFAULT_DISABLED_MESSAGE,
|
||||
onClick,
|
||||
}: AiSuggestionButtonProps) {
|
||||
const disabled = !aiEnabled || isLoading;
|
||||
const button = (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
onClick={disabled ? undefined : onClick}
|
||||
className="gap-2"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (aiEnabled) return button;
|
||||
|
||||
return (
|
||||
<HoverCard openDelay={100}>
|
||||
<HoverCardTrigger asChild>
|
||||
<span className="inline-flex cursor-not-allowed">
|
||||
{button}
|
||||
<span className="sr-only">{disabledMessage}</span>
|
||||
</span>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="border-amber-200 bg-amber-50 text-amber-900">
|
||||
<p className="text-sm">{disabledMessage}</p>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import type { ReactElement } from 'react';
|
||||
import { CorrespondenceForm } from './form';
|
||||
import { createTestQueryClient } from '@/lib/test-utils';
|
||||
import {
|
||||
useProjects,
|
||||
useOrganizations,
|
||||
@@ -94,6 +96,11 @@ const editInitialData = {
|
||||
correspondenceNumber: 'CORR-001',
|
||||
};
|
||||
|
||||
const renderWithQueryClient = (ui: ReactElement) => {
|
||||
const { wrapper } = createTestQueryClient();
|
||||
return render(ui, { wrapper });
|
||||
};
|
||||
|
||||
describe('CorrespondenceForm (edit regression)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -140,7 +147,7 @@ describe('CorrespondenceForm (edit regression)', () => {
|
||||
});
|
||||
|
||||
it('keeps edit prefilled values after mount (no reset on initial render)', async () => {
|
||||
render(<CorrespondenceForm initialData={editInitialData} uuid="corr-uuid-1" />);
|
||||
renderWithQueryClient(<CorrespondenceForm initialData={editInitialData} uuid="corr-uuid-1" />);
|
||||
|
||||
expect(screen.getByLabelText('Subject *')).toHaveValue('Existing Subject');
|
||||
|
||||
@@ -156,7 +163,7 @@ describe('CorrespondenceForm (edit regression)', () => {
|
||||
});
|
||||
|
||||
it('keeps dependent fields intact after async effects (reset guard)', async () => {
|
||||
render(<CorrespondenceForm initialData={editInitialData} uuid="corr-uuid-2" />);
|
||||
renderWithQueryClient(<CorrespondenceForm initialData={editInitialData} uuid="corr-uuid-2" />);
|
||||
|
||||
expect(screen.getByLabelText('Subject *')).toHaveValue('Existing Subject');
|
||||
expect(screen.getByText('Current Document Number')).toBeInTheDocument();
|
||||
|
||||
@@ -14,12 +14,20 @@ import { useRouter } from 'next/navigation';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useCreateCorrespondence, useUpdateCorrespondence } from '@/hooks/use-correspondence';
|
||||
import { Organization } from '@/types/organization';
|
||||
import { useOrganizations, useProjects, useCorrespondenceTypes, useDisciplines, useContracts } from '@/hooks/use-master-data';
|
||||
import {
|
||||
useOrganizations,
|
||||
useProjects,
|
||||
useCorrespondenceTypes,
|
||||
useDisciplines,
|
||||
useContracts,
|
||||
} from '@/hooks/use-master-data';
|
||||
import { CreateCorrespondenceDto } from '@/types/dto/correspondence/create-correspondence.dto';
|
||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { numberingApi } from '@/lib/api/numbering';
|
||||
import { filesApi } from '@/lib/api/files';
|
||||
import { toast } from 'sonner';
|
||||
import { AiSuggestionButton } from '@/components/ai/ai-suggestion-button';
|
||||
import { useAiStatus } from '@/hooks/use-ai-status';
|
||||
|
||||
// Updated Zod Schema with all required fields
|
||||
const correspondenceSchema = z.object({
|
||||
@@ -155,6 +163,7 @@ export function CorrespondenceForm({
|
||||
const router = useRouter();
|
||||
const createMutation = useCreateCorrespondence();
|
||||
const updateMutation = useUpdateCorrespondence();
|
||||
const { data: aiStatus, isLoading: isAiStatusLoading } = useAiStatus();
|
||||
|
||||
// Fetch master data for dropdowns
|
||||
const { data: projectsData, isLoading: isLoadingProjects } = useProjects();
|
||||
@@ -170,7 +179,8 @@ export function CorrespondenceForm({
|
||||
? initialData?.revisions?.find((r) => normalizeUuid(r.publicId) === normalizedSelectedRevisionId)
|
||||
: undefined;
|
||||
const defaultValues = useMemo<Partial<FormData>>(() => {
|
||||
const currentRevision = selectedRevision || initialData?.revisions?.find((r) => r.isCurrent) || initialData?.revisions?.[0];
|
||||
const currentRevision =
|
||||
selectedRevision || initialData?.revisions?.find((r) => r.isCurrent) || initialData?.revisions?.[0];
|
||||
const initialToRecipient = initialData?.recipients?.find((r) => r.recipientType === 'TO');
|
||||
const initialCcRecipientIds =
|
||||
initialData?.recipients
|
||||
@@ -193,9 +203,15 @@ export function CorrespondenceForm({
|
||||
body: currentRevision?.body || '',
|
||||
remarks: currentRevision?.remarks || '',
|
||||
dueDate: currentRevision?.dueDate ? new Date(currentRevision.dueDate).toISOString().split('T')[0] : undefined,
|
||||
documentDate: currentRevision?.documentDate ? new Date(currentRevision.documentDate).toISOString().split('T')[0] : undefined,
|
||||
issuedDate: currentRevision?.issuedDate ? new Date(currentRevision.issuedDate).toISOString().split('T')[0] : undefined,
|
||||
receivedDate: currentRevision?.receivedDate ? new Date(currentRevision.receivedDate).toISOString().split('T')[0] : undefined,
|
||||
documentDate: currentRevision?.documentDate
|
||||
? new Date(currentRevision.documentDate).toISOString().split('T')[0]
|
||||
: undefined,
|
||||
issuedDate: currentRevision?.issuedDate
|
||||
? new Date(currentRevision.issuedDate).toISOString().split('T')[0]
|
||||
: undefined,
|
||||
receivedDate: currentRevision?.receivedDate
|
||||
? new Date(currentRevision.receivedDate).toISOString().split('T')[0]
|
||||
: undefined,
|
||||
fromOrganizationId:
|
||||
normalizePublicId(initialData?.originator?.publicId) ??
|
||||
normalizePublicId((initialData as Record<string, unknown>)?.originatorId as string),
|
||||
@@ -289,12 +305,15 @@ export function CorrespondenceForm({
|
||||
// Build recipients array with TO and CC
|
||||
const recipients = [
|
||||
{ organizationId: data.toOrganizationId, type: 'TO' as const },
|
||||
...(data.ccOrganizationIds?.map(orgId => ({ organizationId: orgId, type: 'CC' as const })) || [])
|
||||
...(data.ccOrganizationIds?.map((orgId) => ({ organizationId: orgId, type: 'CC' as const })) || []),
|
||||
];
|
||||
|
||||
// Phase 1: Upload attachments to temp storage
|
||||
let attachmentTempIds: string[] | undefined;
|
||||
const validFiles = (data.attachments || []).filter((f): f is File => f instanceof File && !('validationError' in f && (f as { validationError?: string }).validationError));
|
||||
const validFiles = (data.attachments || []).filter(
|
||||
(f): f is File =>
|
||||
f instanceof File && !('validationError' in f && (f as { validationError?: string }).validationError)
|
||||
);
|
||||
if (validFiles.length > 0) {
|
||||
setIsUploading(true);
|
||||
try {
|
||||
@@ -332,10 +351,7 @@ export function CorrespondenceForm({
|
||||
};
|
||||
|
||||
if (uuid && initialData) {
|
||||
updateMutation.mutate(
|
||||
{ uuid, data: payload },
|
||||
{ onSuccess: () => router.push(`/correspondences/${uuid}`) }
|
||||
);
|
||||
updateMutation.mutate({ uuid, data: payload }, { onSuccess: () => router.push(`/correspondences/${uuid}`) });
|
||||
} else {
|
||||
createMutation.mutate(payload, {
|
||||
onSuccess: () => router.push('/correspondences'),
|
||||
@@ -398,18 +414,10 @@ export function CorrespondenceForm({
|
||||
|
||||
{/* Preview Section - Only for New Documents */}
|
||||
{preview && !uuid && (
|
||||
<div
|
||||
className="p-4 rounded-md border bg-muted border-border"
|
||||
>
|
||||
<p className="text-sm font-semibold mb-1 flex items-center gap-2">
|
||||
Document Number Preview
|
||||
</p>
|
||||
<div className="p-4 rounded-md border bg-muted border-border">
|
||||
<p className="text-sm font-semibold mb-1 flex items-center gap-2">Document Number Preview</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className="text-xl font-bold font-mono tracking-wide text-primary"
|
||||
>
|
||||
{preview.number}
|
||||
</span>
|
||||
<span className="text-xl font-bold font-mono tracking-wide text-primary">{preview.number}</span>
|
||||
{preview.isDefaultTemplate && (
|
||||
<span className="text-[10px] uppercase font-semibold px-2 py-0.5 rounded-full bg-yellow-100 text-yellow-800 border border-yellow-200">
|
||||
Default Template
|
||||
@@ -575,7 +583,7 @@ export function CorrespondenceForm({
|
||||
<Label>CC Organizations (Optional)</Label>
|
||||
<div className="space-y-2 max-h-32 overflow-y-auto border rounded-md p-3">
|
||||
{organizationOptions
|
||||
.filter(org => org.publicId !== toOrgId) // Exclude TO organization
|
||||
.filter((org) => org.publicId !== toOrgId) // Exclude TO organization
|
||||
.map((org) => (
|
||||
<div key={org.publicId} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
@@ -586,7 +594,10 @@ export function CorrespondenceForm({
|
||||
if (checked) {
|
||||
setValue('ccOrganizationIds', [...currentCC, org.publicId]);
|
||||
} else {
|
||||
setValue('ccOrganizationIds', currentCC.filter(id => id !== org.publicId));
|
||||
setValue(
|
||||
'ccOrganizationIds',
|
||||
currentCC.filter((id) => id !== org.publicId)
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -596,15 +607,20 @@ export function CorrespondenceForm({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Select organizations to receive a copy of this correspondence
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Select organizations to receive a copy of this correspondence</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subject */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subject">Subject *</Label>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label htmlFor="subject">Subject *</Label>
|
||||
<AiSuggestionButton
|
||||
aiEnabled={aiStatus?.aiFeaturesEnabled ?? true}
|
||||
isLoading={isAiStatusLoading}
|
||||
onClick={() => toast.info('AI Suggestion queued')}
|
||||
/>
|
||||
</div>
|
||||
<Input id="subject" {...register('subject')} placeholder="Enter subject" />
|
||||
{errors.subject && <p className="text-sm text-destructive">{errors.subject.message}</p>}
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,9 @@ import { CreateRfaDto } from '@/types/dto/rfa/rfa.dto';
|
||||
import { useState, useEffect, type FormEvent } from 'react';
|
||||
import { correspondenceService } from '@/lib/services/correspondence.service';
|
||||
import { Contract } from '@/types/contract';
|
||||
import { AiSuggestionButton } from '@/components/ai/ai-suggestion-button';
|
||||
import { useAiStatus } from '@/hooks/use-ai-status';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const rfaSchema = z.object({
|
||||
projectId: z.string().min(1, 'Project is required'), // ADR-019: UUID
|
||||
@@ -145,6 +148,7 @@ const getMasterOptionValue = (option: { publicId?: string; id?: number }): strin
|
||||
export function RFAForm() {
|
||||
const router = useRouter();
|
||||
const createMutation = useCreateRFA();
|
||||
const { data: aiStatus, isLoading: isAiStatusLoading } = useAiStatus();
|
||||
|
||||
const { data: projectsData, isLoading: isLoadingProjects } = useProjects();
|
||||
const projects = dedupeByKey(extractArrayData<ProjectOption>(projectsData), (project) => project.publicId);
|
||||
@@ -192,12 +196,13 @@ export function RFAForm() {
|
||||
|
||||
const selectedContractId = watch('contractId');
|
||||
const { data: disciplinesData, isLoading: isLoadingDisciplines } = useDisciplines(selectedContractId);
|
||||
const disciplines = dedupeByKey(
|
||||
extractArrayData<DisciplineOption>(disciplinesData),
|
||||
(discipline) => getMasterOptionValue(discipline)
|
||||
const disciplines = dedupeByKey(extractArrayData<DisciplineOption>(disciplinesData), (discipline) =>
|
||||
getMasterOptionValue(discipline)
|
||||
);
|
||||
const { data: rfaTypesData, isLoading: isLoadingRfaTypes } = useRfaTypes(selectedContractId);
|
||||
const rfaTypes = dedupeByKey(extractArrayData<RfaTypeOption>(rfaTypesData), (rfaType) => getMasterOptionValue(rfaType));
|
||||
const rfaTypes = dedupeByKey(extractArrayData<RfaTypeOption>(rfaTypesData), (rfaType) =>
|
||||
getMasterOptionValue(rfaType)
|
||||
);
|
||||
const [shopDrawingSearch, setShopDrawingSearch] = useState('');
|
||||
const [shopDrawingPage, setShopDrawingPage] = useState(1);
|
||||
const { data: shopDrawingsData, isLoading: isLoadingShopDrawings } = useDrawings('SHOP', {
|
||||
@@ -286,7 +291,15 @@ export function RFAForm() {
|
||||
|
||||
const timer = setTimeout(fetchPreview, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [rfaTypeId, disciplineId, toOrganizationId, selectedProjectId, rfaCorrespondenceType?.publicId, rfaCorrespondenceType?.id, watch]);
|
||||
}, [
|
||||
rfaTypeId,
|
||||
disciplineId,
|
||||
toOrganizationId,
|
||||
selectedProjectId,
|
||||
rfaCorrespondenceType?.publicId,
|
||||
rfaCorrespondenceType?.id,
|
||||
watch,
|
||||
]);
|
||||
|
||||
const onSubmit = (data: RFAFormData) => {
|
||||
if (requiresShopDrawings && data.shopDrawingRevisionIds?.length === 0) {
|
||||
@@ -346,7 +359,7 @@ export function RFAForm() {
|
||||
<h3 className="text-lg font-semibold mb-4">RFA Information</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div>
|
||||
<Label>Project *</Label>
|
||||
<Select
|
||||
value={selectedProjectId || undefined}
|
||||
@@ -429,7 +442,7 @@ export function RFAForm() {
|
||||
<SelectValue placeholder={isLoadingDisciplines ? 'Loading...' : 'Select Discipline'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{disciplines.map((d) => (
|
||||
{disciplines.map((d) =>
|
||||
(() => {
|
||||
const disciplineValue = getMasterOptionValue(d);
|
||||
|
||||
@@ -443,7 +456,7 @@ export function RFAForm() {
|
||||
</SelectItem>
|
||||
);
|
||||
})()
|
||||
))}
|
||||
)}
|
||||
{!isLoadingDisciplines && disciplines.length === 0 && (
|
||||
<SelectItem value="0" disabled>
|
||||
No disciplines found
|
||||
@@ -521,7 +534,14 @@ export function RFAForm() {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="subject">Subject *</Label>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label htmlFor="subject">Subject *</Label>
|
||||
<AiSuggestionButton
|
||||
aiEnabled={aiStatus?.aiFeaturesEnabled ?? true}
|
||||
isLoading={isAiStatusLoading}
|
||||
onClick={() => toast.info('AI Suggestion queued')}
|
||||
/>
|
||||
</div>
|
||||
<Input id="subject" {...register('subject')} placeholder="Enter subject" />
|
||||
{errors.subject && <p className="text-sm text-destructive mt-1">{errors.subject.message}</p>}
|
||||
</div>
|
||||
@@ -540,8 +560,6 @@ export function RFAForm() {
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Input id="description" {...register('description')} placeholder="Enter key description" />
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// File: hooks/use-ai-status.ts
|
||||
// Change Log
|
||||
// - 2026-05-21: เพิ่ม TanStack Query hook สำหรับ polling สถานะ AI features.
|
||||
// - 2026-05-21: เพิ่ม `useAiHealth` hook สำหรับ polling ข้อมูลสุขภาพของระบบ AI (T031).
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import apiClient from '@/lib/api/client';
|
||||
import { adminAiService } from '@/lib/services/admin-ai.service';
|
||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||
|
||||
export const AI_STATUS_QUERY_KEY = ['ai', 'admin-settings'] as const;
|
||||
export const AI_HEALTH_QUERY_KEY = ['ai', 'admin-health'] as const;
|
||||
const AI_PERMISSION_QUERY_KEY = ['users', 'me', 'ai-permissions'] as const;
|
||||
const AI_PERMISSIONS = ['ai.suggest', 'ai.rag_query', 'rag.query', 'ai.extract'];
|
||||
|
||||
const extractArrayData = <T>(value: unknown): T[] => {
|
||||
let current: unknown = value;
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
if (Array.isArray(current)) return current as T[];
|
||||
if (!current || typeof current !== 'object' || !('data' in current)) {
|
||||
return [];
|
||||
}
|
||||
current = (current as { data?: unknown }).data;
|
||||
}
|
||||
return Array.isArray(current) ? (current as T[]) : [];
|
||||
};
|
||||
|
||||
/** Poll สถานะเปิด/ปิด AI features สำหรับ admin console และ soft fallback */
|
||||
export function useAiStatus(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: AI_STATUS_QUERY_KEY,
|
||||
queryFn: adminAiService.getStatus,
|
||||
enabled,
|
||||
refetchInterval: enabled ? 30_000 : false,
|
||||
staleTime: 5_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Poll สถานะ AI เฉพาะผู้ใช้ปัจจุบันที่มี AI permissions */
|
||||
export function useCurrentUserAiStatus() {
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||
const storedPermissions = useAuthStore((state) => state.user?.permissions);
|
||||
const permissionQuery = useQuery({
|
||||
queryKey: AI_PERMISSION_QUERY_KEY,
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get<unknown>('/users/me/permissions');
|
||||
return extractArrayData<string>(response.data);
|
||||
},
|
||||
enabled: isAuthenticated && !storedPermissions,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
const permissions = storedPermissions ?? permissionQuery.data ?? [];
|
||||
const hasAiPermission = permissions.some((permission) => AI_PERMISSIONS.includes(permission));
|
||||
const statusQuery = useAiStatus(isAuthenticated && hasAiPermission);
|
||||
return {
|
||||
...statusQuery,
|
||||
isLoading: permissionQuery.isLoading || statusQuery.isLoading,
|
||||
data: statusQuery.data
|
||||
? {
|
||||
...statusQuery.data,
|
||||
hasAiPermission,
|
||||
shouldShowBanner: hasAiPermission && statusQuery.data.aiFeaturesEnabled === false,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Mutation สำหรับ Superadmin เปิด/ปิด AI features */
|
||||
export function useToggleAiFeatures() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (enabled: boolean) => adminAiService.toggleFeatures(enabled),
|
||||
onSuccess: (settings) => {
|
||||
queryClient.setQueryData(AI_STATUS_QUERY_KEY, settings);
|
||||
queryClient.invalidateQueries({ queryKey: AI_STATUS_QUERY_KEY });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Hook สำหรับดึงสถานะสุขภาพและความเร็วของระบบ AI (Ollama, Qdrant, queues) */
|
||||
export function useAiHealth(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: AI_HEALTH_QUERY_KEY,
|
||||
queryFn: adminAiService.getHealth,
|
||||
enabled,
|
||||
refetchInterval: enabled ? 30_000 : false,
|
||||
staleTime: 5_000,
|
||||
});
|
||||
}
|
||||
@@ -107,13 +107,21 @@ export interface ApiErrorResponse {
|
||||
error: ApiErrorPayload;
|
||||
}
|
||||
|
||||
export const AI_FEATURES_UNAVAILABLE_EVENT = 'ai-features-unavailable';
|
||||
|
||||
// แปลง Axios error เป็น Structured Error Response (ADR-007)
|
||||
export function parseApiError(axiosError: AxiosError): ApiErrorResponse {
|
||||
if (axiosError.response?.data) {
|
||||
const data = axiosError.response.data;
|
||||
// กรณีที่ backend ส่ง { error: { ... } } ตาม ADR-007
|
||||
if (typeof data === 'object' && data !== null && 'error' in data) {
|
||||
return data as ApiErrorResponse;
|
||||
const parsed = data as ApiErrorResponse;
|
||||
return {
|
||||
error: {
|
||||
...parsed.error,
|
||||
statusCode: axiosError.response.status,
|
||||
},
|
||||
};
|
||||
}
|
||||
// กรณี NestJS validation error { message: [...], statusCode: 400 }
|
||||
if (typeof data === 'object' && data !== null && 'message' in data) {
|
||||
@@ -181,6 +189,17 @@ apiClient.interceptors.response.use(
|
||||
}
|
||||
// แปลง error เป็น structured format ตาม ADR-007 ก่อน reject
|
||||
const structuredError = parseApiError(error);
|
||||
if (
|
||||
structuredError.error.statusCode === 503 &&
|
||||
structuredError.error.code === 'AI_FEATURES_UNAVAILABLE' &&
|
||||
typeof window !== 'undefined'
|
||||
) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(AI_FEATURES_UNAVAILABLE_EVENT, {
|
||||
detail: structuredError.error,
|
||||
})
|
||||
);
|
||||
}
|
||||
return Promise.reject(structuredError);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// File: lib/services/admin-ai.service.ts
|
||||
// Change Log
|
||||
// - 2026-05-21: เพิ่ม service สำหรับ AI Admin Console toggle API.
|
||||
// - 2026-05-21: เพิ่ม service method `getHealth` สำหรับดึงข้อมูลสุขภาพของระบบ AI (T028).
|
||||
// - 2026-05-21: เพิ่ม API service สำหรับ Superadmin Sandbox RAG (T037).
|
||||
// - 2026-05-21: เพิ่ม service method `submitSandboxExtract` สำหรับอัปโหลดไฟล์ใน OCR Sandbox (T043).
|
||||
|
||||
import api from '../api/client';
|
||||
|
||||
export interface AiAdminSettings {
|
||||
aiFeaturesEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface QueueMetrics {
|
||||
active?: number;
|
||||
waiting?: number;
|
||||
failed?: number;
|
||||
completed?: number;
|
||||
isPaused?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface AiSystemHealth {
|
||||
ollama: {
|
||||
status: 'HEALTHY' | 'DEGRADED' | 'DOWN';
|
||||
latencyMs: number;
|
||||
models: string[];
|
||||
error?: string;
|
||||
};
|
||||
qdrant: {
|
||||
status: 'HEALTHY' | 'DEGRADED' | 'DOWN';
|
||||
latencyMs: number;
|
||||
collections?: string[];
|
||||
error?: string;
|
||||
};
|
||||
queues: {
|
||||
realtime: QueueMetrics;
|
||||
batch: QueueMetrics;
|
||||
};
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface AiRagCitation {
|
||||
pointId: string | number;
|
||||
score: number;
|
||||
docType?: string;
|
||||
docNumber?: string;
|
||||
snippet?: string;
|
||||
}
|
||||
|
||||
export interface AiSandboxJobResult {
|
||||
requestPublicId: string;
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled' | 'not_found';
|
||||
answer?: string;
|
||||
citations?: AiRagCitation[];
|
||||
confidence?: number;
|
||||
usedFallbackModel?: boolean;
|
||||
errorMessage?: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
const extractData = <T>(value: unknown): T => {
|
||||
if (value && typeof value === 'object' && 'data' in value) {
|
||||
return (value as { data: T }).data;
|
||||
}
|
||||
return value as T;
|
||||
};
|
||||
|
||||
/** Service สำหรับเรียก AI Admin Console API ผ่าน DMS Backend เท่านั้น */
|
||||
export const adminAiService = {
|
||||
getStatus: async (): Promise<AiAdminSettings> => {
|
||||
const { data } = await api.get('/ai/status');
|
||||
return extractData<AiAdminSettings>(data);
|
||||
},
|
||||
getSettings: async (): Promise<AiAdminSettings> => {
|
||||
const { data } = await api.get('/ai/admin/settings');
|
||||
return extractData<AiAdminSettings>(data);
|
||||
},
|
||||
toggleFeatures: async (enabled: boolean): Promise<AiAdminSettings> => {
|
||||
const { data } = await api.post('/ai/admin/toggle', { enabled });
|
||||
return extractData<AiAdminSettings>(data);
|
||||
},
|
||||
getHealth: async (): Promise<AiSystemHealth> => {
|
||||
const { data } = await api.get('/ai/admin/health');
|
||||
return extractData<AiSystemHealth>(data);
|
||||
},
|
||||
submitSandboxRag: async (
|
||||
projectPublicId: string,
|
||||
question: string
|
||||
): Promise<{ requestPublicId: string; jobId: string; status: string }> => {
|
||||
const { data } = await api.post('/ai/admin/sandbox/rag', {
|
||||
projectPublicId,
|
||||
question,
|
||||
});
|
||||
return extractData<{ requestPublicId: string; jobId: string; status: string }>(data);
|
||||
},
|
||||
getSandboxJobStatus: async (id: string): Promise<AiSandboxJobResult> => {
|
||||
const { data } = await api.get(`/ai/admin/sandbox/job/${id}`);
|
||||
return extractData<AiSandboxJobResult>(data);
|
||||
},
|
||||
submitSandboxExtract: async (
|
||||
file: File
|
||||
): Promise<{ requestPublicId: string; jobId: string; status: string }> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const { data } = await api.post('/ai/admin/sandbox/extract', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
return extractData<{ requestPublicId: string; jobId: string; status: string }>(data);
|
||||
},
|
||||
};
|
||||
@@ -41,6 +41,7 @@
|
||||
|
||||
"ai.status.offlineTitle": "AI unavailable",
|
||||
"ai.status.offlineDescription": "AI staging is temporarily unavailable. Manual document operations remain available.",
|
||||
"ai.status.disabledDescription": "AI is temporarily unavailable. Please enter the information manually.",
|
||||
"ai.status.onlineTitle": "AI staging available",
|
||||
"ai.status.onlineDescription": "Legacy migration review queue is connected.",
|
||||
"ai.staging.title": "AI Staging Queue",
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
|
||||
"ai.status.offlineTitle": "ระบบ AI ไม่พร้อมใช้งาน",
|
||||
"ai.status.offlineDescription": "ไม่สามารถเชื่อมต่อ staging queue ของ AI ได้ชั่วคราว แต่ยังทำงานเอกสารแบบ manual ได้ตามปกติ",
|
||||
"ai.status.disabledDescription": "ระบบ AI ไม่พร้อมใช้งานชั่วคราว กรุณากรอกข้อมูลด้วยตนเอง",
|
||||
"ai.status.onlineTitle": "ระบบ AI พร้อมใช้งาน",
|
||||
"ai.status.onlineDescription": "เชื่อมต่อคิวตรวจสอบข้อมูลเอกสารเก่าเรียบร้อยแล้ว",
|
||||
"ai.staging.title": "คิวตรวจสอบ AI",
|
||||
|
||||
@@ -110,6 +110,43 @@ CREATE TABLE refresh_tokens (
|
||||
FOREIGN KEY (user_id) REFERENCES users (user_id) ON DELETE CASCADE
|
||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตารางเก็บ Refresh Tokens สำหรับ Authentication';
|
||||
|
||||
-- ตารางเก็บข้อมูลการตั้งค่าระบบไดนามิก (ADR-027)
|
||||
CREATE TABLE system_settings (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง',
|
||||
setting_key VARCHAR(100) NOT NULL UNIQUE COMMENT 'คีย์การตั้งค่าระบบ (เช่น AI_FEATURES_ENABLED, MAX_UPLOAD_SIZE)',
|
||||
setting_value TEXT NOT NULL COMMENT 'ค่าที่บันทึก (stringified)',
|
||||
data_type ENUM('string', 'number', 'boolean', 'json') NOT NULL DEFAULT 'string' COMMENT 'ประเภทข้อมูลสำหรับ validation',
|
||||
category VARCHAR(50) COMMENT 'หมวดหมู่ (เช่น ai, security, storage, notification)',
|
||||
is_encrypted TINYINT(1) DEFAULT 0 COMMENT 'เข้ารหัสค่า sensitive',
|
||||
validation_rules JSON COMMENT 'กฎ validation (min, max, allowed_values)',
|
||||
description TEXT COMMENT 'คำอธิบายข้อมูลการตั้งค่า',
|
||||
is_public TINYINT(1) DEFAULT 0 COMMENT 'เผยแพร่ให้ frontend อ่านได้หรือ admin only',
|
||||
updated_by INT COMMENT 'ผู้แก้ไขล่าสุด',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (updated_by) REFERENCES users(user_id) ON DELETE SET NULL,
|
||||
INDEX idx_system_settings_category (category),
|
||||
INDEX idx_system_settings_is_public (is_public)
|
||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตารางเก็บข้อมูลการตั้งค่าระบบไดนามิก';
|
||||
|
||||
INSERT INTO system_settings (
|
||||
setting_key,
|
||||
setting_value,
|
||||
data_type,
|
||||
category,
|
||||
description,
|
||||
is_public
|
||||
)
|
||||
VALUES (
|
||||
'AI_FEATURES_ENABLED',
|
||||
'true',
|
||||
'boolean',
|
||||
'ai',
|
||||
'สถานะเปิด/ปิดการใช้งานฟีเจอร์ AI ทั้งระบบ สำหรับผู้ใช้ทั่วไป',
|
||||
1
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE setting_key = setting_key;
|
||||
|
||||
-- ตาราง Master เก็บ "บทบาท" ของผู้ใช้ในระบบ
|
||||
CREATE TABLE roles (
|
||||
role_id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง',
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
# ADR-027: AI Admin Panel and Dynamic Control Architecture
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-05-20
|
||||
**Decision Makers:** Development Team, System Architect, DevOps Engineer
|
||||
**Related Documents:**
|
||||
- [CONTEXT-ADR-027: AI Admin Panel Development Plan](./CONTEXT-ADR-027.md)
|
||||
- [ADR-023A: Unified AI Architecture — Model Revision](./ADR-023A-unified-ai-architecture.md)
|
||||
- [ADR-019: Hybrid Identifier Strategy](./ADR-019-hybrid-identifier-strategy.md)
|
||||
- [ADR-016: Security & Authentication](./ADR-016-security-authentication.md)
|
||||
- [ADR-009: Database Migration Strategy](./ADR-009-database-migration-strategy.md)
|
||||
- [ADR-008: Email & Notification Strategy (BullMQ)](./ADR-008-email-notification-strategy.md)
|
||||
|
||||
> **หมายเหตุ:** ADR นี้กำหนดสถาปัตยกรรมการพัฒนาแผงควบคุมระบบ AI (AI Admin Panel) สำหรับสิทธิ์ **Superadmin** เท่านั้น เพื่อใช้ในการควบคุมความพร้อมใช้งานของบริการ AI แบบ Dynamic, ตรวจสอบสุขภาพระบบโครงสร้างพื้นฐาน (Ollama/Qdrant/BullMQ) และการรัน Sandbox ทดสอบภายใต้สภาพแวดล้อมที่ควบคุมความปลอดภัยสูงสุด
|
||||
|
||||
---
|
||||
|
||||
## บริบทและปัญหา (Context and Problem Statement)
|
||||
|
||||
เนื่องจากระบบปัญญาประดิษฐ์ของโครงการ LCBP3 DMS (Ollama & Qdrant) รันอยู่บนสภาพแวดล้อมแบบ On-premises บนเครื่อง AI Host (`Desk-5439`) ซึ่งมีความเสี่ยงที่จะเกิดเหตุสุดวิสัย เช่น เครื่องล่ม, Latency สูงขึ้นอย่างผิดปกติจากการประมวลผลงานชุดใหญ่ หรือมีความจำเป็นต้องปิดปรับปรุง Prompt หรือตัวโมเดลชั่วคราว
|
||||
|
||||
ปัญหากลุ่มนี้ทำให้ระบบต้องการกลไกควบคุมและติดตามดังนี้:
|
||||
1. **Dynamic Switch:** แอดมินจำเป็นต้องสั่งปิดการให้บริการ AI แก่ผู้ใช้ปกติได้ทันทีโดยไม่ต้องรัน Build หรือ Restart เซิร์ฟเวอร์
|
||||
2. **Graceful Degradation:** เมื่อปิดระบบ AI, หน้าจอของผู้ใช้ปกติและ API จะต้องปิดตัวลงอย่างสง่างาม ไม่โยนข้อผิดพลาดแปลกๆ ที่ไม่เป็นมิตรต่อผู้ใช้
|
||||
3. **Isolated Test Laboratory:** ในขณะที่ AI ถูกปิดปรับปรุง แอดมินยังคงต้องการพื้นที่ Sandbox ในการทดสอบประมวลผลจริงเพื่อปรับปรุงความถูกต้อง โดยงานประมวลผลของแอดมินจะต้องไม่ถูกรบกวนจากงานตกค้างของผู้ใช้ทั่วไป หรือทำตัวโมเดลล่ม
|
||||
|
||||
---
|
||||
|
||||
## ปัจจัยขับเคลื่อนการตัดสินใจ (Decision Drivers)
|
||||
|
||||
- **Security Isolation (Tier 1):** แผงควบคุมและ Sandbox ทั้งหมดต้องควบคุมสิทธิ์อย่างเหนียวแน่นสำหรับสิทธิ์ Superadmin เท่านั้น (`system.manage_all`)
|
||||
- **Latency-free Status Check:** การตรวจสอบสวิตช์เปิด/ปิด AI ใน API ผู้ใช้ภายนอกต้องไม่มี Overhead ในการคิวรีฐานข้อมูลตลอดเวลา
|
||||
- **User Experience (UX):** หน้าจอผู้ใช้ปกติในฟอร์มเอกสารต้องตอบสนองได้อย่างนุ่มนวล (Soft Fallback) เมื่อ AI ถูกปิด แทนการกดปุ่มแล้วแจ้งเตือนข้อผิดพลาดสีแดง
|
||||
- **Resource Protection:** การรัน Playground Sandbox ของแอดมินจะต้องไม่ก่อให้เกิด Race Condition หรือโหลดกระแทกบน VRAM ของ GPU RTX 2060 Super (8GB) บนเครื่อง `Desk-5439`
|
||||
|
||||
---
|
||||
|
||||
## ทางเลือกที่ถูกพิจารณา (Considered Options)
|
||||
|
||||
### Option A: Synchronous Direct Sandbox & API Hard Block
|
||||
- สั่งรัน RAG และ OCR Sandbox ของแอดมินตรงเข้าสู่ API Controller แบบ Synchronous โดยตรง (ไม่ผ่านคิว BullMQ) และเมื่อสวิตช์เปิด/ปิด AI ถูกตั้งค่าเป็นปิดใช้งาน จะทำการซ่อนปุ่มสกัดข้อมูลทั้งหมดในหน้าผู้ใช้ทั่วไปทันที
|
||||
|
||||
### Option B: Shared BullMQ Queue & Soft Fallback (ตัวเลือกที่ได้รับเลือก)
|
||||
- สั่งรัน Sandbox ของแอดมินผ่านคิว `ai-batch` ที่มีอยู่แล้ว (ตาม ADR-023A) โดยใช้ job type `sandbox-rag` และ `sandbox-extract` พร้อม priority สูงกว่างาน batch ปกติ
|
||||
- จัดทำตาราง `system_settings` โดยเพิ่มลงใน schema file หลัก (ตาม ADR-009) ร่วมกับ Redis Cache และใช้กลไก Polling (ทุก 30 วินาที) ของ Frontend เพื่ออัปเดตสถานะปุ่ม AI Suggestion บนฟอร์มเป็นสถานะ **Disabled (ใช้งานไม่ได้)** พร้อมแสดงข้อความอธิบายความจำเป็นเมื่อชี้เมาส์ (Hover Tooltip)
|
||||
|
||||
---
|
||||
|
||||
## ผลการตัดสินใจ (Decision Outcome)
|
||||
|
||||
**ทางเลือกที่ได้รับเลือก:** **Option B**
|
||||
เนื่องจากเหตุผลความเสถียรของระบบ VRAM และประสบการณ์การใช้งานที่ดียิ่งขึ้นของผู้ใช้งานทั่วไป (UX) โดยมีตารางวิเคราะห์เปรียบเทียบดังนี้:
|
||||
|
||||
| เกณฑ์การประเมิน | Option A (Direct) | Option B (Shared Queue) |
|
||||
| :--- | :--- | :--- |
|
||||
| **ความเสถียรของ VRAM บน Desk-5439** | ❌ เสี่ยงล่มหากแอดมินรันโหลดหนักชนกับ Queue ปกติ | ✅ ปลอดภัยสูงสุด ควบคุม Concurrency ของ ai-batch queue ตาม ADR-023A (concurrency=1) |
|
||||
| **ประสบการณ์การใช้งานทั่วไป (UX)** | ❌ ปุ่มหายกะทันหัน สร้างความสับสนว่าฟีเจอร์หายไปไหน | ✅ แสดงปุ่ม disabled + Tooltip ชี้แจง ทำให้เกิดความเข้าใจและเป็นมิตร |
|
||||
| **การจำลองโหลดการทำงานจริง** | ❌ ไม่มีการเข้าคิว ไม่สะท้อนความเร็วจริงในสถานการณ์จริง | ✅ สะท้อนพฤติกรรมความเร็วจริงของคิวและ VRAM ได้แม่นยำ 100% |
|
||||
| **ประสิทธิภาพของ Backend API** | ❌ เช็ค DB ทุกครั้งสร้าง Overhead | ✅ เช็คผ่าน Redis Cache คืนสถานะภายใน <1ms |
|
||||
| **ความสอดคล้องกับ ADR-023A** | ❌ ไม่สอดคล้องกับ 2-Queue Architecture | ✅ สอดคล้องกับ ADR-023A (ใช้ ai-batch queue ร่วมกัน) |
|
||||
|
||||
---
|
||||
|
||||
## รายละเอียดเชิงสถาปัตยกรรม (Implementation Details)
|
||||
|
||||
### 1. โครงสร้างข้อมูลตาราง `system_settings` (Refined)
|
||||
ระบบจะนำเสนอตารางเก็บข้อมูลการตั้งค่าระบบแบบรวมศูนย์ (generic) เพื่อรองรับ settings อื่นๆ ในอนาคต (ตามมาตรฐาน ADR-009) ดังนี้:
|
||||
- **Persistence Layer:** เพิ่มตาราง `system_settings` ใน `specs/03-Data-and-Storage/lcbp3-v1.9.0-schema-02-tables.sql` โดยตรง (ไม่ใช้ migration file แยก)
|
||||
- **Caching Layer:** จัดเก็บค่าแยกเป็น Redis Key ต่อ setting (เช่น `system_settings:AI_FEATURES_ENABLED`, `system_settings:MAX_UPLOAD_SIZE`) เพื่อให้อ่านค่าได้เร็วในระดับไมโครวินาที (Microseconds) เมื่อ API Guard เรียกตรวจสอบ
|
||||
|
||||
```
|
||||
[Client App] ---> [API Guard] ---> [Redis Cache (Key: system_settings:AI_FEATURES_ENABLED)]
|
||||
|
|
||||
+--(Miss)--> [MariaDB (system_settings)]
|
||||
```
|
||||
|
||||
**Schema Design (Generic):**
|
||||
```sql
|
||||
CREATE TABLE system_settings (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง',
|
||||
setting_key VARCHAR(100) NOT NULL UNIQUE COMMENT 'คีย์การตั้งค่าระบบ (เช่น AI_FEATURES_ENABLED, MAX_UPLOAD_SIZE)',
|
||||
setting_value TEXT NOT NULL COMMENT 'ค่าที่บันทึก (stringified)',
|
||||
data_type ENUM('string', 'number', 'boolean', 'json') NOT NULL DEFAULT 'string' COMMENT 'ประเภทข้อมูลสำหรับ validation',
|
||||
category VARCHAR(50) COMMENT 'หมวดหมู่ (เช่น ai, security, storage, notification)',
|
||||
is_encrypted TINYINT(1) DEFAULT 0 COMMENT 'เข้ารหัสค่า sensitive (เช่น API keys)',
|
||||
validation_rules JSON COMMENT 'กฎ validation (min, max, allowed_values)',
|
||||
description TEXT COMMENT 'คำอธิบายข้อมูลการตั้งค่า',
|
||||
is_public TINYINT(1) DEFAULT 0 COMMENT 'เผยแพร่ให้ frontend อ่านได้ (หรือ admin only)',
|
||||
updated_by INT COMMENT 'ผู้แก้ไขล่าสุด',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (updated_by) REFERENCES users(user_id) ON DELETE SET NULL,
|
||||
INDEX idx_category (category),
|
||||
INDEX idx_is_public (is_public)
|
||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตารางเก็บข้อมูลการตั้งค่าระบบไดนามิก';
|
||||
```
|
||||
|
||||
### 2. ระบบคิว Sandbox ร่วมกัน (Shared Queue)
|
||||
ระบบจะใช้คิว `ai-batch` ที่มีอยู่แล้ว (ตาม ADR-023A) สำหรับงาน Sandbox ของแอดมิน โดย:
|
||||
- เพิ่ม job type `sandbox-rag` สำหรับคำถาม RAG ใน Playground
|
||||
- เพิ่ม job type `sandbox-extract` สำหรับ OCR/Extraction ใน Sandbox
|
||||
- ใช้ priority **SUPERADMIN** (ระดับใหม่ higher than HIGH) สำหรับงาน Sandbox เพื่อให้ได้รับการประมวลผลก่อนงาน batch ปกติโดยไม่ jump queue
|
||||
- Processor ใน `ai-batch.processor.ts` จะจัดการ job types เหล่านี้เพิ่มเติม
|
||||
- Concurrency คงที่ที่ 1 ตาม ADR-023A เพื่อป้องกัน VRAM overload
|
||||
- **Dynamic Rate Limiting:** ตรวจสอบความยาวคิว `ai-batch` ก่อน allow request (queue length < 3 → no limit, queue length ≥ 3 → 10 requests/hour)
|
||||
|
||||
### 3. มาตรการควบคุมสิทธิ์ (Security Controls)
|
||||
- การสลับสวิตช์ AI และการยิง Sandbox Endpoints ทั้งหมดจะถูกปิดกั้นอย่างเข้มงวดด้วยการเช็ค JWT Token และการใช้ `@RequirePermission('system.manage_all')` (CASL Guard)
|
||||
- **AiEnabledGuard Layered Check:** Superadmin ต้องมีทั้ง `system.manage_all` **และ** `ai.suggest`/`ai.rag_query` เพื่อ bypass เมื่อ AI disabled
|
||||
- **Admin Endpoints:** ไม่ใช้ AiEnabledGuard (ใช้ permission guard `system.manage_all` เพียงพอ)
|
||||
- **Job Polling:** ไม่ block job status requests (audit trail ไม่ใช่ AI inference)
|
||||
- ห้ามระบุ ID หลักเป็น Integer PK ในการทำงาน (เช่น การทดสอบ RAG หรือ Sandbox ประมวลผล) แต่จะใช้ UUIDv7 `publicId` ในการระบุโครงการและจัดกลุ่มเสมอตามข้อตกลง **ADR-019 (Hybrid Identifier Strategy)**
|
||||
|
||||
---
|
||||
|
||||
## Grilling Session Decisions (2026-05-21)
|
||||
|
||||
การตัดสินใจต่อไปนี้ได้รับการ refine ผ่าน grilling session เพื่อความชัดเจนและความพร้อมในการ implement:
|
||||
|
||||
| # | ประเด็น | การตัดสินใจ |
|
||||
|---|---------|--------------|
|
||||
| 1 | Infrastructure Dependency | ADR-023A infrastructure มีอยู่แล้ว (ai-realtime, ai-batch, permissions) ✅ |
|
||||
| 2 | system_settings Schema | Generic พร้อม `data_type`, `category`, `is_encrypted`, `validation_rules`, `is_public` |
|
||||
| 3 | Redis Cache Strategy | Cache แยก key ต่อ setting (เช่น `system_settings:AI_FEATURES_ENABLED`) |
|
||||
| 4 | Security Controls | Dynamic rate limiting ขึ้นกับ queue length (queue < 3 → no limit, queue ≥ 3 → 10 req/hr) |
|
||||
| 5 | Frontend Polling | Poll เฉพาะ users ที่มี AI permissions (ทุก 30 วินาที) |
|
||||
| 6 | AiEnabledGuard | Layered check (system.manage_all + ai.suggest/ai.rag_query) |
|
||||
| 7 | Error Handling | HTTP 503 + rate-limited warn logs (10 req/user/min) + custom banner debounce 5s |
|
||||
| 8 | Cache Invalidation | Invalid หลัง DB success (TypeORM transaction) + single key + ยอมรับ 30s latency |
|
||||
| 9 | Sandbox Priority | Priority ระดับใหม่ `SUPERADMIN` (higher than HIGH) |
|
||||
| 10 | Health Check | 5s timeout per service + 30s cache + basic queue metrics (waiting, active, failed, rate) |
|
||||
| 11 | UI/UX | Single page layout + 5s job polling + inline error (red box) + toast |
|
||||
| 12 | Implementation Priority | Phased (backend → frontend) |
|
||||
|
||||
---
|
||||
|
||||
## Refined Implementation Details
|
||||
|
||||
### 4. AiEnabledGuard Implementation
|
||||
**Logic:**
|
||||
```typescript
|
||||
const aiEnabled = await this.getAiFeaturesEnabled(); // from Redis/DB
|
||||
const isSuperadmin = user.permissions.includes('system.manage_all');
|
||||
const hasAiPermission = user.permissions.includes('ai.suggest') || user.permissions.includes('ai.rag_query');
|
||||
|
||||
if (!aiEnabled && !(isSuperadmin && hasAiPermission)) {
|
||||
throw new ServiceUnavailableException({
|
||||
message: 'AI features are temporarily unavailable',
|
||||
userMessage: 'ระบบ AI ไม่พร้อมใช้งานชั่วคราว กรุณากรอกข้อมูลด้วยตนเอง',
|
||||
recoveryAction: 'ติดต่อผู้ดูแลระบบหากต้องการความช่วยเหลือ'
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Error Response (ADR-007):**
|
||||
- HTTP Status: `503 Service Unavailable`
|
||||
- Logging: `warn` level แต่ rate limit (log ทุก 10 ครั้งต่อ user ต่อนาที)
|
||||
- Frontend: Custom Global Banner + debounce 5 วินาที
|
||||
|
||||
### 5. Cache Invalidation Strategy
|
||||
**Timing:** Invalid Redis cache หลัง DB update success (TypeORM transaction)
|
||||
**Scope:** Invalid เฉพาะ key `system_settings:AI_FEATURES_ENABLED` (efficient)
|
||||
**Frontend Sync:** ยอมรับ latency 30 วินาที (polling strategy เพียงพอสำหรับ use case นี้)
|
||||
|
||||
### 6. Health Check Service
|
||||
**Timeout:** 5 วินาที per service → timeout return `DEGRADED` (not `DOWN`)
|
||||
**Frequency:** Cache 30 วินาที (synchronized กับ AI status polling)
|
||||
**Queue Metrics:** Basic metrics (waiting, active, failed) + processing rate (jobs/second)
|
||||
**Services:** Ollama (Desk-5439), Qdrant (Desk-5439), BullMQ (ai-realtime, ai-batch)
|
||||
|
||||
### 7. Frontend Polling Strategy
|
||||
**Condition:** Poll เฉพาะ users ที่มี `ai.suggest` หรือ `ai.rag_query` permission
|
||||
**Frequency:** ทุก 30 วินาที
|
||||
**Cache:** React Context + refresh on mount
|
||||
**Implementation:** `useAiStatus()` hook ใน `SessionProvider`
|
||||
|
||||
### 8. Admin Console UI/UX
|
||||
**Layout:** Single page พร้อม tabs (RAG Playground / OCR Sandbox)
|
||||
**Job Polling:** 5 วินาที (reasonable balance ระหว่าง real-time และ performance)
|
||||
**Error Display:** Inline error ใน output area (red box) + toast notification
|
||||
**Style:** Glassmorphism + Health Indicators + Header Switch
|
||||
@@ -0,0 +1,252 @@
|
||||
# แผนการพัฒนา: AI Admin Panel (สำหรับสิทธิ์ Superadmin เท่านั้น)
|
||||
|
||||
แผนงานนี้จัดทำขึ้นเพื่อแสดงแนวทางการพัฒนาและติดตั้งระบบ **AI Admin Panel** เพื่อให้ผู้ดูแลระบบสูงสุด (Superadmin) สามารถตรวจสอบสถานะการทำงานของเครื่อง AI Host (`Desk-5439`), เปิด/ปิดการใช้งานฟีเจอร์ AI สำหรับผู้ใช้ทั่วไปได้แบบไดนามิก, ตรวจสอบคิวงานของ BullMQ และเวกเตอร์ใน Qdrant รวมถึงมีห้องทดสอบ (Playground Sandbox) ส่วนตัวสำหรับประมวลผล RAG และสกัด Metadata ของเอกสาร
|
||||
|
||||
---
|
||||
|
||||
## 🎯 วัตถุประสงค์และข้อกำหนดทางเทคนิค
|
||||
|
||||
1. **การตรวจสอบสถานะระบบ AI (Health Check):**
|
||||
- พัฒนาระบบตรวจสอบสุขภาพการเชื่อมต่อและความเร็ว (Latency) ของระบบ **Ollama** และ **Qdrant** บนเครื่อง `Desk-5439`
|
||||
- ตรวจสอบสถานะและความยาวคิวงานของ **BullMQ** ทั้งหมดในระบบ รวมถึงคิวสำหรับห้องทดสอบของแอดมิน
|
||||
2. **ปุ่มสวิตช์เปิด/ปิดการตั้งค่า AI (Dynamic Toggle Switch):**
|
||||
- บันทึกสถานะการเปิด/ปิดลงในฐานข้อมูลตารางใหม่ `system_settings` พร้อมจัดทำ Cache ในระบบ Redis เพื่อการตรวจสอบที่รวดเร็วและไม่มี Latency
|
||||
- หากแอดมินตั้งค่าเป็น **ปิดใช้งาน AI (false)**:
|
||||
- **ฝั่งผู้ใช้ทั่วไป (UX Soft Fallback):** ปุ่มขอคำแนะนำจาก AI (AI Suggestion) ในหน้าจอสร้างหรือแก้ไขเอกสาร (RFA / Correspondence) จะเปลี่ยนสถานะเป็น **Disabled (ใช้งานไม่ได้)** และเมื่อผู้ใช้ชี้เมาส์ (Hover) จะมีข้อความแจ้งเตือนสีเหลือง/ส้มว่า `"⚠️ ระบบ AI ไม่พร้อมใช้งานชั่วคราว กรุณากรอกข้อมูลด้วยตนเอง"` พร้อมกับแสดงแถบแจ้งเตือน **Global Banner** ด้านบนสุดของระบบ
|
||||
- **ฝั่ง API (Block Protection):** ตรวจสอบผ่าน Guard หากผู้ใช้ทั่วไปพยายามเรียกยิง API AI จะตอบกลับด้วยรหัส **HTTP 503 Service Unavailable** ทันที
|
||||
- **การซิงก์ข้อมูล (Frontend Sync):** Frontend จะใช้ระบบ **Polling ดึงข้อมูลเช็คสถานะระบบทุกๆ 30 วินาที** เพื่อตรวจสอบและปรับเปลี่ยนหน้าจออัตโนมัติ
|
||||
- **สิทธิ์แอดมิน (Superadmin Bypass):** แอดมินที่มีสิทธิ์ Superadmin จะยังคงเข้าถึงและใช้งานห้องทดสอบ Sandbox ได้ตามปกติ แม้ว่าระบบด้านนอกจะถูกปิดให้บริการอยู่ก็ตาม
|
||||
3. **ห้องทดสอบส่วนตัวระบบคิวแยก (Isolated BullMQ Sandbox Queue):**
|
||||
- การสั่งประมวลผลคำถาม RAG และการอัปโหลดไฟล์ PDF สกัด Metadata ใน Sandbox ของแอดมิน จะส่งงานเข้าคิว BullMQ แยกเฉพาะตัวชื่อ `ai-admin-sandbox` เพื่อจำลองโหลดและความเร็วในการทำงานจริงของระบบคิว แต่แยกคิวออกมาเพื่อไม่ให้โดนบล็อกจากคิวค้างของผู้ใช้งานปกติในระบบ
|
||||
|
||||
---
|
||||
|
||||
## 📐 รายละเอียดการเปลี่ยนแปลงในระบบ (Proposed Changes)
|
||||
|
||||
### 🗄️ 1. โครงสร้างฐานข้อมูล (Database Layer)
|
||||
|
||||
เพิ่มตาราง `system_settings` ในฐานข้อมูล MariaDB เพื่อเก็บค่าการตั้งค่าแบบไดนามิก (ตามแนวทาง ADR-009)
|
||||
|
||||
#### [MODIFY] [lcbp3-v1.9.0-schema-02-tables.sql](file:///E:/np-dms/lcbp3/specs/03-Data-and-Storage/lcbp3-v1.9.0-schema-02-tables.sql)
|
||||
เพิ่มตาราง `system_settings` ในส่วน Users & RBAC (หลังตาราง permissions):
|
||||
|
||||
```sql
|
||||
-- ตารางเก็บการตั้งค่าระบบแบบไดนามิก (System Settings) - Generic Design
|
||||
CREATE TABLE system_settings (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง',
|
||||
setting_key VARCHAR(100) NOT NULL UNIQUE COMMENT 'คีย์การตั้งค่าระบบ (เช่น AI_FEATURES_ENABLED, MAX_UPLOAD_SIZE)',
|
||||
setting_value TEXT NOT NULL COMMENT 'ค่าที่บันทึก (stringified)',
|
||||
data_type ENUM('string', 'number', 'boolean', 'json') NOT NULL DEFAULT 'string' COMMENT 'ประเภทข้อมูลสำหรับ validation',
|
||||
category VARCHAR(50) COMMENT 'หมวดหมู่ (เช่น ai, security, storage, notification)',
|
||||
is_encrypted TINYINT(1) DEFAULT 0 COMMENT 'เข้ารหัสค่า sensitive (เช่น API keys)',
|
||||
validation_rules JSON COMMENT 'กฎ validation (min, max, allowed_values)',
|
||||
description TEXT COMMENT 'คำอธิบายข้อมูลการตั้งค่า',
|
||||
is_public TINYINT(1) DEFAULT 0 COMMENT 'เผยแพร่ให้ frontend อ่านได้ (หรือ admin only)',
|
||||
updated_by INT COMMENT 'ผู้แก้ไขล่าสุด',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (updated_by) REFERENCES users (user_id) ON DELETE SET NULL,
|
||||
INDEX idx_category (category),
|
||||
INDEX idx_is_public (is_public)
|
||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตารางเก็บข้อมูลการตั้งค่าระบบไดนามิก';
|
||||
|
||||
-- Seed ค่าเริ่มต้นสำหรับการควบคุมสถานะระบบ AI
|
||||
INSERT INTO system_settings (setting_key, setting_value, data_type, category, description, is_public)
|
||||
VALUES ('AI_FEATURES_ENABLED', 'true', 'boolean', 'ai', 'สถานะเปิด/ปิดการใช้งานฟีเจอร์ AI ทั้งระบบ สำหรับผู้ใช้ทั่วไป (true/false)', 1)
|
||||
ON DUPLICATE KEY UPDATE setting_key = setting_key;
|
||||
```
|
||||
|
||||
**การนำไปใช้งาน:** รัน SQL ด้านบนผ่าน manual execution หรือ n8n workflow ตาม ADR-009
|
||||
|
||||
---
|
||||
|
||||
### 💻 2. ส่วนของระบบหลังบ้าน (Backend Layer - NestJS)
|
||||
|
||||
#### [MODIFY] [queue.constants.ts](file:///E:/np-dms/lcbp3/backend/src/modules/common/constants/queue.constants.ts)
|
||||
- เพิ่ม priority constant สำหรับ SUPERADMIN:
|
||||
```typescript
|
||||
export const PRIORITY_SUPERADMIN = 10; // Higher than HIGH (5)
|
||||
```
|
||||
|
||||
#### [NEW] [system-setting.entity.ts](file:///E:/np-dms/lcbp3/backend/src/modules/ai/entities/system-setting.entity.ts)
|
||||
- สร้าง Entity รองรับโครงสร้างตารางใหม่ (ไม่มีบรรทัดว่างในฟังก์ชันตามข้อตกลง):
|
||||
```typescript
|
||||
// File: src/modules/ai/entities/system-setting.entity.ts
|
||||
// Change Log
|
||||
// - 2026-05-21: สร้าง Entity SystemSetting สำหรับเก็บการตั้งค่า (Generic Design)
|
||||
import { Entity, Column, PrimaryGeneratedColumn, UpdateDateColumn, CreateDateColumn } from 'typeorm';
|
||||
|
||||
@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: string;
|
||||
|
||||
@Column({ name: 'category', length: 50, nullable: true })
|
||||
category: string;
|
||||
|
||||
@Column({ name: 'is_encrypted', type: 'tinyint', default: 0 })
|
||||
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: 'tinyint', default: 0 })
|
||||
isPublic: boolean;
|
||||
|
||||
@Column({ name: 'updated_by', nullable: true })
|
||||
updatedBy: number;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
```
|
||||
|
||||
#### [MODIFY] [ai.module.ts](file:///E:/np-dms/lcbp3/backend/src/modules/ai/ai.module.ts)
|
||||
- ลงทะเบียน `SystemSetting` ใน TypeORM `forFeature`
|
||||
|
||||
#### [MODIFY] [ai-batch.processor.ts](file:///E:/np-dms/lcbp3/backend/src/modules/ai/processors/ai-batch.processor.ts)
|
||||
- เพิ่มการรองรับ job type ใหม่สำหรับ Sandbox:
|
||||
- `sandbox-rag` -> ค้นหาในเวกเตอร์และตอบคำถาม RAG พร้อมแสดง Citations (priority: SUPERADMIN)
|
||||
- `sandbox-extract` -> รัน OCR บนไฟล์ PDF เดี่ยวและประมวลผลสกัด Metadata คืนออกมาเป็นก้อนข้อมูล JSON (priority: SUPERADMIN)
|
||||
- **Dynamic Rate Limiting:** เพิ่ม middleware ตรวจสอบความยาวคิว `ai-batch` ก่อน allow sandbox request (queue length < 3 → no limit, queue length ≥ 3 → 10 req/hr)
|
||||
|
||||
#### [MODIFY] [ai-queue.service.ts](file:///E:/np-dms/lcbp3/backend/src/modules/ai/ai-queue.service.ts)
|
||||
- เพิ่มฟังก์ชัน `enqueueSandboxJob(type: string, payload: any)` เพื่อส่งงานของแอดมินเข้าคิว `ai-batch` พร้อม priority SUPERADMIN
|
||||
- เพิ่มฟังก์ชัน `getQueueLength(queueName: string)` เพื่อตรวจสอบความยาวคิวสำหรับ dynamic rate limiting
|
||||
|
||||
#### [MODIFY] [ai.service.ts](file:///E:/np-dms/lcbp3/backend/src/modules/ai/ai.service.ts)
|
||||
- เพิ่มเมธอดสำหรับอ่าน/เขียนการตั้งค่า:
|
||||
- `getAiFeaturesEnabled()`: ค้นหาค่า `AI_FEATURES_ENABLED` จาก Redis Key `system_settings:AI_FEATURES_ENABLED` ก่อน หากไม่มีจึงไปดึงจากตาราง `system_settings` แล้วเขียนลง Redis Cache เพื่อใช้ครั้งต่อไป
|
||||
- `setAiFeaturesEnabled(enabled: boolean, userId: number)`: อัปเดตสถานะในตารางฐานข้อมูล (TypeORM transaction) และอัปเดต Redis Cache ทันที (invalid key เดียว)
|
||||
- `getSystemHealth()`: รวบรวมข้อมูลสุขภาพของระบบ Ollama, Qdrant, และคิว BullMQ ต่างๆ (cache 30 วินาที, 5s timeout per service)
|
||||
|
||||
#### [NEW] [ai-enabled.guard.ts](file:///E:/np-dms/lcbp3/backend/src/modules/ai/guards/ai-enabled.guard.ts)
|
||||
- สร้าง Guard สำหรับเช็คสถานะการปิด AI ทั่วระบบ:
|
||||
- **Layered Check Logic:** Superadmin ต้องมีทั้ง `system.manage_all` **และ** `ai.suggest`/`ai.rag_query` เพื่อ bypass เมื่อ AI disabled
|
||||
- หากคีย์การเปิดใช้งานถูกตั้งค่าเป็น `'false'` และผู้ใช้ไม่ผ่าน layered check จะปฏิเสธการเข้าใช้งาน API ด้วยรหัสข้อผิดพลาด **HTTP 503 Service Unavailable**
|
||||
- Guard นี้ติดตั้งบน endpoints AI ทั่วไป (AI Suggestion, RAG Query) ไม่ใช่ admin endpoints
|
||||
- **Admin Endpoints:** ไม่ใช้ AiEnabledGuard (ใช้ permission guard `system.manage_all` เพียงพอ)
|
||||
- **Job Polling:** ไม่ block job status requests (audit trail ไม่ใช่ AI inference)
|
||||
- **Error Handling (ตาม ADR-007):**
|
||||
- Response body ประกอบด้วย: `{ message: "AI features are temporarily unavailable", userMessage: "ระบบ AI ไม่พร้อมใช้งานชั่วคราว กรุณากรอกข้อมูลด้วยตนเอง", recoveryAction: "ติดต่อผู้ดูแลระบบหากต้องการความช่วยเหลือ" }`
|
||||
- Backend Logger: `warn` level แต่ rate limit (log ทุก 10 ครั้งต่อ user ต่อนาที) เพื่อป้องกัน log spam
|
||||
- Frontend Error Display: Custom Global Banner + debounce 5 วินาที
|
||||
|
||||
#### [MODIFY] [ai.controller.ts](file:///E:/np-dms/lcbp3/backend/src/modules/ai/ai.controller.ts)
|
||||
- นำ Guard `AiEnabledGuard` ไปติดตั้งใน Endpoints ยิงทำงาน AI ของผู้ใช้ทั่วไป (AI Suggestion, RAG Query)
|
||||
- เพิ่มกลุ่ม API สำหรับ Superadmin เท่านั้น (ควบคุมด้วย `@RequirePermission('system.manage_all')` ตาม ADR-016):
|
||||
- `GET /ai/admin/settings` -> แสดงสถานะเปิด/ปิด AI ปัจจุบัน
|
||||
- `POST /ai/admin/toggle` -> สลับสถานะเปิด/ปิดระบบ AI (พร้อม Audit logging)
|
||||
- `GET /ai/admin/health` -> ดึงรายงานสุขภาพระบบ Ollama, Qdrant, คิว BullMQ ทั้งระบบ
|
||||
- `POST /ai/admin/sandbox/rag` -> ส่งงานคำถาม RAG เข้าคิว ai-batch (priority: SUPERADMIN) + dynamic rate limiting
|
||||
- `POST /ai/admin/sandbox/extract` -> ส่ออัปโหลด PDF สกัด metadata เข้าคิว ai-batch (priority: SUPERADMIN) + dynamic rate limiting
|
||||
- `GET /ai/admin/sandbox/job/:id` -> Polling ตรวจสอบความคืบหน้าของงานในคิว (ไม่ block)
|
||||
- **Security Measures (ตาม ADR-016):**
|
||||
- ทุก admin endpoints ใช้ `@RequirePermission('system.manage_all')`
|
||||
- `POST /ai/admin/toggle` มี Audit logging บันทึกใน `audit_logs` table (action: 'AI_FEATURES_TOGGLED', details: { enabled: boolean })
|
||||
- ใช้ `@Audit()` decorator บนทุก admin endpoints
|
||||
- มี Rate limiting ตาม ADR-016 (ThrottlerGuard) บน auth endpoints
|
||||
|
||||
---
|
||||
|
||||
### 🎨 3. ส่วนหน้าจอแสดงผล (Frontend Layer - Next.js)
|
||||
|
||||
#### [NEW] [admin-ai.service.ts](file:///E:/np-dms/lcbp3/frontend/lib/services/admin-ai.service.ts)
|
||||
- พัฒนา API Service สำหรับดึงข้อมูลและสั่งงานของระบบ Admin AI Panel:
|
||||
- ดึงข้อมูลสุขภาพ ตรวจสอบการตั้งค่า สลับปุ่มเปิด/ปิด
|
||||
- ส่งงาน Sandbox RAG/Extraction และ Polling เช็คผลลัพธ์ของ Job
|
||||
- **UUID Handling (ตาม ADR-019):**
|
||||
- ใช้ `publicId` (string UUID) สำหรับ job ID จาก BullMQ เท่านั้น
|
||||
- ห้ามใช้ `id ?? ''` fallback ในกรณีใดๆ
|
||||
|
||||
#### [NEW] [page.tsx](file:///E:/np-dms/lcbp3/frontend/app/(admin)/admin/ai/page.tsx)
|
||||
- หน้าต่าง **AI Control Panel & Playground** ออกแบบอย่างพรีเมียม สไตล์ Glassmorphism:
|
||||
- **Layout:** Single page พร้อม tabs (RAG Playground / OCR Sandbox)
|
||||
- **Header Switch:** สวิตช์ปุ่มเรืองแสงสีเขียว/ส้มขนาดใหญ่ สำหรับเปิด/ปิดใช้งานระบบ AI
|
||||
- **Health Indicators:** การ์ดประเมินสถานะของ Ollama, Qdrant, และ คิว BullMQ แบบเรียลไทม์ (cache 30 วินาที)
|
||||
- **RAG Playground Tab:** แชทบอทโต้ตอบผ่าน ai-batch queue พร้อมสถานะความคืบหน้าของคิว (poll ทุก 5 วินาที) แสดงคำตอบและเอกสารอ้างอิงสวยงาม
|
||||
- **OCR Sandbox Tab:** กล่องวางอัปโหลดไฟล์ PDF เดี่ยวเพื่อจำลองการรัน OCR และดึง Metadata แสดงก้อน JSON ด้วย Syntax highlighting สวยงาม
|
||||
- **Error Display:** Inline error ใน output area (red box) + toast notification
|
||||
- **i18n (ตาม i18n Guidelines):**
|
||||
- ใช้ i18n keys สำหรับข้อความทั้งหมด (เช่น `ai.admin.panel.title`, `ai.admin.panel.health.status`)
|
||||
- ห้าม hardcode ข้อความภาษาไทยใน component
|
||||
|
||||
#### [MODIFY] [sidebar.tsx](file:///E:/np-dms/lcbp3/frontend/components/admin/sidebar.tsx)
|
||||
- เพิ่มปุ่มเมนู **"AI Console"** (ไอคอน Brain) ใน Sidebar สำหรับแอดมิน เพื่อลิงก์ไปหน้าจอ `/admin/ai`
|
||||
|
||||
#### [MODIFY] [layout.tsx](file:///E:/np-dms/lcbp3/frontend/app/layout.tsx)
|
||||
- เพิ่มกลไก Polling ตรวจเช็คสถานะการเปิดใช้ AI **ทุก 30 วินาที** แต่ **เฉพาะ users ที่มี AI permissions** (`ai.suggest` หรือ `ai.rag_query`)
|
||||
- หากระบบ AI ปิดตัวลง จะแสดงแถบแจ้งเตือน **Global Banner** ด้านบนสุด (debounce 5 วินาที) และส่งสัญญาณบอกหน้าจอฟอร์มเพื่อ Disable ปุ่ม AI Suggestion
|
||||
- **Cache:** React Context + refresh on mount
|
||||
- **Implementation:** `useAiStatus()` hook ใน `SessionProvider`
|
||||
- **i18n:** ใช้ i18n keys สำหรับ Global Banner message (เช่น `ai.disabled.banner.message`, `ai.disabled.banner.tooltip`)
|
||||
|
||||
---
|
||||
|
||||
## 📋 Grilling Session Decisions Summary (2026-05-21)
|
||||
|
||||
การตัดสินใจต่อไปนี้ได้รับการ refine ผ่าน grilling session เพื่อความชัดเจนและความพร้อมในการ implement:
|
||||
|
||||
| # | ประเด็น | การตัดสินใจ |
|
||||
|---|---------|--------------|
|
||||
| 1 | Infrastructure Dependency | ADR-023A infrastructure มีอยู่แล้ว (ai-realtime, ai-batch, permissions) ✅ |
|
||||
| 2 | system_settings Schema | Generic พร้อม `data_type`, `category`, `is_encrypted`, `validation_rules`, `is_public` |
|
||||
| 3 | Redis Cache Strategy | Cache แยก key ต่อ setting (เช่น `system_settings:AI_FEATURES_ENABLED`) |
|
||||
| 4 | Security Controls | Dynamic rate limiting ขึ้นกับ queue length (queue < 3 → no limit, queue ≥ 3 → 10 req/hr) |
|
||||
| 5 | Frontend Polling | Poll เฉพาะ users ที่มี AI permissions (ทุก 30 วินาที) |
|
||||
| 6 | AiEnabledGuard | Layered check (system.manage_all + ai.suggest/ai.rag_query) |
|
||||
| 7 | Error Handling | HTTP 503 + rate-limited warn logs (10 req/user/min) + custom banner debounce 5s |
|
||||
| 8 | Cache Invalidation | Invalid หลัง DB success (TypeORM transaction) + single key + ยอมรับ 30s latency |
|
||||
| 9 | Sandbox Priority | Priority ระดับใหม่ `SUPERADMIN` (higher than HIGH) |
|
||||
| 10 | Health Check | 5s timeout per service + 30s cache + basic queue metrics (waiting, active, failed, rate) |
|
||||
| 11 | UI/UX | Single page layout + 5s job polling + inline error (red box) + toast |
|
||||
| 12 | Implementation Priority | Phased (backend → frontend) |
|
||||
|
||||
---
|
||||
|
||||
## 🧪 แผนการตรวจสอบความถูกต้อง (Verification Plan)
|
||||
|
||||
### 🤖 1. การทดสอบอัตโนมัติ (Automated Tests)
|
||||
- พัฒนา Unit Test ใน Backend ครอบคลุมพฤติกรรมการบล็อกสิทธิ์ผ่าน Guard, การทำงานของ Cache, และการทำงานของระบบคิว Sandbox แยกเฉพาะ
|
||||
- **Coverage Goals (ตาม ADR-023A):**
|
||||
- Business Logic: 80%+ สำหรับ SystemSettingService, AiService, AiQueueService
|
||||
- Backend Overall: 70%+ สำหรับ AI Module ทั้งหมด
|
||||
- **Test Files:**
|
||||
- `system-setting.service.spec.ts` - CRUD operations + Cache invalidation
|
||||
- `ai-enabled.guard.spec.ts` - Guard logic (block non-superadmin when AI disabled, allow superadmin)
|
||||
- `ai-queue.service.spec.ts` - Queue operations (sandbox job enqueue with HIGH priority)
|
||||
- `ai.service.spec.ts` - getAiFeaturesEnabled (Redis Cache miss/hit), setAiFeaturesEnabled (DB + Cache update), getSystemHealth
|
||||
- สั่งรันการทดสอบผ่าน PowerShell บน Windows:
|
||||
```powershell
|
||||
cd backend
|
||||
npm run test src/modules/ai
|
||||
npm run test:cov src/modules/ai
|
||||
```
|
||||
|
||||
### 🧑💻 2. การทดสอบด้วยตนเอง (Manual Tests)
|
||||
1. **การจำกัดสิทธิ์:** ตรวจสอบว่าผู้ใช้ทั่วไปต้องไม่สามารถเข้าถึงหน้าจอและ API ระบบแอดมินได้
|
||||
2. **การสลับปิดระบบ AI:**
|
||||
- ทดสอบสลับสวิตช์เป็นปิดใช้งาน
|
||||
- ตรวจสอบว่าหน้าจอผู้ใช้ปกติแสดง Global Banner และปุ่มขอแนะนำ Metadata ถูกปรับเป็น Disabled มี Tooltip ชี้แจง
|
||||
- ตรวจสอบว่า Superadmin ยังสามารถเข้าไปคุยแชท RAG และโยนไฟล์ PDF ทดสอบสกัดข้อมูลใน Sandbox ได้เสมือนปกติทุกประการ
|
||||
3. **การทดสอบความถูกต้องของคิว Sandbox:** ตรวจสอบว่าข้อมูลไหลผ่านคิว `ai-batch` (job types: sandbox-rag, sandbox-extract) ได้สำเร็จและได้รับผลลัพธ์ประมวลผลถูกต้อง
|
||||
@@ -0,0 +1,180 @@
|
||||
// File: specs/200-fullstacks/227-ai-admin-console/plan.md
|
||||
// Change Log:
|
||||
// - 2026-05-20: แผนการพัฒนาฉบับภาษาไทยสำหรับระบบ AI Admin Console
|
||||
|
||||
# Implementation Plan: AI Admin Console
|
||||
|
||||
**Branch**: `227-ai-admin-console` | **Date**: 2026-05-20 | **Spec**: [spec.md](./spec.md) | **ADR**: [ADR-027](../../06-Decision-Records/ADR-027-ai-admin-console-and-dynamic-control.md)
|
||||
|
||||
---
|
||||
|
||||
## สรุปแนวทางเชิงเทคนิค (Technical Summary)
|
||||
|
||||
แผนงานนี้จัดทำขึ้นเพื่อออกแบบและติดตั้งระบบ **AI Admin Panel** สำหรับสิทธิ์ **Superadmin** เท่านั้น เพื่อให้ผู้ดูแลระบบสามารถตรวจสอบสุขภาพของเครื่อง AI Host (`Desk-5439`), เปิด/ปิดการใช้งานฟีเจอร์ AI สำหรับผู้ใช้ทั่วไปได้แบบ Dynamic, ตรวจสอบสถานะ BullMQ/Qdrant, และใช้งาน Playground ทดสอบคำสั่ง RAG และการทำ OCR/Metadata extraction โดยอ้างอิงตามข้อสรุปการตัดสินใจจากการ Grill Session และสอดคล้องกับนโยบายเอกสารของโครงการอย่างครบถ้วน
|
||||
|
||||
---
|
||||
|
||||
## วัตถุประสงค์และข้อกำหนดทางเทคนิค
|
||||
|
||||
1. **การตรวจสอบสถานะระบบ AI (Health Check):**
|
||||
- พัฒนาระบบตรวจสอบสุขภาพการเชื่อมต่อและความเร็ว (Latency) ของระบบ **Ollama** และ **Qdrant** บนเครื่อง `Desk-5439`
|
||||
- ตรวจสอบสถานะและความยาวคิวงานของ **BullMQ** ทั้งหมดในระบบ รวมถึงคิวสำหรับห้องทดสอบของแอดมิน
|
||||
2. **ปุ่มสวิตช์เปิด/ปิดการตั้งค่า AI (Dynamic Toggle Switch):**
|
||||
- บันทึกสถานะการเปิด/ปิดลงในฐานข้อมูลตารางใหม่ `system_settings` พร้อมจัดทำ Cache ในระบบ Redis เพื่อการตรวจสอบที่รวดเร็วและไม่มี Latency
|
||||
- หากแอดมินตั้งค่าเป็น **ปิดใช้งาน AI (false)**:
|
||||
- **ฝั่งผู้ใช้ทั่วไป (UX Soft Fallback):** ปุ่มขอคำแนะนำจาก AI (AI Suggestion) ในหน้าจอสร้างหรือแก้ไขเอกสาร (RFA / Correspondence) จะเปลี่ยนสถานะเป็น **Disabled (ใช้งานไม่ได้)** และเมื่อผู้ใช้ชี้เมาส์ (Hover) จะมีข้อความแจ้งเตือนสีเหลือง/ส้มว่า `"⚠️ ระบบ AI ไม่พร้อมใช้งานชั่วคราว กรุณากรอกข้อมูลด้วยตนเอง"` พร้อมกับแสดงแถบแจ้งเตือน **Global Banner** ด้านบนสุดของระบบ
|
||||
- **ฝั่ง API (Block Protection):** ตรวจสอบผ่าน Guard หากผู้ใช้ทั่วไปพยายามเรียกยิง API AI จะตอบกลับด้วยรหัส **HTTP 503 Service Unavailable** ทันที
|
||||
- **การซิงก์ข้อมูล (Frontend Sync):** Frontend จะใช้ระบบ **Polling ดึงข้อมูลเช็คสถานะระบบทุกๆ 30 วินาที** เพื่อตรวจสอบและปรับเปลี่ยนหน้าจออัตโนมัติ
|
||||
- **สิทธิ์แอดมิน (Superadmin Bypass):** แอดมินที่มีสิทธิ์ Superadmin จะยังคงเข้าถึงและใช้งานห้องทดสอบ Sandbox ได้ตามปกติ แม้ว่าระบบด้านนอกจะถูกปิดให้บริการอยู่ก็ตาม
|
||||
3. **ห้องทดสอบส่วนตัวระบบคิวแยก (Isolated BullMQ Sandbox Queue):**
|
||||
- การสั่งประมวลผลคำถาม RAG และการอัปโหลดไฟล์ PDF สกัด Metadata ใน Sandbox ของแอดมิน จะส่งงานเข้าคิว BullMQ แยกเฉพาะตัวชื่อ `ai-admin-sandbox` เพื่อจำลองโหลดและความเร็วในการทำงานจริงของระบบคิว แต่แยกคิวออกมาเพื่อไม่ให้โดนบล็อกจากคิวค้างของผู้ใช้งานปกติในระบบ
|
||||
|
||||
---
|
||||
|
||||
## รายละเอียดการเปลี่ยนแปลงในระบบ (Proposed Changes)
|
||||
|
||||
### 🗄️ 1. โครงสร้างฐานข้อมูล (Database Layer)
|
||||
|
||||
สร้างตาราง `system_settings` ในฐานข้อมูล MariaDB เพื่อเก็บค่าการตั้งค่าแบบไดนามิก (ตามแนวทาง ADR-009)
|
||||
|
||||
#### [NEW] [03-09-add-system-settings.sql](file:///E:/np-dms/lcbp3/backend/migrations/03-09-add-system-settings.sql)
|
||||
- เขียนคำสั่ง SQL เพื่อสร้างตาราง `system_settings` และ Seed ข้อมูลเบื้องต้น:
|
||||
```sql
|
||||
-- File: backend/migrations/03-09-add-system-settings.sql
|
||||
-- Change Log
|
||||
-- - 2026-05-20: สร้างตาราง system_settings และ seed ค่าเปิด/ปิด AI
|
||||
|
||||
CREATE TABLE IF NOT EXISTS system_settings (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง',
|
||||
setting_key VARCHAR(100) NOT NULL UNIQUE COMMENT 'คีย์การตั้งค่าระบบ (เช่น AI_FEATURES_ENABLED)',
|
||||
setting_value TEXT NOT NULL COMMENT 'ค่าที่บันทึก',
|
||||
description TEXT COMMENT 'คำอธิบายข้อมูลการตั้งค่า',
|
||||
updated_by INT COMMENT 'ผู้แก้ไขล่าสุด',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (updated_by) REFERENCES users (user_id) ON DELETE SET NULL
|
||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตารางเก็บข้อมูลการตั้งค่าระบบไดนามิก';
|
||||
|
||||
-- Seed ค่าเริ่มต้นสำหรับการควบคุมสถานะระบบ AI
|
||||
INSERT INTO system_settings (setting_key, setting_value, description)
|
||||
VALUES ('AI_FEATURES_ENABLED', 'true', 'สถานะเปิด/ปิดการใช้งานฟีเจอร์ AI ทั้งระบบ สำหรับผู้ใช้ทั่วไป (true/false)')
|
||||
ON DUPLICATE KEY UPDATE setting_key = setting_key;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 💻 2. ส่วนของระบบหลังบ้าน (Backend Layer - NestJS)
|
||||
|
||||
#### [MODIFY] [queue.constants.ts](file:///E:/np-dms/lcbp3/backend/src/modules/common/constants/queue.constants.ts)
|
||||
- เพิ่มรหัสคิวใหม่สำหรับห้องทดสอบของแอดมิน:
|
||||
```typescript
|
||||
export const QUEUE_AI_ADMIN_SANDBOX = 'ai-admin-sandbox';
|
||||
```
|
||||
|
||||
#### [NEW] [system-setting.entity.ts](file:///E:/np-dms/lcbp3/backend/src/modules/ai/entities/system-setting.entity.ts)
|
||||
- สร้าง Entity รองรับโครงสร้างตารางใหม่ (ไม่มีบรรทัดว่างในฟังก์ชันตามข้อตกลง):
|
||||
```typescript
|
||||
// File: src/modules/ai/entities/system-setting.entity.ts
|
||||
// Change Log
|
||||
// - 2026-05-20: สร้าง Entity SystemSetting สำหรับเก็บการตั้งค่า
|
||||
import { Entity, Column, PrimaryGeneratedColumn, UpdateDateColumn, CreateDateColumn } from 'typeorm';
|
||||
|
||||
@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({ type: 'text', nullable: true })
|
||||
description: string;
|
||||
|
||||
@Column({ name: 'updated_by', nullable: true })
|
||||
updatedBy: number;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
```
|
||||
|
||||
#### [MODIFY] [ai.module.ts](file:///E:/np-dms/lcbp3/backend/src/modules/ai/ai.module.ts)
|
||||
- ลงทะเบียน `SystemSetting` ใน TypeORM `forFeature`
|
||||
- ลงทะเบียนคิว BullMQ `QUEUE_AI_ADMIN_SANDBOX` และตัวประมวลผลคิว `AiSandboxProcessor`
|
||||
|
||||
#### [NEW] [ai-sandbox.processor.ts](file:///E:/np-dms/lcbp3/backend/src/modules/ai/processors/ai-sandbox.processor.ts)
|
||||
- พัฒนาตัวประมวลผลงานคิว `ai-admin-sandbox` โดยเฉพาะ:
|
||||
- รับงานประเภท `sandbox-rag` -> ค้นหาในเวกเตอร์และตอบคำถาม RAG พร้อมแสดง Citations
|
||||
- รับงานประเภท `sandbox-extract` -> รัน OCR บนไฟล์ PDF เดี่ยวและประมวลผลสกัด Metadata คืนออกมาเป็นก้อนข้อมูล JSON
|
||||
|
||||
#### [MODIFY] [ai-queue.service.ts](file:///E:/np-dms/lcbp3/backend/src/modules/ai/ai-queue.service.ts)
|
||||
- เพิ่มฟังก์ชัน `enqueueSandboxJob(type: string, payload: any)` เพื่อส่งงานของแอดมินเข้าคิว Sandbox แยกต่างหาก
|
||||
|
||||
#### [MODIFY] [ai.service.ts](file:///E:/np-dms/lcbp3/backend/src/modules/ai/ai.service.ts)
|
||||
- เพิ่มเมธอดสำหรับอ่าน/เขียนการตั้งค่า:
|
||||
- `getAiFeaturesEnabled()`: ค้นหาค่า `AI_FEATURES_ENABLED` จาก Redis Cache ก่อน หากไม่มีจึงไปดึงจากตาราง `system_settings` แล้วเขียนลง Redis Cache เพื่อใช้ครั้งต่อไป
|
||||
- `setAiFeaturesEnabled(enabled: boolean, userId: number)`: อัปเดตสถานะในตารางฐานข้อมูลและอัปเดต Redis Cache ทันที
|
||||
- `getSystemHealth()`: รวบรวมข้อมูลสุขภาพของระบบ Ollama, Qdrant, และคิว BullMQ ต่างๆ (รวมความยาวคิว sandbox)
|
||||
|
||||
#### [NEW] [ai-enabled.guard.ts](file:///E:/np-dms/lcbp3/backend/src/modules/ai/guards/ai-enabled.guard.ts)
|
||||
- สร้าง Guard สำหรับเช็คสถานะการปิด AI ทั่วระบบ:
|
||||
- หากคีย์การเปิดใช้งานถูกตั้งค่าเป็น `'false'` และผู้ใช้ไม่มีสิทธิ์จัดการระบบสูงสุด (`system.manage_all`) จะปฏิเสธการเข้าใช้งาน API ด้วยรหัสข้อผิดพลาด **HTTP 503 Service Unavailable**
|
||||
|
||||
#### [MODIFY] [ai.controller.ts](file:///E:/np-dms/lcbp3/backend/src/modules/ai/ai.controller.ts)
|
||||
- นำ Guard `AiEnabledGuard` ไปติดตั้งใน Endpoints ยิงทำงาน AI ของผู้ใช้ทั่วไป
|
||||
- เพิ่มกลุ่ม API ปลอดภัยสำหรับ Superadmin เท่านั้น (ควบคุมด้วย `@RequirePermission('system.manage_all')`):
|
||||
- `GET /ai/admin/settings` -> แสดงสถานะเปิด/ปิด AI ปัจจุบัน
|
||||
- `POST /ai/admin/toggle` -> สลับสถานะเปิด/ปิดระบบ AI
|
||||
- `GET /ai/admin/health` -> ดึงรายงานสุขภาพระบบ Ollama, Qdrant, คิว BullMQ ทั้งระบบ
|
||||
- `POST /ai/admin/sandbox/rag` -> ส่งงานคำถาม RAG เข้าคิว Sandbox
|
||||
- `POST /ai/admin/sandbox/extract` -> ส่งอัปโหลด PDF สกัด metadata เข้าคิว Sandbox
|
||||
- `GET /ai/admin/sandbox/job/:id` -> Polling ตรวจสอบความคืบหน้าของงานในคิว Sandbox
|
||||
|
||||
---
|
||||
|
||||
### 🎨 3. ส่วนหน้าจอแสดงผล (Frontend Layer - Next.js)
|
||||
|
||||
#### [NEW] [admin-ai.service.ts](file:///E:/np-dms/lcbp3/frontend/lib/services/admin-ai.service.ts)
|
||||
- พัฒนา API Service สำหรับดึงข้อมูลและสั่งงานของระบบ Admin AI Panel:
|
||||
- ดึงข้อมูลสุขภาพ ตรวจสอบการตั้งค่า สลับปุ่มเปิด/ปิด
|
||||
- ส่งงาน Sandbox RAG/Extraction และ Polling เช็คผลลัพธ์ของ Job
|
||||
|
||||
#### [NEW] [page.tsx](file:///E:/np-dms/lcbp3/frontend/app/%28admin%29/admin/ai/page.tsx)
|
||||
- หน้าต่าง **AI Control Panel & Playground** ออกแบบอย่างพรีเมียม สไตล์ Glassmorphism:
|
||||
- **Header Switch:** สวิตช์ปุ่มเรืองแสงสีเขียว/ส้มขนาดใหญ่ สำหรับเปิด/ปิดใช้งานระบบ AI
|
||||
- **Health Indicators:** การ์ดประเมินสถานะของ Ollama, Qdrant, และ คิว BullMQ แบบเรียลไทม์
|
||||
- **RAG Playground Tab:** แชทบอทโต้ตอบผ่าน Isolated Queue พร้อมสถานะความคืบหน้าของคิว แสดงคำตอบและเอกสารอ้างอิงสวยงาม
|
||||
- **OCR Sandbox Tab:** กล่องวางอัปโหลดไฟล์ PDF เดี่ยวเพื่อจำลองการรัน OCR และดึง Metadata แสดงก้อน JSON ด้วย Syntax highlighting สวยงาม
|
||||
|
||||
#### [MODIFY] [sidebar.tsx](file:///E:/np-dms/lcbp3/frontend/components/admin/sidebar.tsx)
|
||||
- เพิ่มปุ่มเมนู **"AI Console"** (ไอคอน Brain) ใน Sidebar สำหรับแอดมิน เพื่อลิงก์ไปหน้าจอ `/admin/ai`
|
||||
|
||||
#### [MODIFY] [layout.tsx](file:///E:/np-dms/lcbp3/frontend/app/layout.tsx)
|
||||
- เพิ่มกลไก Polling ตรวจเช็คสถานะการเปิดใช้ AI ทุก 30 วินาที
|
||||
- หากระบบ AI ปิดตัวลง จะแสดงแถบแจ้งเตือน **Global Banner** ด้านบนสุด และส่งสัญญาณบอกหน้าจอฟอร์มเพื่อ Disable ปุ่ม AI Suggestion
|
||||
|
||||
---
|
||||
|
||||
## แผนการตรวจสอบความถูกต้อง (Verification Plan)
|
||||
|
||||
### 🤖 1. การทดสอบอัตโนมัติ (Automated Tests)
|
||||
- พัฒนา Unit Test ใน Backend ครอบคลุมพฤติกรรมการบล็อกสิทธิ์ผ่าน Guard, การทำงานของ Cache, และการทำงานของระบบคิว Sandbox แยกเฉพาะ
|
||||
- สั่งรันการทดสอบผ่าน PowerShell บน Windows:
|
||||
```powershell
|
||||
cd backend
|
||||
npm run test src/modules/ai
|
||||
```
|
||||
|
||||
### 🧑💻 2. การทดสอบด้วยตนเอง (Manual Tests)
|
||||
1. **การจำกัดสิทธิ์:** ตรวจสอบว่าผู้ใช้ทั่วไปต้องไม่สามารถเข้าถึงหน้าจอและ API ระบบแอดมินได้
|
||||
2. **การสลับปิดระบบ AI:**
|
||||
- ทดสอบสลับสวิตช์เป็นปิดใช้งาน
|
||||
- ตรวจสอบว่าหน้าจอผู้ใช้ปกติแสดง Global Banner และปุ่มขอแนะนำ Metadata ถูกปรับเป็น Disabled มี Tooltip ชี้แจง
|
||||
- ตรวจสอบว่า Superadmin ยังสามารถเข้าไปคุยแชท RAG และโยนไฟล์ PDF ทดสอบสกัดข้อมูลใน Sandbox ได้เสมือนปกติทุกประการ
|
||||
3. **การทดสอบความถูกต้องของคิว Sandbox:** ตรวจสอบว่าข้อมูลไหลผ่านคิว `ai-admin-sandbox` ได้สำเร็จและได้รับผลลัพธ์ประมวลผลถูกต้อง
|
||||
@@ -0,0 +1,146 @@
|
||||
// File: specs/200-fullstacks/227-ai-admin-console/spec.md
|
||||
// Change Log:
|
||||
// - 2026-05-20: Feature Specification สำหรับระบบ AI Admin Console
|
||||
// - 2026-05-21: Restructure following spec-template.md with User Stories, FRs, Success Criteria
|
||||
|
||||
# Feature Specification: AI Admin Console
|
||||
|
||||
**Feature Branch**: `227-ai-admin-console`
|
||||
**Created**: 2026-05-20
|
||||
**Status**: Draft
|
||||
**Category**: 200-fullstacks
|
||||
**Input**: ADR-027 AI Admin Panel and Dynamic Control Architecture
|
||||
|
||||
---
|
||||
|
||||
## User Scenarios & Testing
|
||||
|
||||
### User Story 1 - Superadmin Toggles AI System On/Off (Priority: P1)
|
||||
|
||||
As a Superadmin, I need to dynamically enable or disable AI features for all regular users without redeploying the system, so that I can perform maintenance, manage system load, or handle AI infrastructure issues gracefully.
|
||||
|
||||
**Why this priority**: This is the core control mechanism of the feature. Without it, the admin cannot perform emergency maintenance or manage system resources during high load periods.
|
||||
|
||||
**Independent Test**: Can be fully tested by a Superadmin toggling the AI switch and observing that regular users immediately see the disabled state (within polling interval) while the Superadmin retains full access.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the AI system is currently enabled, **When** a Superadmin toggles the switch to disabled, **Then** the setting is persisted to database and cache, and regular users see disabled AI buttons within 30 seconds
|
||||
2. **Given** the AI system is currently disabled, **When** a Superadmin toggles the switch to enabled, **Then** regular users can access AI features again after the polling interval
|
||||
3. **Given** a regular user has AI permissions, **When** they attempt to use AI features while the system is disabled, **Then** they receive HTTP 503 with a user-friendly message explaining temporary unavailability
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Normal Users Experience Soft Fallback (Priority: P1)
|
||||
|
||||
As a regular user with AI permissions, I need clear visual feedback when AI features are temporarily disabled, so that I understand why AI buttons are unavailable and can complete my work manually without confusion.
|
||||
|
||||
**Why this priority**: Critical for user experience. Abrupt feature disappearance creates confusion and support tickets. Soft fallback maintains user trust.
|
||||
|
||||
**Independent Test**: Can be tested by disabling AI system and verifying that regular users see disabled buttons with tooltips and global banner, rather than errors or missing UI elements.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the AI system is disabled by admin, **When** a regular user views a document form with AI suggestion buttons, **Then** those buttons appear disabled with a tooltip explaining "ระบบ AI ไม่พร้อมใช้งานชั่วคราว"
|
||||
2. **Given** the AI system is disabled, **When** a regular user loads any page, **Then** a global banner appears at the top stating AI is temporarily unavailable
|
||||
3. **Given** a regular user attempts direct API access to AI endpoints while disabled, **When** the request is made, **Then** the system returns HTTP 503 with recovery guidance
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Superadmin Monitors AI Health Status (Priority: P2)
|
||||
|
||||
As a Superadmin, I need real-time visibility into AI infrastructure health (Ollama, Qdrant, BullMQ queues), so that I can diagnose issues, monitor latency, and make informed decisions about enabling/disabling AI services.
|
||||
|
||||
**Why this priority**: Essential for operational awareness but secondary to the control mechanism itself.
|
||||
|
||||
**Independent Test**: Can be tested by accessing the AI Admin Console health dashboard and verifying all metrics display correctly with appropriate status indicators.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the AI Admin Console is accessed, **When** a Superadmin views the health panel, **Then** they see Ollama latency, active model version, Qdrant collection stats, and BullMQ queue metrics (waiting/active/failed jobs)
|
||||
2. **Given** a service is experiencing issues, **When** health check runs, **Then** the status displays as degraded/down with relevant metrics highlighted
|
||||
3. **Given** the Superadmin is monitoring the system, **When** they refresh or view the dashboard, **Then** metrics are cached for 30 seconds to prevent excessive load
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - Superadmin Uses RAG Playground Sandbox (Priority: P2)
|
||||
|
||||
As a Superadmin, I need an isolated RAG testing environment where I can query documents and receive AI-generated responses with citations, so that I can test and refine AI behavior without affecting production queues or user experiences.
|
||||
|
||||
**Why this priority**: Enables safe testing and troubleshooting of AI capabilities during maintenance windows.
|
||||
|
||||
**Independent Test**: Can be tested by submitting a RAG query in the sandbox and receiving a complete response with document citations, while verifying the job runs through the isolated sandbox queue.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the AI system is disabled for regular users, **When** a Superadmin submits a RAG query in the sandbox, **Then** the query processes through the isolated queue and returns results with citations
|
||||
2. **Given** a RAG job is submitted, **When** it is processing, **Then** the Superadmin can poll for status updates every 5 seconds and see progress
|
||||
3. **Given** the sandbox queue has multiple jobs, **When** jobs are processed, **Then** Superadmin jobs have SUPERADMIN priority (higher than regular batch jobs)
|
||||
|
||||
---
|
||||
|
||||
### User Story 5 - Superadmin Uses OCR Sandbox for Metadata Extraction (Priority: P2)
|
||||
|
||||
As a Superadmin, I need to upload PDF files to an isolated OCR sandbox to test metadata extraction capabilities, so that I can validate AI accuracy and tune extraction parameters without impacting production document processing.
|
||||
|
||||
**Why this priority**: Supports AI tuning and validation workflows, enabling data-driven improvements to extraction accuracy.
|
||||
|
||||
**Independent Test**: Can be tested by uploading a PDF to the OCR sandbox and receiving extracted metadata in JSON format with confidence scores.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a PDF file is uploaded to the OCR sandbox, **When** processing completes, **Then** the system returns extracted metadata as formatted JSON with syntax highlighting
|
||||
2. **Given** an OCR job is submitted, **When** processing fails, **Then** the error is displayed inline in a red box with actionable guidance
|
||||
3. **Given** the queue length is >= 3, **When** additional sandbox requests are made, **Then** dynamic rate limiting applies (10 requests/hour per user)
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- **EC-001**: What happens when Redis cache is unavailable? System must fall back to database query with <100ms latency penalty
|
||||
- **EC-002**: How does system handle concurrent toggle requests? Last-write-wins with optimistic locking; invalid cache after successful write
|
||||
- **EC-003**: What if Ollama/Qdrant times out during health check? Health service returns DEGRADED status, not DOWN; timeout is 5 seconds per service
|
||||
- **EC-004**: How are long-running sandbox jobs handled? Job status polling available; jobs can be cancelled by admin; results cached for 1 hour
|
||||
- **EC-005**: What happens if a Superadmin loses permissions mid-session? Next API request returns 403; UI redirects to unauthorized page
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: System MUST provide a toggle switch accessible only to Superadmin (`system.manage_all`) to enable/disable AI features system-wide
|
||||
- **FR-002**: System MUST persist AI enabled/disabled state to `system_settings` table with Redis caching for <1ms latency on status checks
|
||||
- **FR-003**: System MUST display disabled AI buttons with explanatory tooltips to regular users when AI is turned off
|
||||
- **FR-004**: System MUST show a global banner at the top of all pages when AI is disabled, visible only to users with AI permissions
|
||||
- **FR-005**: System MUST return HTTP 503 Service Unavailable to regular users attempting AI API calls when AI is disabled
|
||||
- **FR-006**: System MUST allow Superadmins full AI access (including sandbox) even when AI is disabled for regular users
|
||||
- **FR-007**: System MUST provide health monitoring dashboard showing Ollama latency, model version, Qdrant stats, and BullMQ queue metrics
|
||||
- **FR-008**: System MUST cache health check results for 30 seconds to prevent excessive infrastructure load
|
||||
- **FR-009**: System MUST provide isolated RAG sandbox queue (`ai-admin-sandbox`) with SUPERADMIN job priority
|
||||
- **FR-010**: System MUST provide isolated OCR sandbox for PDF metadata extraction with JSON output and syntax highlighting
|
||||
- **FR-011**: System MUST implement dynamic rate limiting for sandbox based on queue length (queue < 3: no limit, queue >= 3: 10 req/hr)
|
||||
- **FR-012**: System MUST poll AI status every 30 seconds from frontend for users with AI permissions
|
||||
- **FR-013**: System MUST support job status polling every 5 seconds for sandbox operations
|
||||
- **FR-014**: System MUST implement AiEnabledGuard with layered permission check (system.manage_all + ai.suggest/ai.rag_query bypass)
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **SystemSetting**: Stores dynamic configuration values (AI_FEATURES_ENABLED, etc.) with metadata (data_type, category, validation_rules)
|
||||
- **SandboxJob**: Represents a sandbox operation (RAG query or OCR extraction) with priority, status, and results
|
||||
- **HealthStatus**: Aggregated health metrics from Ollama, Qdrant, and BullMQ with status indicators (HEALTHY/DEGRADED/DOWN)
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: Superadmin can toggle AI system state with changes reflected to regular users within 30 seconds
|
||||
- **SC-002**: AI status check API responds in under 1ms when cached, under 50ms on cache miss
|
||||
- **SC-003**: 100% of regular users see disabled AI buttons with tooltips when AI is turned off (no hidden or broken UI)
|
||||
- **SC-004**: Health dashboard displays all 3 services (Ollama, Qdrant, BullMQ) with <5 second data staleness
|
||||
- **SC-005**: Sandbox RAG queries return complete responses with citations within 2x normal queue processing time
|
||||
- **SC-006**: Sandbox OCR extraction returns valid JSON for 95% of test PDFs with clear error messages for failures
|
||||
- **SC-007**: Zero unauthorized access to admin endpoints (verified by security tests)
|
||||
- **SC-008**: System gracefully degrades when AI disabled with zero error reports from confused users
|
||||
@@ -0,0 +1,229 @@
|
||||
// File: specs/200-fullstacks/227-ai-admin-console/tasks.md
|
||||
// Change Log:
|
||||
// - 2026-05-20: Initial task list for AI Admin Console
|
||||
// - 2026-05-21: Restructure following speckit tasks-template.md format
|
||||
|
||||
# Tasks: AI Admin Console
|
||||
|
||||
**Input**: Design documents from `/specs/200-fullstacks/227-ai-admin-console/`
|
||||
**Prerequisites**: plan.md (required), spec.md (required for user stories)
|
||||
**Tests**: Include unit tests for Guard, Service, and Controller layers
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Project initialization and basic structure setup
|
||||
|
||||
- [X] T001 Create feature branch `227-ai-admin-console` from main
|
||||
- [X] T002 [P] Setup AI Admin Console folder structure in `frontend/app/(admin)/admin/ai/`
|
||||
- [X] T003 [P] Verify shared `QUEUE_AI_BATCH` usage for admin sandbox per ADR-027 (no `QUEUE_AI_ADMIN_SANDBOX`)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites) ⚠️
|
||||
|
||||
**Purpose**: Core infrastructure that MUST be complete before ANY user story can be implemented
|
||||
|
||||
**⚠️ CRITICAL**: No user story work can begin until this phase is complete
|
||||
|
||||
- [X] T004 Create `system_settings` table SQL in `specs/03-Data-and-Storage/lcbp3-v1.9.0-schema-02-tables.sql` (per ADR-009)
|
||||
- [X] T005 [P] Create `SystemSetting` entity in `backend/src/modules/ai/entities/system-setting.entity.ts`
|
||||
- [X] T006 [P] Register `SystemSetting` entity in `backend/src/modules/ai/ai.module.ts` TypeORM forFeature
|
||||
- [X] T007 [P] Create `AiEnabledGuard` in `backend/src/modules/ai/guards/ai-enabled.guard.ts`
|
||||
- [X] T008 Implement `getAiFeaturesEnabled()` and `setAiFeaturesEnabled()` methods in `backend/src/modules/ai/ai-settings.service.ts` with Redis caching
|
||||
- [X] T009 Keep existing `ai-batch` BullMQ registration for admin sandbox per ADR-027
|
||||
- [X] T010 Defer sandbox job handling to `AiBatchProcessor` per ADR-027 (no separate `AiSandboxProcessor`)
|
||||
|
||||
**Checkpoint**: Foundation ready - user story implementation can now begin in parallel
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - Superadmin Toggles AI System On/Off (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Enable Superadmin to dynamically control AI system availability with database persistence and Redis caching
|
||||
|
||||
**Independent Test**: Superadmin can toggle AI switch and verify state persists across page refreshes and API calls
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [X] T011 [P] [US1] Unit test for `AiSettingsService.getAiFeaturesEnabled()` cache behavior in `backend/src/modules/ai/ai-settings.service.spec.ts`
|
||||
- [X] T012 [P] [US1] Unit test for `AiEnabledGuard` blocking/allowing logic in `backend/src/modules/ai/guards/ai-enabled.guard.spec.ts`
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T013 [US1] Implement `POST /ai/admin/toggle` endpoint in `backend/src/modules/ai/ai.controller.ts` with `@RequirePermission('system.manage_all')`
|
||||
- [X] T014 [US1] Implement `GET /ai/admin/settings` endpoint to return current AI enabled state
|
||||
- [X] T015 [US1] Add cache invalidation logic in `AiSettingsService.setAiFeaturesEnabled()` after DB update (TypeORM transaction)
|
||||
- [X] T016 [US1] Apply `AiEnabledGuard` to existing AI endpoints in `AiController` (suggest, rag_query)
|
||||
- [X] T017 [US1] Create `admin-ai.service.ts` in `frontend/lib/services/admin-ai.service.ts` with toggle API methods
|
||||
- [X] T018 [US1] Build AI toggle switch component in `frontend/app/(admin)/admin/ai/page.tsx` (Header Switch section)
|
||||
- [X] T019 [US1] Create `useAiStatus()` hook in `frontend/hooks/use-ai-status.ts` for polling AI state
|
||||
|
||||
**Checkpoint**: At this point, User Story 1 should be fully functional and testable independently
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Normal Users Experience Soft Fallback (Priority: P1)
|
||||
|
||||
**Goal**: Implement soft fallback UX with disabled buttons, tooltips, and global banner when AI is turned off
|
||||
|
||||
**Independent Test**: Disable AI as admin, then verify regular user sees disabled buttons with tooltips and global banner
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [x] T020 [P] [US2] Unit test for soft fallback component rendering in `frontend/components/ai/__tests__/ai-suggestion-button.test.tsx`
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [x] T021 [US2] Create `AiSuggestionButton` component in `frontend/components/ai/ai-suggestion-button.tsx` with disabled state and tooltip
|
||||
- [x] T022 [US2] Create `AiStatusBanner` component in `frontend/components/ai/AiStatusBanner.tsx` for global banner display
|
||||
- [x] T023 [US2] Integrate AI status polling (30s interval) in `frontend/providers/session-provider.tsx` or layout
|
||||
- [x] T024 [US2] Update document forms (RFA/Correspondence) to use `AiSuggestionButton` with AI status check
|
||||
- [x] T025 [US2] Implement HTTP 503 error handling in `frontend/lib/api/client.ts` for AI endpoint failures
|
||||
|
||||
**Checkpoint**: User Stories 1 AND 2 should both work independently (toggle affects user experience)
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Superadmin Monitors AI Health Status (Priority: P2)
|
||||
|
||||
**Goal**: Provide real-time health monitoring dashboard for Ollama, Qdrant, and BullMQ
|
||||
|
||||
**Independent Test**: Access AI Admin Console and verify all health metrics display correctly
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [X] T026 [P] [US3] Unit test for `AiService.getSystemHealth()` in `backend/src/modules/ai/ai.service.spec.ts`
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T027 [US3] Implement `getSystemHealth()` method in `backend/src/modules/ai/ai.service.ts` with 5s timeout per service
|
||||
- [X] T028 [US3] Implement `GET /ai/admin/health` endpoint in `backend/src/modules/ai/ai.controller.ts`
|
||||
- [X] T029 [US3] Add health check caching (30s) using Redis or in-memory cache with TTL
|
||||
- [X] T030 [US3] Create Health Indicator cards component in `frontend/app/(admin)/admin/ai/page.tsx`
|
||||
- [X] T031 [US3] Implement health status polling (30s) in admin console page
|
||||
|
||||
**Checkpoint**: All health monitoring features functional and independently testable
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: User Story 4 - Superadmin Uses RAG Playground Sandbox (Priority: P2)
|
||||
|
||||
**Goal**: Enable isolated RAG testing environment with sandbox queue and job polling
|
||||
|
||||
**Independent Test**: Submit RAG query in sandbox and receive response with citations
|
||||
|
||||
### Tests for User Story 4
|
||||
|
||||
- [X] T032 [P] [US4] Unit test for RAG sandbox job processing in `backend/src/modules/ai/processors/ai-sandbox.processor.spec.ts` (Unified in ai-batch.processor.spec.ts)
|
||||
|
||||
### Implementation for User Story 4
|
||||
|
||||
- [X] T033 [US4] Implement `sandbox-rag` job handler in `backend/src/modules/ai/processors/ai-sandbox.processor.ts` (Unified in ai-batch.processor.ts)
|
||||
- [X] T034 [US4] Add `enqueueSandboxJob()` method in `backend/src/modules/ai/ai-queue.service.ts` with SUPERADMIN priority
|
||||
- [X] T035 [US4] Implement `POST /ai/admin/sandbox/rag` endpoint in `backend/src/modules/ai/ai.controller.ts`
|
||||
- [X] T036 [US4] Implement `GET /ai/admin/sandbox/job/:id` endpoint for job status polling
|
||||
- [X] T037 [US4] Create RAG Playground tab UI in `frontend/app/(admin)/admin/ai/page.tsx`
|
||||
- [X] T038 [US4] Implement job status polling (5s) with progress display in RAG Playground
|
||||
|
||||
**Checkpoint**: RAG sandbox fully functional with isolated queue processing
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: User Story 5 - Superadmin Uses OCR Sandbox (Priority: P2)
|
||||
|
||||
**Goal**: Provide isolated OCR sandbox for PDF metadata extraction with JSON output
|
||||
|
||||
**Independent Test**: Upload PDF to OCR sandbox and receive valid JSON extraction results
|
||||
|
||||
### Tests for User Story 5
|
||||
|
||||
- [X] T039 [P] [US5] Unit test for OCR sandbox job processing in `backend/src/modules/ai/processors/ai-sandbox.processor.spec.ts`
|
||||
|
||||
### Implementation for User Story 5
|
||||
|
||||
- [X] T040 [US5] Implement `sandbox-extract` job handler in `backend/src/modules/ai/processors/ai-sandbox.processor.ts`
|
||||
- [X] T041 [US5] Implement `POST /ai/admin/sandbox/extract` endpoint in `backend/src/modules/ai/ai.controller.ts`
|
||||
- [X] T042 [US5] Implement dynamic rate limiting logic (queue < 3: no limit, >= 3: 10 req/hr) in controller
|
||||
- [X] T043 [US5] Create OCR Sandbox tab with drag-drop file upload in `frontend/app/(admin)/admin/ai/page.tsx`
|
||||
- [X] T044 [US5] Implement JSON output display with syntax highlighting in OCR Sandbox tab
|
||||
- [X] T045 [US5] Add inline error display (red box) for failed OCR extractions
|
||||
|
||||
**Checkpoint**: OCR sandbox fully functional with rate limiting and error handling
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Improvements that affect multiple user stories
|
||||
|
||||
- [X] T046 [P] Add "AI Console" menu item in `frontend/components/admin/sidebar.tsx` (Superadmin only)
|
||||
- [X] T047 [P] Update agent context via `update-agent-context.sh` with new AI Admin Console patterns
|
||||
- [X] T048 Security hardening: Verify all admin endpoints require `system.manage_all` permission
|
||||
- [X] T049 Run `quickstart.md` validation and walkthrough tests
|
||||
- [X] T050 Create `walkthrough.md` documenting end-to-end testing procedures
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: No dependencies - can start immediately
|
||||
- **Foundational (Phase 2)**: Depends on Setup completion - BLOCKS all user stories
|
||||
- **User Stories (Phase 3-7)**: All depend on Foundational phase completion
|
||||
- User stories can proceed in parallel (if staffed)
|
||||
- Or sequentially in priority order (P1 → P2)
|
||||
- **Polish (Phase 8)**: Depends on all desired user stories being complete
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **User Story 1 (P1)**: Can start after Foundational (Phase 2) - No dependencies
|
||||
- **User Story 2 (P1)**: Can start after Foundational (Phase 2) - Integrates with US1 toggle state
|
||||
- **User Story 3 (P2)**: Can start after Foundational (Phase 2) - Independent monitoring feature
|
||||
- **User Story 4 (P2)**: Can start after Foundational (Phase 2) - Uses same sandbox queue infrastructure
|
||||
- **User Story 5 (P2)**: Can start after Foundational (Phase 2) - Shares OCR extraction with US4 patterns
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Tests (if included) MUST be written and FAIL before implementation
|
||||
- Services before controllers
|
||||
- Controllers before frontend integration
|
||||
- Core implementation before polish
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- All Setup tasks marked [P] can run in parallel
|
||||
- All Foundational tasks marked [P] can run in parallel (within Phase 2)
|
||||
- Once Foundational is done, US3/4/5 can start in parallel (independent P2 stories)
|
||||
- US1 and US2 should be developed sequentially (toggle affects fallback)
|
||||
- Different story tests can run in parallel
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Stories 1 & 2)
|
||||
|
||||
1. Complete Phase 1: Setup
|
||||
2. Complete Phase 2: Foundational (CRITICAL - blocks all stories)
|
||||
3. Complete Phase 3: User Story 1 (toggle mechanism)
|
||||
4. Complete Phase 4: User Story 2 (soft fallback)
|
||||
5. **STOP and VALIDATE**: Test toggle → fallback flow end-to-end
|
||||
6. Deploy/demo MVP
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. MVP (US1 + US2) → Deploy
|
||||
2. Add US3 (Health Monitoring) → Deploy
|
||||
3. Add US4 (RAG Sandbox) → Deploy
|
||||
4. Add US5 (OCR Sandbox) → Deploy
|
||||
|
||||
### Parallel Team Strategy
|
||||
|
||||
With multiple developers post-Foundational:
|
||||
|
||||
- Developer A: US1 + US2 (core control + fallback)
|
||||
- Developer B: US3 (health monitoring)
|
||||
- Developer C: US4 + US5 (sandbox features)
|
||||
@@ -0,0 +1,69 @@
|
||||
// File: specs/200-fullstacks/227-ai-admin-console/walkthrough.md
|
||||
// Change Log
|
||||
// - 2026-05-21: สร้าง Walkthrough สำหรับการประเมินและตรวจสอบสิทธิ์ระบบ AI Admin Console (Phase 7 & Phase 8)
|
||||
|
||||
# Walkthrough: AI Admin Console (Phase 7 & 8 Completed)
|
||||
|
||||
เอกสารนี้สรุปผลการพัฒนาและตรวจสอบระบบ **AI Admin Console** สำหรับสิทธิ์ผู้ดูแลระบบระดับสูง (Superadmin) ในเฟสที่ 7 และ 8
|
||||
|
||||
---
|
||||
|
||||
## 🎯 รายละเอียดของฟีเจอร์และสิ่งที่ระบบทำสำเร็จ
|
||||
|
||||
การพัฒนานี้ต่อยอดความสามารถของระบบจัดการ AI (ADR-023/ADR-023A/ADR-027) เพื่อให้ Superadmin สามารถ:
|
||||
1. **ทดสอบการทำ OCR และการสกัด Metadata ในสภาพแวดล้อมจำลอง (OCR Sandbox Playground)** ผ่านการอัปโหลดไฟล์ PDF
|
||||
2. **ประมวลผล OCR และการสกัดแบบไม่ระบุตัวตน (Anonymous / Sandboxed)** โดยระบบจะไม่เขียนข้อมูลลงฐานข้อมูลจริง
|
||||
3. **กำหนดและคุมสิทธิ์อัตราการใช้งานด้วย Dynamic Rate Limiting** ป้องกันปัญหาคิวประมวลผลหนาแน่น (Queue bottleneck) เมื่อคิวในระบบมีงาน >= 3 งาน
|
||||
4. **ความมั่นใจด้านความปลอดภัยสูงสุด (Security Hardening)** ป้องกันไม่ให้ผู้ใช้ภายนอกเข้าถึง Endpoint ของ AI Admin Console ผ่าน CASL Permission `system.manage_all`
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ รายการไฟล์ที่เปลี่ยนแปลง (Proposed & Implemented Changes)
|
||||
|
||||
### Backend (NestJS)
|
||||
- **[MODIFY] [ai-batch.processor.ts](file:///e:/np-dms/lcbp3/backend/src/modules/ai/processors/ai-batch.processor.ts)**
|
||||
- เพิ่มการประมวลผลงาน `sandbox-extract` ด้วย `ocrService` และเรียกใช้งาน `ollamaService` เพื่อจำลองการสกัด Metadata
|
||||
- บันทึกผลลัพธ์ลง Redis cache (`ai:rag:result:${idempotencyKey}`) ด้วยเวลาหมดอายุ 1 ชั่วโมง (EC-004)
|
||||
- ไม่บันทึกข้อมูลลงฐานข้อมูลของระบบ (Database Bypass)
|
||||
- **[MODIFY] [ai.controller.ts](file:///e:/np-dms/lcbp3/backend/src/modules/ai/ai.controller.ts)**
|
||||
- เพิ่ม Endpoint `POST /ai/admin/sandbox/extract` รองรับการอัปโหลดไฟล์ PDF ขนาดไม่เกิน 50MB
|
||||
- ดำเนินการเช็คขนาดคิว BullMQ `QUEUE_AI_BATCH` ในการเปิดใช้งาน Dynamic Rate Limiting: ถ้าขนาดคิว >= 3 จะจำกัดไม่เกิน 10 requests/hour ต่อผู้ใช้
|
||||
- **[MODIFY] [ai-batch.processor.spec.ts](file:///e:/np-dms/lcbp3/backend/src/modules/ai/processors/ai-batch.processor.spec.ts)**
|
||||
- เพิ่ม Unit Test ครอบคลุมการทำงานจำลอง OCR Sandbox และการบันทึกผลลง Redis
|
||||
|
||||
### Frontend (Next.js)
|
||||
- **[MODIFY] [admin-ai.service.ts](file:///e:/np-dms/lcbp3/frontend/lib/services/admin-ai.service.ts)**
|
||||
- เพิ่มฟังก์ชัน `uploadOcrSandbox` สำหรับการอัปโหลด PDF ไปประมวลผลและ `getSandboxJobStatus` สำหรับการดึงข้อมูลสถานะงาน
|
||||
- **[MODIFY] [page.tsx](file:///e:/np-dms/lcbp3/frontend/app/(admin)/admin/ai/page.tsx)**
|
||||
- ปรับแก้โครงสร้างของแท็บ OCR Playground ให้รองรับการ Drag-and-Drop ไฟล์ และแสดงความก้าวหน้าการสกัดผลลัพธ์ผ่าน Progress bar
|
||||
- ทำการพ่นโค้ด JSON syntax highlight ให้สวยงาม และแสดงกล่องข้อความสีแดงเตือนกรณีเกิดข้อผิดพลาดในการประมวลผล
|
||||
|
||||
---
|
||||
|
||||
## 🧪 แผนและการทดสอบ (What was Tested & Validation Results)
|
||||
|
||||
### 1. การตรวจสอบ Unit Tests บน Backend
|
||||
เราได้รันการทดสอบ Unit Tests ของระบบ AI Batch Processor โดยจำลองสถานการณ์ทั้งหมด:
|
||||
```bash
|
||||
pnpm test src/modules/ai/processors/ai-batch.processor.spec.ts
|
||||
```
|
||||
**ผลการทดสอบ**:
|
||||
- `ควรสามารถเรียก process embed-document และอัปเดตสถานะใน database` -> ผ่าน (PASS)
|
||||
- `ควรประมวลผล sandbox-rag โดยการเรียก ragService.processQuery และข้ามการอัปเดต database` -> ผ่าน (PASS)
|
||||
- `ควรประมวลผล sandbox-extract โดยใช้ OcrService, OllamaService และเก็บค่าลง Redis` -> ผ่าน (PASS)
|
||||
|
||||
### 2. การตรวจสอบการคอมไพล์ Frontend (TypeScript Verification)
|
||||
ดำเนินการประเมิน Type safety ของโค้ด Next.js:
|
||||
```bash
|
||||
npx tsc --noEmit
|
||||
```
|
||||
**ผลการทดสอบ**:
|
||||
- คอมไพล์ได้ผ่าน ปราศจากข้อผิดพลาดด้าน TypeScript (Zero compiler errors!)
|
||||
|
||||
### 3. การตรวจสอบความปลอดภัย (Security Audit)
|
||||
- การทดสอบเรียก API ของผู้ดูแลระบบด้วย JWT ของผู้ใช้ธรรมดาที่ไม่ได้รับบทบาท Superadmin จะถูกบล็อกด้วยข้อผิดพลาด HTTP 403 Forbidden เสมอ
|
||||
|
||||
---
|
||||
|
||||
> [!NOTE]
|
||||
> ฟังก์ชัน OCR Sandbox Playground นี้จำกัดการเข้าถึงเฉพาะ Superadmin เท่านั้น (จำเป็นต้องมีสิทธิ์ `system.manage_all` ในระบบ)
|
||||
Reference in New Issue
Block a user