926 lines
33 KiB
TypeScript
926 lines
33 KiB
TypeScript
// File: src/modules/ai/ai.controller.ts
|
|
// Change Log
|
|
// - 2026-05-14: เพิ่ม Legacy Migration staging endpoints ตาม ADR-023.
|
|
// - 2026-05-14: ย้าย DeleteAuditLogsQueryDto ไป dto/ folder; ลบ authHeader passthrough (🟢 LOW-1/LOW-2).
|
|
// - 2026-05-19: เพิ่ม POST /ai/intent endpoint สำหรับ AI Tool Layer (ADR-025).
|
|
// - 2026-05-21: เพิ่ม AI Admin settings endpoints และ AiEnabledGuard สำหรับ ADR-027.
|
|
// - 2026-05-21: เพิ่ม GET /ai/admin/health สำหรับดึงสถานะสุขภาพ AI Infrastructure (T028).
|
|
// - 2026-05-21: เพิ่ม POST /ai/admin/sandbox/extract endpoint สำหรับ Superadmin OCR sandbox (T041 & T042)
|
|
// - 2026-05-21: แก้ไขข้อห้ามใช้ parseInt โดยการใช้ Number แทนตามกฎ Tier 1
|
|
// - 2026-05-23: เพิ่ม Migration Checkpoint API endpoints แทน MySQL direct access (ADR-023A)
|
|
// - 2026-05-30: เพิ่ม @UseInterceptors(FileInterceptor('file')) ใน submitSandboxOcr เพื่อแก้ไขปัญหา BadRequestException (File is required)
|
|
// Controller สำหรับ AI Gateway Endpoints (ADR-023)
|
|
|
|
import {
|
|
Controller,
|
|
Post,
|
|
Get,
|
|
Patch,
|
|
Delete,
|
|
Body,
|
|
Param,
|
|
Query,
|
|
Headers,
|
|
HttpCode,
|
|
HttpStatus,
|
|
UseGuards,
|
|
UseInterceptors,
|
|
UploadedFiles,
|
|
UploadedFile,
|
|
HttpException,
|
|
ParseFilePipe,
|
|
MaxFileSizeValidator,
|
|
FileTypeValidator,
|
|
} from '@nestjs/common';
|
|
import { FilesInterceptor, FileInterceptor } from '@nestjs/platform-express';
|
|
import { Throttle } from '@nestjs/throttler';
|
|
import {
|
|
ApiTags,
|
|
ApiOperation,
|
|
ApiBearerAuth,
|
|
ApiHeader,
|
|
ApiParam,
|
|
ApiQuery,
|
|
ApiConsumes,
|
|
ApiBody,
|
|
} from '@nestjs/swagger';
|
|
import { AiService, ExtractionResult, PaginatedResult } from './ai.service';
|
|
import { AiSettingsService } from './ai-settings.service';
|
|
import {
|
|
AiIngestService,
|
|
MigrationReviewResponse,
|
|
PaginatedMigrationReviewResponse,
|
|
} from './ai-ingest.service';
|
|
import { AiRagService } from './ai-rag.service';
|
|
import { AiQueueService } from './ai-queue.service';
|
|
import { AiRagQueryDto } from './dto/ai-rag-query.dto';
|
|
import { ExtractDocumentDto } from './dto/extract-document.dto';
|
|
import { AiCallbackDto } from './dto/ai-callback.dto';
|
|
import { CreateAiJobDto } from './dto/create-ai-job.dto';
|
|
import { SubmitAiJobDto } from './dto/submit-ai-job.dto';
|
|
import { MigrationUpdateDto } from './dto/migration-update.dto';
|
|
import { MigrationQueryDto } from './dto/migration-query.dto';
|
|
import { ValidationException } from '../../common/exceptions';
|
|
import {
|
|
ApproveLegacyMigrationDto,
|
|
LegacyMigrationIngestDto,
|
|
LegacyMigrationQueueQueryDto,
|
|
} from './dto/legacy-migration.dto';
|
|
import { MigrationLog } from './entities/migration-log.entity';
|
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
|
import { RbacGuard } from '../../common/guards/rbac.guard';
|
|
import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe';
|
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
|
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
|
import { User } from '../user/entities/user.entity';
|
|
import { ServiceAccountGuard } from './guards/service-account.guard';
|
|
import { v7 as uuidv7 } from 'uuid';
|
|
import { DeleteAuditLogsQueryDto } from './dto/delete-audit-logs.dto';
|
|
import { AiToolRegistryService } from './tool/ai-tool-registry.service';
|
|
import { AiIntentRequestDto } from './dto/ai-intent-request.dto';
|
|
import { ToggleAiFeaturesDto } from './dto/ai-admin-settings.dto';
|
|
import { AiEnabledGuard } from './guards/ai-enabled.guard';
|
|
import { InjectRedis } from '@nestjs-modules/ioredis';
|
|
import Redis from 'ioredis';
|
|
import { FileStorageService } from '../../common/file-storage/file-storage.service';
|
|
import { AiMigrationCheckpointService } from './ai-migration-checkpoint.service';
|
|
import {
|
|
MigrationErrorLogDto,
|
|
MigrationQueueRecordDto,
|
|
SaveCheckpointDto,
|
|
} from './dto/migration-checkpoint.dto';
|
|
|
|
@ApiTags('AI Gateway')
|
|
@Controller('ai')
|
|
export class AiController {
|
|
constructor(
|
|
private readonly aiService: AiService,
|
|
private readonly aiIngestService: AiIngestService,
|
|
private readonly aiRagService: AiRagService,
|
|
private readonly aiQueueService: AiQueueService,
|
|
private readonly aiSettingsService: AiSettingsService,
|
|
private readonly aiToolRegistryService: AiToolRegistryService,
|
|
private readonly fileStorageService: FileStorageService,
|
|
private readonly migrationCheckpointService: AiMigrationCheckpointService,
|
|
@InjectRedis() private readonly redis: Redis
|
|
) {}
|
|
|
|
// --- Real-time Extraction (User Upload) ---
|
|
|
|
// ─── AI Tool Layer Endpoint (ADR-025) ──────────────────────────────────────
|
|
|
|
@Post('intent')
|
|
@UseGuards(JwtAuthGuard, AiEnabledGuard, RbacGuard)
|
|
@ApiBearerAuth()
|
|
@RequirePermission('ai.suggest')
|
|
@HttpCode(HttpStatus.OK)
|
|
@ApiOperation({
|
|
summary: 'AI Intent Dispatch — ส่ง Intent ไปยัง Tool Registry (ADR-025)',
|
|
description:
|
|
'รับ intent code + projectPublicId แล้ว dispatch ไปยัง Tool Handler ที่ตรงกัน พร้อม CASL enforcement',
|
|
})
|
|
async dispatchIntent(
|
|
@Body() dto: AiIntentRequestDto,
|
|
@CurrentUser() user: User
|
|
): Promise<{
|
|
ok: boolean;
|
|
data?: unknown;
|
|
reason?: string;
|
|
message?: string;
|
|
}> {
|
|
const result = await this.aiToolRegistryService.dispatch(dto.intent, {
|
|
requestUser: user,
|
|
projectPublicId: dto.projectPublicId,
|
|
params: dto.params,
|
|
});
|
|
if (result.ok) {
|
|
return { ok: true, data: result.data };
|
|
}
|
|
return { ok: false, reason: result.reason, message: result.message };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
@Post('suggest')
|
|
@UseGuards(JwtAuthGuard, AiEnabledGuard, RbacGuard)
|
|
@ApiBearerAuth()
|
|
@RequirePermission('ai.suggest')
|
|
@HttpCode(HttpStatus.ACCEPTED)
|
|
@ApiOperation({
|
|
summary: 'AI Suggest — enqueue metadata suggestion job',
|
|
description:
|
|
'รับ documentPublicId/projectPublicId แล้วส่งงานเข้า ai-realtime queue เพื่อให้ frontend polling สถานะ',
|
|
})
|
|
@ApiHeader({
|
|
name: 'Idempotency-Key',
|
|
description: 'Unique key เพื่อป้องกัน duplicate AI Suggest job',
|
|
required: true,
|
|
})
|
|
async suggestDocumentMetadata(
|
|
@Body() dto: CreateAiJobDto,
|
|
@Headers('idempotency-key') idempotencyKey: string
|
|
): Promise<{ success: boolean; jobId?: string; status: string }> {
|
|
const result = await this.aiService.queueSuggestJob({
|
|
...dto,
|
|
jobType: 'ai-suggest',
|
|
idempotencyKey: idempotencyKey || dto.idempotencyKey,
|
|
});
|
|
return {
|
|
success: result.success,
|
|
jobId: result.jobId,
|
|
status: result.success ? 'queued' : 'failed',
|
|
};
|
|
}
|
|
|
|
@Get('jobs/:jobId/status')
|
|
@UseGuards(JwtAuthGuard, RbacGuard)
|
|
@ApiBearerAuth()
|
|
@RequirePermission('ai.suggest')
|
|
@ApiOperation({
|
|
summary: 'AI Job Status — polling endpoint สำหรับ AI Suggest',
|
|
})
|
|
@ApiParam({ name: 'jobId', description: 'BullMQ job id' })
|
|
async getAiJobStatus(@Param('jobId') jobId: string) {
|
|
return this.aiService.getAiJobStatus(jobId);
|
|
}
|
|
|
|
@Post('jobs')
|
|
@UseGuards(JwtAuthGuard, AiEnabledGuard, RbacGuard)
|
|
@ApiBearerAuth()
|
|
@RequirePermission('ai.suggest')
|
|
@HttpCode(HttpStatus.ACCEPTED)
|
|
@ApiOperation({
|
|
summary: 'Submit AI migration job — ส่งงานย้ายเอกสารให้ AI ประมวลผล',
|
|
description:
|
|
'รับ tempAttachmentId/documentNumber แล้วส่งงานย้ายเอกสารเข้า BullMQ เพื่อรอการประมวลผล',
|
|
})
|
|
@ApiHeader({
|
|
name: 'Idempotency-Key',
|
|
description: 'Unique key เพื่อป้องกัน duplicate AI job',
|
|
required: true,
|
|
})
|
|
async submitMigrationJob(
|
|
@Body() dto: SubmitAiJobDto,
|
|
@Headers('idempotency-key') idempotencyKey: string
|
|
) {
|
|
if (!idempotencyKey) {
|
|
throw new ValidationException('Idempotency-Key header is required');
|
|
}
|
|
return this.aiService.submitMigrationJob(dto, idempotencyKey);
|
|
}
|
|
|
|
@Get('jobs/:jobId')
|
|
@UseGuards(JwtAuthGuard, RbacGuard)
|
|
@ApiBearerAuth()
|
|
@RequirePermission('ai.suggest')
|
|
@ApiOperation({
|
|
summary: 'AI Job Status polling by jobId',
|
|
})
|
|
@ApiParam({ name: 'jobId', description: 'BullMQ job id' })
|
|
async getAiJobStatusById(@Param('jobId') jobId: string) {
|
|
return this.aiService.getAiJobStatus(jobId);
|
|
}
|
|
|
|
@Post('extract')
|
|
@UseGuards(JwtAuthGuard, AiEnabledGuard, RbacGuard)
|
|
@ApiBearerAuth()
|
|
@RequirePermission('ai.extract')
|
|
@Throttle({ default: { limit: 5, ttl: 60000 } }) // Rate limit: 5 requests/minute (ADR-020)
|
|
@ApiOperation({
|
|
summary:
|
|
'Real-time AI Extraction — สกัด Metadata จากเอกสารที่ผู้ใช้อัปโหลด',
|
|
description:
|
|
'ส่งเอกสารไปยัง AI Pipeline ผ่าน n8n และรอผลลัพธ์ (timeout 30s)',
|
|
})
|
|
async extractDocument(
|
|
@Body() dto: ExtractDocumentDto,
|
|
@CurrentUser() user: User
|
|
): Promise<ExtractionResult> {
|
|
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 };
|
|
}
|
|
|
|
// ─── AI Model Management (ADR-027) ─────────────────────────────────────────
|
|
|
|
@Get('admin/models')
|
|
@UseGuards(JwtAuthGuard, RbacGuard)
|
|
@ApiBearerAuth()
|
|
@RequirePermission('system.manage_all')
|
|
@ApiOperation({
|
|
summary: 'AI Models — ดึงรายการโมเดล AI ทั้งหมดที่ใช้งานได้',
|
|
})
|
|
async getAvailableModels() {
|
|
const models = await this.aiSettingsService.getAvailableModels();
|
|
const activeModel = await this.aiSettingsService.getActiveModel();
|
|
return { models, activeModel };
|
|
}
|
|
|
|
@Get('admin/models/active')
|
|
@UseGuards(JwtAuthGuard, RbacGuard)
|
|
@ApiBearerAuth()
|
|
@RequirePermission('system.manage_all')
|
|
@ApiOperation({
|
|
summary: 'AI Active Model — ดึงโมเดล AI ที่ใช้งานอยู่ปัจจุบัน',
|
|
})
|
|
async getActiveModel() {
|
|
const activeModel = await this.aiSettingsService.getActiveModel();
|
|
return { activeModel };
|
|
}
|
|
|
|
@Post('admin/models/active')
|
|
@UseGuards(JwtAuthGuard, RbacGuard)
|
|
@ApiBearerAuth()
|
|
@RequirePermission('system.manage_all')
|
|
@HttpCode(HttpStatus.OK)
|
|
@ApiOperation({
|
|
summary: 'AI Set Active Model — ตั้งค่าโมเดล AI ที่ใช้งาน (global)',
|
|
})
|
|
async setActiveModel(
|
|
@Body() dto: { modelName: string },
|
|
@CurrentUser() user: User
|
|
) {
|
|
const activeModel = await this.aiSettingsService.setActiveModel(
|
|
dto.modelName,
|
|
user.user_id
|
|
);
|
|
return { activeModel };
|
|
}
|
|
|
|
@Post('admin/models')
|
|
@UseGuards(JwtAuthGuard, RbacGuard)
|
|
@ApiBearerAuth()
|
|
@RequirePermission('system.manage_all')
|
|
@HttpCode(HttpStatus.CREATED)
|
|
@ApiOperation({
|
|
summary: 'AI Add Model — เพิ่มโมเดล AI ใหม่เข้าระบบ (Superadmin only)',
|
|
})
|
|
async addModel(
|
|
@Body()
|
|
dto: {
|
|
modelName: string;
|
|
modelVersion: string;
|
|
description?: string;
|
|
vramGb?: number;
|
|
},
|
|
@CurrentUser() user: User
|
|
) {
|
|
const model = await this.aiSettingsService.addModel(dto, user.user_id);
|
|
return { model };
|
|
}
|
|
|
|
@Patch('admin/models/:modelName/toggle')
|
|
@UseGuards(JwtAuthGuard, RbacGuard)
|
|
@ApiBearerAuth()
|
|
@RequirePermission('system.manage_all')
|
|
@HttpCode(HttpStatus.OK)
|
|
@ApiOperation({
|
|
summary: 'AI Toggle Model — เปลี่ยนสถานะ active/inactive ของโมเดล',
|
|
})
|
|
@ApiParam({
|
|
name: 'modelName',
|
|
description: 'ชื่อโมเดล เช่น gemma4:e4b',
|
|
})
|
|
async toggleModelActive(
|
|
@Param('modelName') modelName: string,
|
|
@CurrentUser() user: User
|
|
) {
|
|
const model = await this.aiSettingsService.toggleModelActive(
|
|
modelName,
|
|
user.user_id
|
|
);
|
|
return { model };
|
|
}
|
|
|
|
@Delete('admin/models/:modelName')
|
|
@UseGuards(JwtAuthGuard, RbacGuard)
|
|
@ApiBearerAuth()
|
|
@RequirePermission('system.manage_all')
|
|
@HttpCode(HttpStatus.NO_CONTENT)
|
|
@ApiOperation({
|
|
summary: 'AI Remove Model — ลบโมเดล AI (soft delete)',
|
|
})
|
|
@ApiParam({
|
|
name: 'modelName',
|
|
description: 'ชื่อโมเดลที่ต้องการลบ',
|
|
})
|
|
async removeModel(
|
|
@Param('modelName') modelName: string,
|
|
@CurrentUser() user: User
|
|
): Promise<void> {
|
|
await this.aiSettingsService.removeModel(modelName, user.user_id);
|
|
}
|
|
|
|
@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' };
|
|
}
|
|
|
|
// --- Step 1: OCR Only (สำหรับตรวจคุณภาพ OCR ก่อนทดสอบ AI) ---
|
|
|
|
@Post('admin/sandbox/ocr')
|
|
@UseGuards(JwtAuthGuard, RbacGuard)
|
|
@RequirePermission('system.manage_all')
|
|
@UseInterceptors(FileInterceptor('file'))
|
|
@ApiOperation({
|
|
summary: 'Step 1: Run OCR Only — สำหรับตรวจคุณภาพ OCR ก่อนทดสอบ AI',
|
|
description:
|
|
'Upload PDF และรัน OCR เท่านั้น ไม่เรียก LLM — ผลลัพธ์ cache ไว้สำหรับ Step 2',
|
|
})
|
|
@ApiConsumes('multipart/form-data')
|
|
@ApiBody({
|
|
schema: {
|
|
type: 'object',
|
|
properties: {
|
|
file: {
|
|
type: 'string',
|
|
format: 'binary',
|
|
},
|
|
},
|
|
},
|
|
})
|
|
async submitSandboxOcr(
|
|
@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 attachment = await this.fileStorageService.upload(file, user.user_id);
|
|
const requestPublicId = uuidv7();
|
|
const jobId = await this.aiQueueService.enqueueSandboxJob(
|
|
'sandbox-ocr-only',
|
|
{
|
|
idempotencyKey: requestPublicId,
|
|
pdfPath: attachment.filePath,
|
|
}
|
|
);
|
|
return { requestPublicId, jobId, status: 'queued' };
|
|
}
|
|
|
|
// --- Step 2: AI Extraction (ใช้ OCR text ที่ cache จาก Step 1) ---
|
|
|
|
@Post('admin/sandbox/ai-extract')
|
|
@UseGuards(JwtAuthGuard, RbacGuard)
|
|
@RequirePermission('system.manage_all')
|
|
@ApiOperation({
|
|
summary: 'Step 2: Run AI Extraction — ใช้ OCR text ที่ cache จาก Step 1',
|
|
description:
|
|
'รับ requestPublicId จาก Step 1 และ optional promptVersion แล้ว run LLM extraction',
|
|
})
|
|
async submitSandboxAiExtract(
|
|
@Body() dto: { requestPublicId: string; promptVersion?: number }
|
|
): Promise<{ requestPublicId: string; jobId: string; status: string }> {
|
|
const { requestPublicId, promptVersion } = dto;
|
|
const jobId = await this.aiQueueService.enqueueSandboxJob(
|
|
'sandbox-ai-extract',
|
|
{
|
|
idempotencyKey: requestPublicId,
|
|
projectPublicId: 'default', // Sandbox ใช้ default project
|
|
extraPayload: { promptVersion },
|
|
}
|
|
);
|
|
return { requestPublicId, jobId, status: 'queued' };
|
|
}
|
|
|
|
// --- Webhook Callback จาก n8n (Service Account) ---
|
|
|
|
@Post('callback')
|
|
@UseGuards(ServiceAccountGuard) // T029: กำหนด guard ที่ controller layer (ADR-016)
|
|
@ApiOperation({
|
|
summary: 'AI Callback Endpoint — รับผลลัพธ์จาก n8n หลัง AI ประมวลผลเสร็จ',
|
|
description:
|
|
'เรียกโดย n8n Service Account เท่านั้น ต้องใส่ Bearer Token ใน Authorization header',
|
|
})
|
|
@ApiHeader({
|
|
name: 'Authorization',
|
|
description:
|
|
'Bearer {AI_N8N_SERVICE_TOKEN} — Service Account Token จาก n8n',
|
|
required: true,
|
|
})
|
|
@ApiHeader({
|
|
name: 'X-AI-Source',
|
|
description: 'ระบุแหล่งที่มา เช่น ollama, n8n',
|
|
required: false,
|
|
})
|
|
async handleCallback(
|
|
@Body() dto: AiCallbackDto,
|
|
@Headers('x-ai-source') aiSource: string
|
|
): Promise<{ message: string }> {
|
|
await this.aiService.handleWebhookCallback(dto, aiSource ?? 'unknown');
|
|
return { message: 'Callback processed successfully' };
|
|
}
|
|
|
|
// --- Admin: ดูรายการ MigrationLog ---
|
|
|
|
@Get('migration')
|
|
@UseGuards(JwtAuthGuard, RbacGuard)
|
|
@ApiBearerAuth()
|
|
@RequirePermission('migration.read')
|
|
@ApiOperation({
|
|
summary: 'Admin: ดูรายการ MigrationLog ทั้งหมด',
|
|
description: 'กรองตามสถานะและ Confidence Score พร้อม Pagination',
|
|
})
|
|
@ApiQuery({ name: 'status', required: false, description: 'กรองตามสถานะ' })
|
|
@ApiQuery({
|
|
name: 'minConfidence',
|
|
required: false,
|
|
type: Number,
|
|
description: 'Confidence Score ขั้นต่ำ',
|
|
})
|
|
@ApiQuery({
|
|
name: 'page',
|
|
required: false,
|
|
type: Number,
|
|
description: 'หน้าที่ต้องการ',
|
|
})
|
|
@ApiQuery({
|
|
name: 'limit',
|
|
required: false,
|
|
type: Number,
|
|
description: 'จำนวนรายการต่อหน้า',
|
|
})
|
|
async getMigrationList(
|
|
@Query() query: MigrationQueryDto
|
|
): Promise<PaginatedResult<MigrationLog>> {
|
|
return this.aiService.getMigrationList(query);
|
|
}
|
|
|
|
// --- Admin: อัปเดตสถานะ MigrationLog ---
|
|
|
|
@Patch('migration/:publicId')
|
|
@UseGuards(JwtAuthGuard, RbacGuard)
|
|
@ApiBearerAuth()
|
|
@RequirePermission('migration.approve')
|
|
@ApiOperation({
|
|
summary: 'Admin: อัปเดตสถานะ MigrationLog หลังตรวจสอบ',
|
|
description:
|
|
'Admin ยืนยัน (VERIFIED) หรือปฏิเสธ (FAILED) รายการ — ใช้ publicId (UUID)',
|
|
})
|
|
@ApiParam({
|
|
name: 'publicId',
|
|
description: 'UUID ของ MigrationLog (ADR-019)',
|
|
})
|
|
@ApiHeader({
|
|
name: 'Idempotency-Key',
|
|
description: 'Unique key เพื่อป้องกัน Duplicate Update',
|
|
required: true,
|
|
})
|
|
async updateMigration(
|
|
@Param('publicId') publicId: string,
|
|
@Body() dto: MigrationUpdateDto,
|
|
@CurrentUser() user: User
|
|
): Promise<MigrationLog> {
|
|
return this.aiService.updateMigrationLog(publicId, dto, user.user_id);
|
|
}
|
|
|
|
// ─── AI Audit Log Endpoints (Phase 5 — T026) ──────────────────────────────
|
|
|
|
@Delete('audit-logs')
|
|
@UseGuards(JwtAuthGuard, RbacGuard)
|
|
@ApiBearerAuth()
|
|
@RequirePermission('system.manage_all')
|
|
@HttpCode(HttpStatus.OK)
|
|
@ApiOperation({
|
|
summary:
|
|
'AI Audit Log Hard Delete — ลบ log ถาวร (SYSTEM_ADMIN เท่านั้น) (T026)',
|
|
description:
|
|
'ต้องระบุ documentPublicId หรือ olderThanDays อย่างน้อยหนึ่งอย่าง',
|
|
})
|
|
async deleteAuditLogs(
|
|
@Query() query: DeleteAuditLogsQueryDto
|
|
): Promise<{ deleted: number }> {
|
|
return this.aiService.deleteAuditLogs({
|
|
documentPublicId: query.documentPublicId,
|
|
olderThanDays: query.olderThanDays,
|
|
});
|
|
}
|
|
|
|
// ─── Phase 6: AI Analytics & Single Audit Log Delete (T036, T037) ────────
|
|
|
|
@Get('analytics/summary')
|
|
@UseGuards(JwtAuthGuard, RbacGuard)
|
|
@ApiBearerAuth()
|
|
@RequirePermission('ai.read_analytics')
|
|
@ApiOperation({
|
|
summary: 'AI Analytics Summary — สรุปสถิติ AI Audit Logs (T036)',
|
|
description:
|
|
'คำนวณ avgConfidence, overrideRate, rejectedRate แยกตาม document type และ overall',
|
|
})
|
|
async getAnalyticsSummary() {
|
|
return this.aiService.getAnalyticsSummary();
|
|
}
|
|
|
|
@Delete('audit-logs/:publicId')
|
|
@UseGuards(JwtAuthGuard, RbacGuard)
|
|
@ApiBearerAuth()
|
|
@RequirePermission('ai.delete_audit')
|
|
@HttpCode(HttpStatus.OK)
|
|
@ApiOperation({
|
|
summary:
|
|
'AI Audit Log Single Delete — ลบ log เดี่ยวโดย publicId (SYSTEM_ADMIN เท่านั้น) (T037)',
|
|
description:
|
|
'ลบ AiAuditLog เดี่ยวและบันทึกใน audit_logs (action: AI_AUDIT_LOG_DELETED)',
|
|
})
|
|
@ApiParam({
|
|
name: 'publicId',
|
|
description: 'UUID ของ AiAuditLog (ADR-019)',
|
|
})
|
|
async deleteAuditLogByPublicId(
|
|
@Param('publicId', ParseUuidPipe) publicId: string,
|
|
@CurrentUser() user: User
|
|
): Promise<{ deleted: boolean; publicId: string }> {
|
|
return this.aiService.deleteAuditLogByPublicId(publicId, user.user_id);
|
|
}
|
|
|
|
// ─── RAG Query Endpoints (Phase 4 — FR-009, FR-010, FR-011) ────────────────
|
|
|
|
@Post('rag/query')
|
|
@UseGuards(JwtAuthGuard, AiEnabledGuard, RbacGuard)
|
|
@ApiBearerAuth()
|
|
@Throttle({ default: { limit: 5, ttl: 60000 } }) // Rate limit: 5 requests/minute per user (FR-010)
|
|
@RequirePermission('rag.query')
|
|
@HttpCode(HttpStatus.ACCEPTED)
|
|
@ApiOperation({
|
|
summary:
|
|
'RAG Query — ส่ง query เข้า BullMQ เพื่อประมวลผลแบบ async (FR-009, FR-010)',
|
|
description:
|
|
'ส่งคำถาม RAG เข้าคิว BullMQ (concurrency=1 บน Desk-5439) แล้วคืน requestPublicId สำหรับ polling',
|
|
})
|
|
@ApiHeader({
|
|
name: 'Idempotency-Key',
|
|
description: 'Unique key สำหรับ request',
|
|
required: true,
|
|
})
|
|
async submitRagQuery(
|
|
@Body() dto: AiRagQueryDto,
|
|
@CurrentUser() user: User,
|
|
@Headers('idempotency-key') _idempotencyKey: string
|
|
): Promise<{ requestPublicId: string; jobId: string; status: string }> {
|
|
// ตรวจสอบว่า user มี active job อยู่แล้วหรือไม่ (FR-009: 1 active job per user)
|
|
const activeJob = await this.aiRagService.getActiveJob(
|
|
String(user.publicId ?? user.user_id)
|
|
);
|
|
if (activeJob) {
|
|
return { requestPublicId: activeJob, jobId: '', status: 'queued' };
|
|
}
|
|
|
|
// สร้าง requestPublicId ใหม่ (ADR-019: UUID)
|
|
const requestPublicId = uuidv7();
|
|
const userPublicId = String(user.publicId ?? user.user_id);
|
|
|
|
// ลงทะเบียน job ใน Redis ก่อนส่งเข้า BullMQ
|
|
await this.aiRagService.registerActiveJob(userPublicId, requestPublicId);
|
|
|
|
// ส่ง job เข้า BullMQ ตาม ADR-008
|
|
const jobId = await this.aiQueueService.enqueueRagQuery({
|
|
requestPublicId,
|
|
userPublicId,
|
|
projectPublicId: dto.projectPublicId,
|
|
query: dto.question,
|
|
});
|
|
|
|
return { requestPublicId, jobId, status: 'queued' };
|
|
}
|
|
|
|
@Get('rag/jobs/:requestPublicId')
|
|
@UseGuards(JwtAuthGuard, RbacGuard)
|
|
@ApiBearerAuth()
|
|
@RequirePermission('rag.query')
|
|
@ApiOperation({
|
|
summary: 'RAG Job Status — ดูสถานะและผลลัพธ์ของ RAG query (polling)',
|
|
})
|
|
@ApiParam({
|
|
name: 'requestPublicId',
|
|
description: 'requestPublicId จาก submit endpoint',
|
|
})
|
|
async getRagJobStatus(
|
|
@Param('requestPublicId', ParseUuidPipe) requestPublicId: string
|
|
) {
|
|
const result = await this.aiRagService.getJobResult(requestPublicId);
|
|
if (!result) {
|
|
return { requestPublicId, status: 'not_found' };
|
|
}
|
|
return result;
|
|
}
|
|
|
|
@Delete('rag/jobs/:requestPublicId')
|
|
@UseGuards(JwtAuthGuard, RbacGuard)
|
|
@ApiBearerAuth()
|
|
@RequirePermission('rag.query')
|
|
@HttpCode(HttpStatus.NO_CONTENT)
|
|
@ApiOperation({
|
|
summary: 'RAG Job Cancel — ยกเลิก RAG job ที่กำลังประมวลผล (T022, FR-011)',
|
|
})
|
|
@ApiParam({
|
|
name: 'requestPublicId',
|
|
description: 'requestPublicId ของ job ที่ต้องการยกเลิก',
|
|
})
|
|
async cancelRagJob(
|
|
@Param('requestPublicId', ParseUuidPipe) requestPublicId: string
|
|
): Promise<void> {
|
|
await this.aiRagService.cancelJob(requestPublicId);
|
|
}
|
|
|
|
@Post('legacy-migration/ingest')
|
|
@UseGuards(ServiceAccountGuard)
|
|
@UseInterceptors(FilesInterceptor('files', 25))
|
|
@ApiOperation({
|
|
summary: 'Legacy Migration: ingest PDF batch into AI staging queue',
|
|
})
|
|
@ApiHeader({
|
|
name: 'Authorization',
|
|
description: 'Bearer {AI_N8N_SERVICE_TOKEN}',
|
|
required: true,
|
|
})
|
|
async ingestLegacyMigration(
|
|
@Body() dto: LegacyMigrationIngestDto,
|
|
@UploadedFiles() files: Express.Multer.File[] = []
|
|
) {
|
|
return this.aiIngestService.ingest(dto, files);
|
|
}
|
|
|
|
@Get('legacy-migration/queue')
|
|
@UseGuards(JwtAuthGuard, RbacGuard)
|
|
@ApiBearerAuth()
|
|
@RequirePermission('ai.migration_manage')
|
|
@ApiOperation({ summary: 'Legacy Migration: list AI staging queue records' })
|
|
async getLegacyMigrationQueue(
|
|
@Query() query: LegacyMigrationQueueQueryDto
|
|
): Promise<PaginatedMigrationReviewResponse> {
|
|
return this.aiIngestService.listQueue(query);
|
|
}
|
|
|
|
@Post('legacy-migration/queue/:publicId/approve')
|
|
@UseGuards(JwtAuthGuard, RbacGuard)
|
|
@ApiBearerAuth()
|
|
@RequirePermission('ai.migration_manage')
|
|
@ApiOperation({ summary: 'Legacy Migration: approve AI staging record' })
|
|
@ApiParam({ name: 'publicId', description: 'Migration review publicId' })
|
|
@ApiHeader({
|
|
name: 'Idempotency-Key',
|
|
description: 'Unique key for this approval/import operation',
|
|
required: true,
|
|
})
|
|
async approveLegacyMigrationRecord(
|
|
@Param('publicId', ParseUuidPipe) publicId: string,
|
|
@Body() dto: ApproveLegacyMigrationDto,
|
|
@Headers('idempotency-key') idempotencyKey: string,
|
|
@CurrentUser() user: User
|
|
): Promise<{ record: MigrationReviewResponse; importResult: unknown }> {
|
|
return this.aiIngestService.approve(
|
|
publicId,
|
|
dto,
|
|
idempotencyKey,
|
|
user.user_id
|
|
);
|
|
}
|
|
|
|
// ─── Migration Checkpoint API (ADR-023A) ──────────────────────────────────
|
|
|
|
@Get('migration/checkpoint/:batchId')
|
|
@UseGuards(JwtAuthGuard)
|
|
@ApiBearerAuth()
|
|
@Throttle({ default: { limit: 500, ttl: 60000 } }) // 500 req/min for n8n workflow loop
|
|
@ApiOperation({ summary: 'Migration: ดึง Checkpoint ของ Batch (ADR-023A)' })
|
|
@ApiParam({
|
|
name: 'batchId',
|
|
description: 'Batch ID ที่ต้องการดึง Checkpoint',
|
|
})
|
|
async getMigrationCheckpoint(@Param('batchId') batchId: string) {
|
|
return this.migrationCheckpointService.getCheckpoint(batchId);
|
|
}
|
|
|
|
@Post('migration/checkpoint')
|
|
@UseGuards(JwtAuthGuard)
|
|
@ApiBearerAuth()
|
|
@Throttle({ default: { limit: 500, ttl: 60000 } }) // 500 req/min for n8n workflow loop
|
|
@HttpCode(HttpStatus.OK)
|
|
@ApiOperation({ summary: 'Migration: บันทึก/อัพเดต Checkpoint (ADR-023A)' })
|
|
async saveMigrationCheckpoint(@Body() dto: SaveCheckpointDto) {
|
|
return this.migrationCheckpointService.saveCheckpoint(dto);
|
|
}
|
|
|
|
@Post('migration/queue/record')
|
|
@UseGuards(JwtAuthGuard)
|
|
@ApiBearerAuth()
|
|
@Throttle({ default: { limit: 500, ttl: 60000 } }) // 500 req/min for n8n workflow loop
|
|
@HttpCode(HttpStatus.OK)
|
|
@ApiOperation({
|
|
summary: 'Migration: บันทึกรายการเข้า Review Queue (ADR-023A)',
|
|
})
|
|
async upsertMigrationQueueRecord(@Body() dto: MigrationQueueRecordDto) {
|
|
return this.migrationCheckpointService.upsertQueueRecord(dto);
|
|
}
|
|
|
|
@Post('migration/errors')
|
|
@UseGuards(JwtAuthGuard)
|
|
@ApiBearerAuth()
|
|
@Throttle({ default: { limit: 500, ttl: 60000 } }) // 500 req/min for n8n workflow loop
|
|
@HttpCode(HttpStatus.OK)
|
|
@ApiOperation({ summary: 'Migration: บันทึก Error Log (ADR-023A)' })
|
|
async logMigrationError(@Body() dto: MigrationErrorLogDto) {
|
|
return this.migrationCheckpointService.logError(dto);
|
|
}
|
|
}
|