a2973be208
- เพิ่ม POST /api/ai/jobs + GET /api/ai/jobs/:jobId endpoints (FR-001, FR-002) - เพิ่ม BullMQ Worker MigrateDocumentWorker + OCR auto-detect (FR-003, FR-004) - เพิ่ม cleanup-temp-files + expire-pending-reviews workers (FR-005, FR-005a/b) - สร้าง SQL deltas: tags, correspondence_tags, alter migration_review_queue (FR-006, ADR-009) - เพิ่ม MigrationReviewService.commitRecord() + SELECT FOR UPDATE (FR-007, FR-007a) - เพิ่ม CASL permission migration.commit + MigrationReviewController (FR-007) - สร้าง TagsModule + TagsService + TagsController (US3) - สร้าง Migration Review Queue frontend page + ReviewQueueTable (US2) - อัปเดต n8n guide: deterministic Idempotency-Key + token pre-flight (FR-001a, FR-010a/b) - สร้าง spec.md, plan.md, tasks.md, data-model.md, contracts/, quickstart.md - สร้าง ADR-028 document + validation-report.md (PASS 32/32 tasks, 173/173 tests)
686 lines
25 KiB
TypeScript
686 lines
25 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
|
|
// 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,
|
|
} 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';
|
|
|
|
@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,
|
|
@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 };
|
|
}
|
|
|
|
@Get('admin/health')
|
|
@UseGuards(JwtAuthGuard, RbacGuard)
|
|
@ApiBearerAuth()
|
|
@RequirePermission('system.manage_all')
|
|
@ApiOperation({
|
|
summary:
|
|
'AI System Health — ดึงสถานะสุขภาพ Ollama, Qdrant และ BullMQ queues',
|
|
})
|
|
async getAiSystemHealth() {
|
|
return this.aiService.getSystemHealth();
|
|
}
|
|
|
|
@Post('admin/sandbox/rag')
|
|
@UseGuards(JwtAuthGuard, AiEnabledGuard, RbacGuard)
|
|
@ApiBearerAuth()
|
|
@RequirePermission('system.manage_all')
|
|
@HttpCode(HttpStatus.ACCEPTED)
|
|
@ApiOperation({
|
|
summary:
|
|
'AI Admin Sandbox RAG Query — ส่ง sandbox RAG เข้า queue ai-batch (T035)',
|
|
description:
|
|
'รัน RAG query สำหรับ Superadmin ใน sandbox environment เพื่อคุมทรัพยากร',
|
|
})
|
|
async submitSandboxRagQuery(
|
|
@Body() dto: AiRagQueryDto,
|
|
@CurrentUser() user: User
|
|
): Promise<{ requestPublicId: string; jobId: string; status: string }> {
|
|
const userPublicId = String(user.publicId ?? user.user_id);
|
|
const activeJob = await this.aiRagService.getActiveJob(userPublicId);
|
|
if (activeJob) {
|
|
return { requestPublicId: activeJob, jobId: activeJob, status: 'queued' };
|
|
}
|
|
const requestPublicId = uuidv7();
|
|
await this.aiRagService.registerActiveJob(userPublicId, requestPublicId);
|
|
const jobId = await this.aiQueueService.enqueueSandboxJob('sandbox-rag', {
|
|
idempotencyKey: requestPublicId,
|
|
projectPublicId: dto.projectPublicId,
|
|
query: dto.question,
|
|
userPublicId,
|
|
});
|
|
return { requestPublicId, jobId, status: 'queued' };
|
|
}
|
|
|
|
@Get('admin/sandbox/job/:id')
|
|
@UseGuards(JwtAuthGuard, RbacGuard)
|
|
@ApiBearerAuth()
|
|
@RequirePermission('system.manage_all')
|
|
@ApiOperation({
|
|
summary:
|
|
'AI Admin Sandbox Job Status — ตรวจสอบสถานะ RAG sandbox job (T036)',
|
|
})
|
|
@ApiParam({
|
|
name: 'id',
|
|
description: 'requestPublicId (UUID) ของ sandbox job ที่ส่งคำขอ',
|
|
})
|
|
async getSandboxJobStatus(@Param('id', ParseUuidPipe) id: string) {
|
|
const result = await this.aiRagService.getJobResult(id);
|
|
if (!result) {
|
|
return { requestPublicId: id, status: 'not_found' };
|
|
}
|
|
return result;
|
|
}
|
|
|
|
@Post('admin/sandbox/extract')
|
|
@UseGuards(JwtAuthGuard, AiEnabledGuard, RbacGuard)
|
|
@ApiBearerAuth()
|
|
@RequirePermission('system.manage_all')
|
|
@UseInterceptors(FileInterceptor('file'))
|
|
@HttpCode(HttpStatus.ACCEPTED)
|
|
@ApiOperation({
|
|
summary:
|
|
'AI Admin Sandbox OCR Extract — อัปโหลดไฟล์เพื่อทำ OCR Sandbox (T041 & T042)',
|
|
description:
|
|
'รัน OCR Sandbox สำหรับ Superadmin โดยคิว batchQueue ควบคุมอัตราการใช้งาน',
|
|
})
|
|
async submitSandboxExtract(
|
|
@UploadedFile(
|
|
new ParseFilePipe({
|
|
validators: [
|
|
new MaxFileSizeValidator({ maxSize: 50 * 1024 * 1024 }),
|
|
new FileTypeValidator({ fileType: 'pdf' }),
|
|
],
|
|
})
|
|
)
|
|
file: Express.Multer.File,
|
|
@CurrentUser() user: User
|
|
): Promise<{ requestPublicId: string; jobId: string; status: string }> {
|
|
const queueSize = await this.aiQueueService.getBatchQueueSize();
|
|
if (queueSize >= 3) {
|
|
const rateKey = `ai:sandbox:rate:${String(user.user_id)}`;
|
|
const countStr = await this.redis.get(rateKey);
|
|
const count = countStr ? Number(countStr) : 0;
|
|
if (count >= 10) {
|
|
throw new HttpException(
|
|
'Rate limit exceeded. Capped at 10 requests per hour when the queue is busy.',
|
|
HttpStatus.TOO_MANY_REQUESTS
|
|
);
|
|
}
|
|
if (!countStr) {
|
|
await this.redis.setex(rateKey, 3600, '1');
|
|
} else {
|
|
await this.redis.incr(rateKey);
|
|
}
|
|
}
|
|
const attachment = await this.fileStorageService.upload(file, user.user_id);
|
|
const requestPublicId = uuidv7();
|
|
const jobId = await this.aiQueueService.enqueueSandboxJob(
|
|
'sandbox-extract',
|
|
{
|
|
idempotencyKey: requestPublicId,
|
|
pdfPath: attachment.filePath,
|
|
}
|
|
);
|
|
return { requestPublicId, jobId, status: 'queued' };
|
|
}
|
|
|
|
// --- Webhook Callback จาก n8n (Service Account) ---
|
|
|
|
@Post('callback')
|
|
@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
|
|
);
|
|
}
|
|
}
|