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",
|
"start:prod": "node dist/main",
|
||||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
"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\"",
|
"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:debug-handles": "jest --config jest.config.js --detectOpenHandles",
|
||||||
"test:watch": "jest --config jest.config.js --watch",
|
"test:watch": "jest --config jest.config.js --watch",
|
||||||
"test:cov": "jest --config jest.config.js --coverage",
|
"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: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: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"
|
"seed": "ts-node -r tsconfig-paths/register src/database/seeds/run-seed.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
// Change Log
|
// Change Log
|
||||||
// - 2026-05-14: เพิ่ม service กลางสำหรับส่งงาน AI เข้า BullMQ ตาม ADR-023.
|
// - 2026-05-14: เพิ่ม service กลางสำหรับส่งงาน AI เข้า BullMQ ตาม ADR-023.
|
||||||
// - 2026-05-14: เพิ่ม JSDoc idempotency contract สำหรับทุก enqueue method (💡 S3).
|
// - 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 { Injectable } from '@nestjs/common';
|
||||||
import { InjectQueue } from '@nestjs/bullmq';
|
import { InjectQueue } from '@nestjs/bullmq';
|
||||||
import { Queue, JobsOptions } from 'bullmq';
|
import { Queue, JobsOptions } from 'bullmq';
|
||||||
@@ -9,6 +11,7 @@ import {
|
|||||||
QUEUE_AI_INGEST,
|
QUEUE_AI_INGEST,
|
||||||
QUEUE_AI_RAG,
|
QUEUE_AI_RAG,
|
||||||
QUEUE_AI_VECTOR_DELETION,
|
QUEUE_AI_VECTOR_DELETION,
|
||||||
|
QUEUE_AI_BATCH,
|
||||||
} from '../common/constants/queue.constants';
|
} from '../common/constants/queue.constants';
|
||||||
|
|
||||||
/** Payload สำหรับงาน ingest เอกสารเก่าเข้า AI Pipeline */
|
/** Payload สำหรับงาน ingest เอกสารเก่าเข้า AI Pipeline */
|
||||||
@@ -48,7 +51,9 @@ export class AiQueueService {
|
|||||||
@InjectQueue(QUEUE_AI_RAG)
|
@InjectQueue(QUEUE_AI_RAG)
|
||||||
private readonly ragQueue: Queue<AiRagJobPayload>,
|
private readonly ragQueue: Queue<AiRagJobPayload>,
|
||||||
@InjectQueue(QUEUE_AI_VECTOR_DELETION)
|
@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);
|
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: เพิ่ม Legacy Migration staging endpoints ตาม ADR-023.
|
||||||
// - 2026-05-14: ย้าย DeleteAuditLogsQueryDto ไป dto/ folder; ลบ authHeader passthrough (🟢 LOW-1/LOW-2).
|
// - 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-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)
|
// Controller สำหรับ AI Gateway Endpoints (ADR-023)
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -20,8 +24,13 @@ import {
|
|||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
UploadedFiles,
|
UploadedFiles,
|
||||||
|
UploadedFile,
|
||||||
|
HttpException,
|
||||||
|
ParseFilePipe,
|
||||||
|
MaxFileSizeValidator,
|
||||||
|
FileTypeValidator,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { FilesInterceptor } from '@nestjs/platform-express';
|
import { FilesInterceptor, FileInterceptor } from '@nestjs/platform-express';
|
||||||
import { Throttle } from '@nestjs/throttler';
|
import { Throttle } from '@nestjs/throttler';
|
||||||
import {
|
import {
|
||||||
ApiTags,
|
ApiTags,
|
||||||
@@ -32,6 +41,7 @@ import {
|
|||||||
ApiQuery,
|
ApiQuery,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
import { AiService, ExtractionResult, PaginatedResult } from './ai.service';
|
import { AiService, ExtractionResult, PaginatedResult } from './ai.service';
|
||||||
|
import { AiSettingsService } from './ai-settings.service';
|
||||||
import {
|
import {
|
||||||
AiIngestService,
|
AiIngestService,
|
||||||
MigrationReviewResponse,
|
MigrationReviewResponse,
|
||||||
@@ -62,6 +72,11 @@ import { v7 as uuidv7 } from 'uuid';
|
|||||||
import { DeleteAuditLogsQueryDto } from './dto/delete-audit-logs.dto';
|
import { DeleteAuditLogsQueryDto } from './dto/delete-audit-logs.dto';
|
||||||
import { AiToolRegistryService } from './tool/ai-tool-registry.service';
|
import { AiToolRegistryService } from './tool/ai-tool-registry.service';
|
||||||
import { AiIntentRequestDto } from './dto/ai-intent-request.dto';
|
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')
|
@ApiTags('AI Gateway')
|
||||||
@Controller('ai')
|
@Controller('ai')
|
||||||
@@ -71,7 +86,10 @@ export class AiController {
|
|||||||
private readonly aiIngestService: AiIngestService,
|
private readonly aiIngestService: AiIngestService,
|
||||||
private readonly aiRagService: AiRagService,
|
private readonly aiRagService: AiRagService,
|
||||||
private readonly aiQueueService: AiQueueService,
|
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) ---
|
// --- Real-time Extraction (User Upload) ---
|
||||||
@@ -79,7 +97,7 @@ export class AiController {
|
|||||||
// ─── AI Tool Layer Endpoint (ADR-025) ──────────────────────────────────────
|
// ─── AI Tool Layer Endpoint (ADR-025) ──────────────────────────────────────
|
||||||
|
|
||||||
@Post('intent')
|
@Post('intent')
|
||||||
@UseGuards(JwtAuthGuard, RbacGuard)
|
@UseGuards(JwtAuthGuard, AiEnabledGuard, RbacGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@RequirePermission('ai.suggest')
|
@RequirePermission('ai.suggest')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@@ -111,7 +129,7 @@ export class AiController {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@Post('suggest')
|
@Post('suggest')
|
||||||
@UseGuards(JwtAuthGuard, RbacGuard)
|
@UseGuards(JwtAuthGuard, AiEnabledGuard, RbacGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@RequirePermission('ai.suggest')
|
@RequirePermission('ai.suggest')
|
||||||
@HttpCode(HttpStatus.ACCEPTED)
|
@HttpCode(HttpStatus.ACCEPTED)
|
||||||
@@ -154,7 +172,7 @@ export class AiController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('extract')
|
@Post('extract')
|
||||||
@UseGuards(JwtAuthGuard, RbacGuard)
|
@UseGuards(JwtAuthGuard, AiEnabledGuard, RbacGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@RequirePermission('ai.extract')
|
@RequirePermission('ai.extract')
|
||||||
@Throttle({ default: { limit: 5, ttl: 60000 } }) // Rate limit: 5 requests/minute (ADR-020)
|
@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);
|
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) ---
|
// --- Webhook Callback จาก n8n (Service Account) ---
|
||||||
|
|
||||||
@Post('callback')
|
@Post('callback')
|
||||||
@@ -324,7 +504,7 @@ export class AiController {
|
|||||||
// ─── RAG Query Endpoints (Phase 4 — FR-009, FR-010, FR-011) ────────────────
|
// ─── RAG Query Endpoints (Phase 4 — FR-009, FR-010, FR-011) ────────────────
|
||||||
|
|
||||||
@Post('rag/query')
|
@Post('rag/query')
|
||||||
@UseGuards(JwtAuthGuard, RbacGuard)
|
@UseGuards(JwtAuthGuard, AiEnabledGuard, RbacGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Throttle({ default: { limit: 5, ttl: 60000 } }) // Rate limit: 5 requests/minute per user (FR-010)
|
@Throttle({ default: { limit: 5, ttl: 60000 } }) // Rate limit: 5 requests/minute per user (FR-010)
|
||||||
@RequirePermission('rag.query')
|
@RequirePermission('rag.query')
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
// - 2026-05-15: เพิ่ม ai-realtime/ai-batch foundation และ stale paused recovery ตาม ADR-023A.
|
// - 2026-05-15: เพิ่ม ai-realtime/ai-batch foundation และ stale paused recovery ตาม ADR-023A.
|
||||||
// - 2026-05-19: เพิ่ม IntentClassifierModule (ADR-024 Intent Classification).
|
// - 2026-05-19: เพิ่ม IntentClassifierModule (ADR-024 Intent Classification).
|
||||||
// - 2026-05-19: เพิ่ม AiToolModule (ADR-025 AI Tool Layer).
|
// - 2026-05-19: เพิ่ม AiToolModule (ADR-025 AI Tool Layer).
|
||||||
|
// - 2026-05-21: ลงทะเบียน SystemSetting, AiSettingsService และ AiEnabledGuard สำหรับ ADR-027.
|
||||||
// Module สำหรับ AI Gateway — ลงทะเบียน Services และ Controllers (ADR-023)
|
// Module สำหรับ AI Gateway — ลงทะเบียน Services และ Controllers (ADR-023)
|
||||||
|
|
||||||
import { Logger, Module, OnModuleInit } from '@nestjs/common';
|
import { Logger, Module, OnModuleInit } from '@nestjs/common';
|
||||||
@@ -15,6 +16,7 @@ import { RedisModule } from '@nestjs-modules/ioredis';
|
|||||||
import { Queue } from 'bullmq';
|
import { Queue } from 'bullmq';
|
||||||
import { AiController } from './ai.controller';
|
import { AiController } from './ai.controller';
|
||||||
import { AiService } from './ai.service';
|
import { AiService } from './ai.service';
|
||||||
|
import { AiSettingsService } from './ai-settings.service';
|
||||||
import { AiIngestService } from './ai-ingest.service';
|
import { AiIngestService } from './ai-ingest.service';
|
||||||
import { AiQueueService } from './ai-queue.service';
|
import { AiQueueService } from './ai-queue.service';
|
||||||
import { AiQdrantService } from './qdrant.service';
|
import { AiQdrantService } from './qdrant.service';
|
||||||
@@ -30,6 +32,8 @@ import { EmbeddingService } from './services/embedding.service';
|
|||||||
import { MigrationLog } from './entities/migration-log.entity';
|
import { MigrationLog } from './entities/migration-log.entity';
|
||||||
import { AiAuditLog } from './entities/ai-audit-log.entity';
|
import { AiAuditLog } from './entities/ai-audit-log.entity';
|
||||||
import { MigrationReviewRecord } from './entities/migration-review.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 { UserModule } from '../user/user.module';
|
||||||
import { MigrationModule } from '../migration/migration.module';
|
import { MigrationModule } from '../migration/migration.module';
|
||||||
import { FileStorageModule } from '../../common/file-storage/file-storage.module';
|
import { FileStorageModule } from '../../common/file-storage/file-storage.module';
|
||||||
@@ -58,6 +62,7 @@ import {
|
|||||||
AiAuditLog,
|
AiAuditLog,
|
||||||
AuditLog,
|
AuditLog,
|
||||||
MigrationReviewRecord,
|
MigrationReviewRecord,
|
||||||
|
SystemSetting,
|
||||||
Attachment,
|
Attachment,
|
||||||
Project,
|
Project,
|
||||||
Organization,
|
Organization,
|
||||||
@@ -114,6 +119,7 @@ import {
|
|||||||
controllers: [AiController],
|
controllers: [AiController],
|
||||||
providers: [
|
providers: [
|
||||||
AiService,
|
AiService,
|
||||||
|
AiSettingsService,
|
||||||
AiIngestService,
|
AiIngestService,
|
||||||
AiQueueService,
|
AiQueueService,
|
||||||
AiQdrantService,
|
AiQdrantService,
|
||||||
@@ -130,9 +136,11 @@ import {
|
|||||||
AiVectorDeletionProcessor,
|
AiVectorDeletionProcessor,
|
||||||
// RbacGuard ต้องการ UserService จาก UserModule
|
// RbacGuard ต้องการ UserService จาก UserModule
|
||||||
RbacGuard,
|
RbacGuard,
|
||||||
|
AiEnabledGuard,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
AiService,
|
AiService,
|
||||||
|
AiSettingsService,
|
||||||
AiIngestService,
|
AiIngestService,
|
||||||
AiQueueService,
|
AiQueueService,
|
||||||
AiQdrantService,
|
AiQdrantService,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
// File: src/modules/ai/ai.service.spec.ts
|
// File: src/modules/ai/ai.service.spec.ts
|
||||||
// Unit Tests สำหรับ AiService — ทดสอบ Business Logic สำคัญ: Callback, Update, Status Transitions
|
// 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 { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||||
@@ -21,6 +23,10 @@ import {
|
|||||||
QUEUE_AI_BATCH,
|
QUEUE_AI_BATCH,
|
||||||
QUEUE_AI_REALTIME,
|
QUEUE_AI_REALTIME,
|
||||||
} from '../common/constants/queue.constants';
|
} from '../common/constants/queue.constants';
|
||||||
|
import { OllamaService } from './services/ollama.service';
|
||||||
|
import { AiQdrantService } from './qdrant.service';
|
||||||
|
|
||||||
|
const DEFAULT_REDIS_TOKEN = 'default_IORedisModuleConnectionToken';
|
||||||
|
|
||||||
describe('AiService', () => {
|
describe('AiService', () => {
|
||||||
let service: AiService;
|
let service: AiService;
|
||||||
@@ -52,11 +58,35 @@ describe('AiService', () => {
|
|||||||
const mockQueue = {
|
const mockQueue = {
|
||||||
add: jest.fn(),
|
add: jest.fn(),
|
||||||
isPaused: jest.fn().mockResolvedValue(false),
|
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(),
|
resume: jest.fn(),
|
||||||
getState: jest.fn().mockResolvedValue('completed'),
|
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
|
// Mock ConfigService — คืนค่า Config ตาม Key
|
||||||
const mockConfigService = {
|
const mockConfigService = {
|
||||||
get: jest.fn((key: string) => {
|
get: jest.fn((key: string) => {
|
||||||
@@ -119,6 +149,9 @@ describe('AiService', () => {
|
|||||||
{ provide: ConfigService, useValue: mockConfigService },
|
{ provide: ConfigService, useValue: mockConfigService },
|
||||||
{ provide: HttpService, useValue: mockHttpService },
|
{ provide: HttpService, useValue: mockHttpService },
|
||||||
{ provide: AiValidationService, useValue: mockValidationService },
|
{ provide: AiValidationService, useValue: mockValidationService },
|
||||||
|
{ provide: OllamaService, useValue: mockOllamaService },
|
||||||
|
{ provide: AiQdrantService, useValue: mockQdrantService },
|
||||||
|
{ provide: DEFAULT_REDIS_TOKEN, useValue: mockRedis },
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
@@ -321,4 +354,67 @@ describe('AiService', () => {
|
|||||||
expect(result).toHaveProperty('totalPages');
|
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
|
// File: src/modules/ai/ai.service.ts
|
||||||
// Service หลักของ AI Gateway — เชื่อมต่อระหว่าง DMS กับ n8n/Ollama Pipeline (ADR-018, ADR-020)
|
// 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 { Injectable, Logger, Optional } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { HttpService } from '@nestjs/axios';
|
import { HttpService } from '@nestjs/axios';
|
||||||
import { InjectQueue } from '@nestjs/bullmq';
|
import { InjectQueue } from '@nestjs/bullmq';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { InjectRedis } from '@nestjs-modules/ioredis';
|
||||||
|
import type Redis from 'ioredis';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { Job, Queue } from 'bullmq';
|
import { Job, Queue } from 'bullmq';
|
||||||
import { firstValueFrom, timeout, catchError } from 'rxjs';
|
import { firstValueFrom, timeout, catchError } from 'rxjs';
|
||||||
@@ -35,6 +39,8 @@ import {
|
|||||||
import { AiRealtimeJobData } from './processors/ai-realtime.processor';
|
import { AiRealtimeJobData } from './processors/ai-realtime.processor';
|
||||||
import { AiBatchJobData } from './processors/ai-batch.processor';
|
import { AiBatchJobData } from './processors/ai-batch.processor';
|
||||||
import { AuditLog } from '../../common/entities/audit-log.entity';
|
import { AuditLog } from '../../common/entities/audit-log.entity';
|
||||||
|
import { OllamaService } from './services/ollama.service';
|
||||||
|
import { AiQdrantService } from './qdrant.service';
|
||||||
|
|
||||||
// ผลลัพธ์ของ Real-time Extraction
|
// ผลลัพธ์ของ Real-time Extraction
|
||||||
export interface ExtractionResult {
|
export interface ExtractionResult {
|
||||||
@@ -97,6 +103,42 @@ export interface AiJobStatusResult {
|
|||||||
failedReason?: string;
|
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()
|
@Injectable()
|
||||||
export class AiService {
|
export class AiService {
|
||||||
private readonly logger = new Logger(AiService.name);
|
private readonly logger = new Logger(AiService.name);
|
||||||
@@ -122,7 +164,14 @@ export class AiService {
|
|||||||
private readonly aiRealtimeQueue?: Queue<AiRealtimeJobData>,
|
private readonly aiRealtimeQueue?: Queue<AiRealtimeJobData>,
|
||||||
@Optional()
|
@Optional()
|
||||||
@InjectQueue(QUEUE_AI_BATCH)
|
@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.n8nWebhookUrl =
|
||||||
this.configService.get<string>('AI_N8N_WEBHOOK_URL') ?? '';
|
this.configService.get<string>('AI_N8N_WEBHOOK_URL') ?? '';
|
||||||
@@ -678,6 +727,76 @@ export class AiService {
|
|||||||
return { deleted: true, publicId };
|
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(
|
private async toJobStatus(
|
||||||
jobId: string,
|
jobId: string,
|
||||||
queue: 'ai-realtime' | 'ai-batch',
|
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
|
// Change Log
|
||||||
// - 2026-05-15: เพิ่ม processor สำหรับ ai-batch queue ตาม ADR-023A.
|
// - 2026-05-15: เพิ่ม processor สำหรับ ai-batch queue ตาม ADR-023A.
|
||||||
// - 2026-05-15: เพิ่ม EmbeddingService สำหรับ embed-document logic (T022).
|
// - 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 { Processor, WorkerHost } from '@nestjs/bullmq';
|
||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
import { Job } from 'bullmq';
|
import { Job } from 'bullmq';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from '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 { Attachment } from '../../../common/file-storage/entities/attachment.entity';
|
||||||
import { QUEUE_AI_BATCH } from '../../common/constants/queue.constants';
|
import { QUEUE_AI_BATCH } from '../../common/constants/queue.constants';
|
||||||
import { EmbeddingService } from '../services/embedding.service';
|
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 {
|
export interface AiBatchJobData {
|
||||||
jobType: AiBatchJobType;
|
jobType: AiBatchJobType;
|
||||||
@@ -27,36 +40,62 @@ export interface AiBatchJobData {
|
|||||||
@Processor(QUEUE_AI_BATCH, { concurrency: 1 })
|
@Processor(QUEUE_AI_BATCH, { concurrency: 1 })
|
||||||
export class AiBatchProcessor extends WorkerHost {
|
export class AiBatchProcessor extends WorkerHost {
|
||||||
private readonly logger = new Logger(AiBatchProcessor.name);
|
private readonly logger = new Logger(AiBatchProcessor.name);
|
||||||
|
private readonly abortControllers = new Map<string, AbortController>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(Attachment)
|
@InjectRepository(Attachment)
|
||||||
private readonly attachmentRepo: Repository<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();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Dispatch งาน batch ตาม jobType */
|
/** Dispatch งาน batch ตาม jobType */
|
||||||
async process(job: Job<AiBatchJobData>): Promise<void> {
|
async process(job: Job<AiBatchJobData>): Promise<void> {
|
||||||
|
const isSandbox =
|
||||||
|
job.data.jobType === 'sandbox-rag' ||
|
||||||
|
job.data.jobType === 'sandbox-extract';
|
||||||
|
if (!isSandbox) {
|
||||||
await this.setAiProcessingStatus(job.data.documentPublicId, 'PROCESSING');
|
await this.setAiProcessingStatus(job.data.documentPublicId, 'PROCESSING');
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
switch (job.data.jobType) {
|
switch (job.data.jobType) {
|
||||||
case 'ocr':
|
case 'ocr':
|
||||||
this.logger.log(`OCR batch job processing — jobId=${String(job.id)}`);
|
this.logger.log(`OCR batch job processing — jobId=${String(job.id)}`);
|
||||||
// OCR logic handled by OcrService in ai-realtime processor
|
if (!isSandbox) {
|
||||||
await this.setAiProcessingStatus(job.data.documentPublicId, 'DONE');
|
await this.setAiProcessingStatus(job.data.documentPublicId, 'DONE');
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
case 'extract-metadata':
|
case 'extract-metadata':
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Metadata extraction job processing — jobId=${String(job.id)}`
|
`Metadata extraction job processing — jobId=${String(job.id)}`
|
||||||
);
|
);
|
||||||
// Metadata extraction handled in ai-realtime processor
|
if (!isSandbox) {
|
||||||
await this.setAiProcessingStatus(job.data.documentPublicId, 'DONE');
|
await this.setAiProcessingStatus(job.data.documentPublicId, 'DONE');
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
case 'embed-document':
|
case 'embed-document':
|
||||||
this.logger.log(`Embedding job processing — jobId=${String(job.id)}`);
|
this.logger.log(`Embedding job processing — jobId=${String(job.id)}`);
|
||||||
await this.processEmbedDocument(job.data);
|
await this.processEmbedDocument(job.data);
|
||||||
|
if (!isSandbox) {
|
||||||
await this.setAiProcessingStatus(job.data.documentPublicId, 'DONE');
|
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;
|
return;
|
||||||
default: {
|
default: {
|
||||||
const unreachable: never = job.data.jobType;
|
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}`,
|
`Batch job failed — jobType=${job.data.jobType}, documentPublicId=${job.data.documentPublicId}`,
|
||||||
err instanceof Error ? err.stack : String(err)
|
err instanceof Error ? err.stack : String(err)
|
||||||
);
|
);
|
||||||
|
if (!isSandbox) {
|
||||||
await this.setAiProcessingStatus(job.data.documentPublicId, 'FAILED');
|
await this.setAiProcessingStatus(job.data.documentPublicId, 'FAILED');
|
||||||
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,27 +121,43 @@ export class AiBatchProcessor extends WorkerHost {
|
|||||||
const { documentPublicId, projectPublicId, payload } = data;
|
const { documentPublicId, projectPublicId, payload } = data;
|
||||||
const pdfPath = payload.pdfPath as string;
|
const pdfPath = payload.pdfPath as string;
|
||||||
const extractedText = payload.extractedText as string | undefined;
|
const extractedText = payload.extractedText as string | undefined;
|
||||||
|
|
||||||
if (!pdfPath) {
|
if (!pdfPath) {
|
||||||
throw new Error('pdfPath is required for embed-document job');
|
throw new Error('pdfPath is required for embed-document job');
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await this.embeddingService.embedDocument(
|
const result = await this.embeddingService.embedDocument(
|
||||||
pdfPath,
|
pdfPath,
|
||||||
documentPublicId,
|
documentPublicId,
|
||||||
projectPublicId,
|
projectPublicId,
|
||||||
extractedText
|
extractedText
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new Error(`Embedding failed: ${result.error ?? 'Unknown error'}`);
|
throw new Error(`Embedding failed: ${result.error ?? 'Unknown error'}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Embedding completed for document ${documentPublicId} — ${result.chunksEmbedded} chunks embedded`
|
`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(
|
private async setAiProcessingStatus(
|
||||||
documentPublicId: string,
|
documentPublicId: string,
|
||||||
status: 'PENDING' | 'PROCESSING' | 'DONE' | 'FAILED'
|
status: 'PENDING' | 'PROCESSING' | 'DONE' | 'FAILED'
|
||||||
@@ -110,4 +167,85 @@ export class AiBatchProcessor extends WorkerHost {
|
|||||||
{ aiProcessingStatus: status }
|
{ 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
|
// Change Log
|
||||||
// - 2026-05-14: เพิ่ม Qdrant gateway สำหรับ AI Module พร้อม project payload filter.
|
// - 2026-05-14: เพิ่ม Qdrant gateway สำหรับ AI Module พร้อม project payload filter.
|
||||||
// - 2026-05-14: เพิ่ม OnModuleInit เพื่อ auto-call ensureCollection() (💡 S2).
|
// - 2026-05-14: เพิ่ม OnModuleInit เพื่อ auto-call ensureCollection() (💡 S2).
|
||||||
|
// - 2026-05-21: เพิ่ม checkHealth สำหรับตรวจสอบสุขภาพและความเร็วของ Qdrant
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Injectable,
|
Injectable,
|
||||||
Logger,
|
Logger,
|
||||||
@@ -138,4 +140,37 @@ export class AiQdrantService implements OnModuleInit {
|
|||||||
points: pointsWithProject,
|
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
|
// File: src/modules/ai/services/ollama.service.ts
|
||||||
// Change Log
|
// Change Log
|
||||||
// - 2026-05-15: เพิ่ม Ollama service สำหรับ ADR-023A 2-model stack.
|
// - 2026-05-15: เพิ่ม Ollama service สำหรับ ADR-023A 2-model stack.
|
||||||
|
// - 2026-05-21: เพิ่ม checkHealth สำหรับตรวจสอบสุขภาพและความเร็ว (Latency) ของ Ollama
|
||||||
|
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
@@ -91,4 +92,37 @@ export class OllamaService {
|
|||||||
getEmbeddingModelName(): string {
|
getEmbeddingModelName(): string {
|
||||||
return this.embedModel;
|
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 ThemeProvider from '@/providers/theme-provider';
|
||||||
import { Toaster } from '@/components/ui/sonner';
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
import { headers } from 'next/headers';
|
import { headers } from 'next/headers';
|
||||||
|
import { AiStatusBannerHost } from '@/components/ai/ai-status-banner-host';
|
||||||
|
|
||||||
const inter = Inter({ subsets: ['latin'] });
|
const inter = Inter({ subsets: ['latin'] });
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ export default async function RootLayout({ children }: RootLayoutProps) {
|
|||||||
<SessionProvider>
|
<SessionProvider>
|
||||||
<ThemeProvider nonce={nonce}>
|
<ThemeProvider nonce={nonce}>
|
||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
|
<AiStatusBannerHost />
|
||||||
{children}
|
{children}
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import Link from 'next/link';
|
|||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
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 {
|
interface MenuItem {
|
||||||
href?: string;
|
href?: string;
|
||||||
@@ -62,6 +62,7 @@ export const menuItems: MenuItem[] = [
|
|||||||
{ href: '/admin/migration/errors', label: 'Error Logs' },
|
{ href: '/admin/migration/errors', label: 'Error Logs' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{ href: '/admin/ai', label: 'AI Console', icon: Brain },
|
||||||
{ href: '/admin/settings', label: 'Settings', icon: Settings },
|
{ href: '/admin/settings', label: 'Settings', icon: Settings },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// File: components/ai/AiStatusBanner.tsx
|
// File: components/ai/AiStatusBanner.tsx
|
||||||
// Change Log
|
// Change Log
|
||||||
// - 2026-05-14: เพิ่ม banner สำหรับ graceful degradation ของ AI staging.
|
// - 2026-05-14: เพิ่ม banner สำหรับ graceful degradation ของ AI staging.
|
||||||
|
// - 2026-05-21: รองรับ global banner เมื่อ Superadmin ปิด AI features.
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { AlertTriangle, CheckCircle2 } from 'lucide-react';
|
import { AlertTriangle, CheckCircle2 } from 'lucide-react';
|
||||||
@@ -8,19 +9,20 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
|||||||
import { useTranslations } from '@/hooks/use-translations';
|
import { useTranslations } from '@/hooks/use-translations';
|
||||||
|
|
||||||
interface AiStatusBannerProps {
|
interface AiStatusBannerProps {
|
||||||
isOffline: boolean;
|
isOffline?: boolean;
|
||||||
|
aiEnabled?: boolean;
|
||||||
queuePaused?: boolean;
|
queuePaused?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AiStatusBanner({ isOffline, queuePaused = false }: AiStatusBannerProps) {
|
export function AiStatusBanner({ isOffline = false, aiEnabled = true, queuePaused = false }: AiStatusBannerProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
if (isOffline) {
|
if (isOffline || !aiEnabled) {
|
||||||
return (
|
return (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertTriangle className="h-4 w-4" />
|
<AlertTriangle className="h-4 w-4" />
|
||||||
<AlertTitle>{t('ai.service_unavailable')}</AlertTitle>
|
<AlertTitle>{t('ai.status.offlineTitle')}</AlertTitle>
|
||||||
<AlertDescription>{t('ai.status.offlineDescription')}</AlertDescription>
|
<AlertDescription>{t('ai.status.disabledDescription')}</AlertDescription>
|
||||||
</Alert>
|
</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 { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { render, screen, waitFor } from '@testing-library/react';
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
import { CorrespondenceForm } from './form';
|
import { CorrespondenceForm } from './form';
|
||||||
|
import { createTestQueryClient } from '@/lib/test-utils';
|
||||||
import {
|
import {
|
||||||
useProjects,
|
useProjects,
|
||||||
useOrganizations,
|
useOrganizations,
|
||||||
@@ -94,6 +96,11 @@ const editInitialData = {
|
|||||||
correspondenceNumber: 'CORR-001',
|
correspondenceNumber: 'CORR-001',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderWithQueryClient = (ui: ReactElement) => {
|
||||||
|
const { wrapper } = createTestQueryClient();
|
||||||
|
return render(ui, { wrapper });
|
||||||
|
};
|
||||||
|
|
||||||
describe('CorrespondenceForm (edit regression)', () => {
|
describe('CorrespondenceForm (edit regression)', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -140,7 +147,7 @@ describe('CorrespondenceForm (edit regression)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('keeps edit prefilled values after mount (no reset on initial render)', async () => {
|
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');
|
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 () => {
|
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.getByLabelText('Subject *')).toHaveValue('Existing Subject');
|
||||||
expect(screen.getByText('Current Document Number')).toBeInTheDocument();
|
expect(screen.getByText('Current Document Number')).toBeInTheDocument();
|
||||||
|
|||||||
@@ -14,12 +14,20 @@ import { useRouter } from 'next/navigation';
|
|||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
import { useCreateCorrespondence, useUpdateCorrespondence } from '@/hooks/use-correspondence';
|
import { useCreateCorrespondence, useUpdateCorrespondence } from '@/hooks/use-correspondence';
|
||||||
import { Organization } from '@/types/organization';
|
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 { CreateCorrespondenceDto } from '@/types/dto/correspondence/create-correspondence.dto';
|
||||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||||
import { numberingApi } from '@/lib/api/numbering';
|
import { numberingApi } from '@/lib/api/numbering';
|
||||||
import { filesApi } from '@/lib/api/files';
|
import { filesApi } from '@/lib/api/files';
|
||||||
import { toast } from 'sonner';
|
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
|
// Updated Zod Schema with all required fields
|
||||||
const correspondenceSchema = z.object({
|
const correspondenceSchema = z.object({
|
||||||
@@ -155,6 +163,7 @@ export function CorrespondenceForm({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const createMutation = useCreateCorrespondence();
|
const createMutation = useCreateCorrespondence();
|
||||||
const updateMutation = useUpdateCorrespondence();
|
const updateMutation = useUpdateCorrespondence();
|
||||||
|
const { data: aiStatus, isLoading: isAiStatusLoading } = useAiStatus();
|
||||||
|
|
||||||
// Fetch master data for dropdowns
|
// Fetch master data for dropdowns
|
||||||
const { data: projectsData, isLoading: isLoadingProjects } = useProjects();
|
const { data: projectsData, isLoading: isLoadingProjects } = useProjects();
|
||||||
@@ -170,7 +179,8 @@ export function CorrespondenceForm({
|
|||||||
? initialData?.revisions?.find((r) => normalizeUuid(r.publicId) === normalizedSelectedRevisionId)
|
? initialData?.revisions?.find((r) => normalizeUuid(r.publicId) === normalizedSelectedRevisionId)
|
||||||
: undefined;
|
: undefined;
|
||||||
const defaultValues = useMemo<Partial<FormData>>(() => {
|
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 initialToRecipient = initialData?.recipients?.find((r) => r.recipientType === 'TO');
|
||||||
const initialCcRecipientIds =
|
const initialCcRecipientIds =
|
||||||
initialData?.recipients
|
initialData?.recipients
|
||||||
@@ -193,9 +203,15 @@ export function CorrespondenceForm({
|
|||||||
body: currentRevision?.body || '',
|
body: currentRevision?.body || '',
|
||||||
remarks: currentRevision?.remarks || '',
|
remarks: currentRevision?.remarks || '',
|
||||||
dueDate: currentRevision?.dueDate ? new Date(currentRevision.dueDate).toISOString().split('T')[0] : undefined,
|
dueDate: currentRevision?.dueDate ? new Date(currentRevision.dueDate).toISOString().split('T')[0] : undefined,
|
||||||
documentDate: currentRevision?.documentDate ? new Date(currentRevision.documentDate).toISOString().split('T')[0] : undefined,
|
documentDate: currentRevision?.documentDate
|
||||||
issuedDate: currentRevision?.issuedDate ? new Date(currentRevision.issuedDate).toISOString().split('T')[0] : undefined,
|
? new Date(currentRevision.documentDate).toISOString().split('T')[0]
|
||||||
receivedDate: currentRevision?.receivedDate ? new Date(currentRevision.receivedDate).toISOString().split('T')[0] : undefined,
|
: 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:
|
fromOrganizationId:
|
||||||
normalizePublicId(initialData?.originator?.publicId) ??
|
normalizePublicId(initialData?.originator?.publicId) ??
|
||||||
normalizePublicId((initialData as Record<string, unknown>)?.originatorId as string),
|
normalizePublicId((initialData as Record<string, unknown>)?.originatorId as string),
|
||||||
@@ -289,12 +305,15 @@ export function CorrespondenceForm({
|
|||||||
// Build recipients array with TO and CC
|
// Build recipients array with TO and CC
|
||||||
const recipients = [
|
const recipients = [
|
||||||
{ organizationId: data.toOrganizationId, type: 'TO' as const },
|
{ 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
|
// Phase 1: Upload attachments to temp storage
|
||||||
let attachmentTempIds: string[] | undefined;
|
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) {
|
if (validFiles.length > 0) {
|
||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
try {
|
try {
|
||||||
@@ -332,10 +351,7 @@ export function CorrespondenceForm({
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (uuid && initialData) {
|
if (uuid && initialData) {
|
||||||
updateMutation.mutate(
|
updateMutation.mutate({ uuid, data: payload }, { onSuccess: () => router.push(`/correspondences/${uuid}`) });
|
||||||
{ uuid, data: payload },
|
|
||||||
{ onSuccess: () => router.push(`/correspondences/${uuid}`) }
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
createMutation.mutate(payload, {
|
createMutation.mutate(payload, {
|
||||||
onSuccess: () => router.push('/correspondences'),
|
onSuccess: () => router.push('/correspondences'),
|
||||||
@@ -398,18 +414,10 @@ export function CorrespondenceForm({
|
|||||||
|
|
||||||
{/* Preview Section - Only for New Documents */}
|
{/* Preview Section - Only for New Documents */}
|
||||||
{preview && !uuid && (
|
{preview && !uuid && (
|
||||||
<div
|
<div className="p-4 rounded-md border bg-muted border-border">
|
||||||
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>
|
||||||
>
|
|
||||||
<p className="text-sm font-semibold mb-1 flex items-center gap-2">
|
|
||||||
Document Number Preview
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span
|
<span className="text-xl font-bold font-mono tracking-wide text-primary">{preview.number}</span>
|
||||||
className="text-xl font-bold font-mono tracking-wide text-primary"
|
|
||||||
>
|
|
||||||
{preview.number}
|
|
||||||
</span>
|
|
||||||
{preview.isDefaultTemplate && (
|
{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">
|
<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
|
Default Template
|
||||||
@@ -575,7 +583,7 @@ export function CorrespondenceForm({
|
|||||||
<Label>CC Organizations (Optional)</Label>
|
<Label>CC Organizations (Optional)</Label>
|
||||||
<div className="space-y-2 max-h-32 overflow-y-auto border rounded-md p-3">
|
<div className="space-y-2 max-h-32 overflow-y-auto border rounded-md p-3">
|
||||||
{organizationOptions
|
{organizationOptions
|
||||||
.filter(org => org.publicId !== toOrgId) // Exclude TO organization
|
.filter((org) => org.publicId !== toOrgId) // Exclude TO organization
|
||||||
.map((org) => (
|
.map((org) => (
|
||||||
<div key={org.publicId} className="flex items-center space-x-2">
|
<div key={org.publicId} className="flex items-center space-x-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -586,7 +594,10 @@ export function CorrespondenceForm({
|
|||||||
if (checked) {
|
if (checked) {
|
||||||
setValue('ccOrganizationIds', [...currentCC, org.publicId]);
|
setValue('ccOrganizationIds', [...currentCC, org.publicId]);
|
||||||
} else {
|
} 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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">Select organizations to receive a copy of this correspondence</p>
|
||||||
Select organizations to receive a copy of this correspondence
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Subject */}
|
{/* Subject */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
<Label htmlFor="subject">Subject *</Label>
|
<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" />
|
<Input id="subject" {...register('subject')} placeholder="Enter subject" />
|
||||||
{errors.subject && <p className="text-sm text-destructive">{errors.subject.message}</p>}
|
{errors.subject && <p className="text-sm text-destructive">{errors.subject.message}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ import { CreateRfaDto } from '@/types/dto/rfa/rfa.dto';
|
|||||||
import { useState, useEffect, type FormEvent } from 'react';
|
import { useState, useEffect, type FormEvent } from 'react';
|
||||||
import { correspondenceService } from '@/lib/services/correspondence.service';
|
import { correspondenceService } from '@/lib/services/correspondence.service';
|
||||||
import { Contract } from '@/types/contract';
|
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({
|
const rfaSchema = z.object({
|
||||||
projectId: z.string().min(1, 'Project is required'), // ADR-019: UUID
|
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() {
|
export function RFAForm() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const createMutation = useCreateRFA();
|
const createMutation = useCreateRFA();
|
||||||
|
const { data: aiStatus, isLoading: isAiStatusLoading } = useAiStatus();
|
||||||
|
|
||||||
const { data: projectsData, isLoading: isLoadingProjects } = useProjects();
|
const { data: projectsData, isLoading: isLoadingProjects } = useProjects();
|
||||||
const projects = dedupeByKey(extractArrayData<ProjectOption>(projectsData), (project) => project.publicId);
|
const projects = dedupeByKey(extractArrayData<ProjectOption>(projectsData), (project) => project.publicId);
|
||||||
@@ -192,12 +196,13 @@ export function RFAForm() {
|
|||||||
|
|
||||||
const selectedContractId = watch('contractId');
|
const selectedContractId = watch('contractId');
|
||||||
const { data: disciplinesData, isLoading: isLoadingDisciplines } = useDisciplines(selectedContractId);
|
const { data: disciplinesData, isLoading: isLoadingDisciplines } = useDisciplines(selectedContractId);
|
||||||
const disciplines = dedupeByKey(
|
const disciplines = dedupeByKey(extractArrayData<DisciplineOption>(disciplinesData), (discipline) =>
|
||||||
extractArrayData<DisciplineOption>(disciplinesData),
|
getMasterOptionValue(discipline)
|
||||||
(discipline) => getMasterOptionValue(discipline)
|
|
||||||
);
|
);
|
||||||
const { data: rfaTypesData, isLoading: isLoadingRfaTypes } = useRfaTypes(selectedContractId);
|
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 [shopDrawingSearch, setShopDrawingSearch] = useState('');
|
||||||
const [shopDrawingPage, setShopDrawingPage] = useState(1);
|
const [shopDrawingPage, setShopDrawingPage] = useState(1);
|
||||||
const { data: shopDrawingsData, isLoading: isLoadingShopDrawings } = useDrawings('SHOP', {
|
const { data: shopDrawingsData, isLoading: isLoadingShopDrawings } = useDrawings('SHOP', {
|
||||||
@@ -286,7 +291,15 @@ export function RFAForm() {
|
|||||||
|
|
||||||
const timer = setTimeout(fetchPreview, 500);
|
const timer = setTimeout(fetchPreview, 500);
|
||||||
return () => clearTimeout(timer);
|
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) => {
|
const onSubmit = (data: RFAFormData) => {
|
||||||
if (requiresShopDrawings && data.shopDrawingRevisionIds?.length === 0) {
|
if (requiresShopDrawings && data.shopDrawingRevisionIds?.length === 0) {
|
||||||
@@ -429,7 +442,7 @@ export function RFAForm() {
|
|||||||
<SelectValue placeholder={isLoadingDisciplines ? 'Loading...' : 'Select Discipline'} />
|
<SelectValue placeholder={isLoadingDisciplines ? 'Loading...' : 'Select Discipline'} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{disciplines.map((d) => (
|
{disciplines.map((d) =>
|
||||||
(() => {
|
(() => {
|
||||||
const disciplineValue = getMasterOptionValue(d);
|
const disciplineValue = getMasterOptionValue(d);
|
||||||
|
|
||||||
@@ -443,7 +456,7 @@ export function RFAForm() {
|
|||||||
</SelectItem>
|
</SelectItem>
|
||||||
);
|
);
|
||||||
})()
|
})()
|
||||||
))}
|
)}
|
||||||
{!isLoadingDisciplines && disciplines.length === 0 && (
|
{!isLoadingDisciplines && disciplines.length === 0 && (
|
||||||
<SelectItem value="0" disabled>
|
<SelectItem value="0" disabled>
|
||||||
No disciplines found
|
No disciplines found
|
||||||
@@ -521,7 +534,14 @@ export function RFAForm() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
<Label htmlFor="subject">Subject *</Label>
|
<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" />
|
<Input id="subject" {...register('subject')} placeholder="Enter subject" />
|
||||||
{errors.subject && <p className="text-sm text-destructive mt-1">{errors.subject.message}</p>}
|
{errors.subject && <p className="text-sm text-destructive mt-1">{errors.subject.message}</p>}
|
||||||
</div>
|
</div>
|
||||||
@@ -540,8 +560,6 @@ export function RFAForm() {
|
|||||||
<Label htmlFor="description">Description</Label>
|
<Label htmlFor="description">Description</Label>
|
||||||
<Input id="description" {...register('description')} placeholder="Enter key description" />
|
<Input id="description" {...register('description')} placeholder="Enter key description" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</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;
|
error: ApiErrorPayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const AI_FEATURES_UNAVAILABLE_EVENT = 'ai-features-unavailable';
|
||||||
|
|
||||||
// แปลง Axios error เป็น Structured Error Response (ADR-007)
|
// แปลง Axios error เป็น Structured Error Response (ADR-007)
|
||||||
export function parseApiError(axiosError: AxiosError): ApiErrorResponse {
|
export function parseApiError(axiosError: AxiosError): ApiErrorResponse {
|
||||||
if (axiosError.response?.data) {
|
if (axiosError.response?.data) {
|
||||||
const data = axiosError.response.data;
|
const data = axiosError.response.data;
|
||||||
// กรณีที่ backend ส่ง { error: { ... } } ตาม ADR-007
|
// กรณีที่ backend ส่ง { error: { ... } } ตาม ADR-007
|
||||||
if (typeof data === 'object' && data !== null && 'error' in data) {
|
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 }
|
// กรณี NestJS validation error { message: [...], statusCode: 400 }
|
||||||
if (typeof data === 'object' && data !== null && 'message' in data) {
|
if (typeof data === 'object' && data !== null && 'message' in data) {
|
||||||
@@ -181,6 +189,17 @@ apiClient.interceptors.response.use(
|
|||||||
}
|
}
|
||||||
// แปลง error เป็น structured format ตาม ADR-007 ก่อน reject
|
// แปลง error เป็น structured format ตาม ADR-007 ก่อน reject
|
||||||
const structuredError = parseApiError(error);
|
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);
|
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.offlineTitle": "AI unavailable",
|
||||||
"ai.status.offlineDescription": "AI staging is temporarily unavailable. Manual document operations remain available.",
|
"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.onlineTitle": "AI staging available",
|
||||||
"ai.status.onlineDescription": "Legacy migration review queue is connected.",
|
"ai.status.onlineDescription": "Legacy migration review queue is connected.",
|
||||||
"ai.staging.title": "AI Staging Queue",
|
"ai.staging.title": "AI Staging Queue",
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
|
|
||||||
"ai.status.offlineTitle": "ระบบ AI ไม่พร้อมใช้งาน",
|
"ai.status.offlineTitle": "ระบบ AI ไม่พร้อมใช้งาน",
|
||||||
"ai.status.offlineDescription": "ไม่สามารถเชื่อมต่อ staging queue ของ AI ได้ชั่วคราว แต่ยังทำงานเอกสารแบบ manual ได้ตามปกติ",
|
"ai.status.offlineDescription": "ไม่สามารถเชื่อมต่อ staging queue ของ AI ได้ชั่วคราว แต่ยังทำงานเอกสารแบบ manual ได้ตามปกติ",
|
||||||
|
"ai.status.disabledDescription": "ระบบ AI ไม่พร้อมใช้งานชั่วคราว กรุณากรอกข้อมูลด้วยตนเอง",
|
||||||
"ai.status.onlineTitle": "ระบบ AI พร้อมใช้งาน",
|
"ai.status.onlineTitle": "ระบบ AI พร้อมใช้งาน",
|
||||||
"ai.status.onlineDescription": "เชื่อมต่อคิวตรวจสอบข้อมูลเอกสารเก่าเรียบร้อยแล้ว",
|
"ai.status.onlineDescription": "เชื่อมต่อคิวตรวจสอบข้อมูลเอกสารเก่าเรียบร้อยแล้ว",
|
||||||
"ai.staging.title": "คิวตรวจสอบ AI",
|
"ai.staging.title": "คิวตรวจสอบ AI",
|
||||||
|
|||||||
@@ -110,6 +110,43 @@ CREATE TABLE refresh_tokens (
|
|||||||
FOREIGN KEY (user_id) REFERENCES users (user_id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users (user_id) ON DELETE CASCADE
|
||||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตารางเก็บ Refresh Tokens สำหรับ Authentication';
|
) 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 เก็บ "บทบาท" ของผู้ใช้ในระบบ
|
-- ตาราง Master เก็บ "บทบาท" ของผู้ใช้ในระบบ
|
||||||
CREATE TABLE roles (
|
CREATE TABLE roles (
|
||||||
role_id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง',
|
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