feat(ai): unify AI architecture, implement RAG and legacy migration
CI / CD Pipeline / build (push) Failing after 5m36s
CI / CD Pipeline / deploy (push) Has been skipped

This commit is contained in:
2026-05-15 11:10:44 +07:00
parent 0240d80da5
commit 6cb3ae10ee
56 changed files with 6051 additions and 304 deletions
+204 -10
View File
@@ -1,17 +1,26 @@
// File: src/modules/ai/ai.controller.ts
// Controller สำหรับ AI Gateway Endpoints (ADR-018, ADR-020)
// Change Log
// - 2026-05-14: เพิ่ม Legacy Migration staging endpoints ตาม ADR-023.
// - 2026-05-14: ย้าย DeleteAuditLogsQueryDto ไป dto/ folder; ลบ authHeader passthrough (🟢 LOW-1/LOW-2).
// Controller สำหรับ AI Gateway Endpoints (ADR-023)
import {
Controller,
Post,
Get,
Patch,
Delete,
Body,
Param,
Query,
Headers,
HttpCode,
HttpStatus,
UseGuards,
UseInterceptors,
UploadedFiles,
} from '@nestjs/common';
import { FilesInterceptor } from '@nestjs/platform-express';
import { Throttle } from '@nestjs/throttler';
import {
ApiTags,
@@ -22,27 +31,50 @@ import {
ApiQuery,
} from '@nestjs/swagger';
import { AiService, ExtractionResult, PaginatedResult } from './ai.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 { MigrationUpdateDto } from './dto/migration-update.dto';
import { MigrationQueryDto } from './dto/migration-query.dto';
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';
@ApiTags('AI Gateway')
@Controller('ai')
export class AiController {
constructor(private readonly aiService: AiService) {}
constructor(
private readonly aiService: AiService,
private readonly aiIngestService: AiIngestService,
private readonly aiRagService: AiRagService,
private readonly aiQueueService: AiQueueService
) {}
// --- Real-time Extraction (User Upload) ---
@Post('extract')
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, RbacGuard)
@ApiBearerAuth()
@RequirePermission('ai.extract')
@Throttle({ default: { limit: 5, ttl: 60000 } }) // Rate limit: 5 requests/minute (ADR-020)
@ApiOperation({
summary:
@@ -60,6 +92,7 @@ export class AiController {
// --- Webhook Callback จาก n8n (Service Account) ---
@Post('callback')
@UseGuards(ServiceAccountGuard) // T029: กำหนด guard ที่ controller layer (ADR-016)
@ApiOperation({
summary: 'AI Callback Endpoint — รับผลลัพธ์จาก n8n หลัง AI ประมวลผลเสร็จ',
description:
@@ -67,7 +100,8 @@ export class AiController {
})
@ApiHeader({
name: 'Authorization',
description: 'Bearer {AI_N8N_AUTH_TOKEN} — Service Account Token จาก n8n',
description:
'Bearer {AI_N8N_SERVICE_TOKEN} — Service Account Token จาก n8n',
required: true,
})
@ApiHeader({
@@ -77,14 +111,9 @@ export class AiController {
})
async handleCallback(
@Body() dto: AiCallbackDto,
@Headers('authorization') authHeader: string,
@Headers('x-ai-source') aiSource: string
): Promise<{ message: string }> {
await this.aiService.handleWebhookCallback(
dto,
aiSource ?? 'unknown',
authHeader
);
await this.aiService.handleWebhookCallback(dto, aiSource ?? 'unknown');
return { message: 'Callback processed successfully' };
}
@@ -150,4 +179,169 @@ export class AiController {
): 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,
});
}
// ─── RAG Query Endpoints (Phase 4 — FR-009, FR-010, FR-011) ────────────────
@Post('rag/query')
@UseGuards(JwtAuthGuard, 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
);
}
}