From 99c8d6185688c732775c088dcabe3aaf9330ed71 Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 9 Apr 2026 09:53:57 +0700 Subject: [PATCH] 690409:0953 Done Task-BE-AI-02 --- backend/src/app.module.ts | 2 + backend/src/common/config/env.validation.ts | 14 + .../modules/ai/ai-validation.service.spec.ts | 211 +++++++++ .../src/modules/ai/ai-validation.service.ts | 127 ++++++ backend/src/modules/ai/ai.controller.ts | 153 +++++++ backend/src/modules/ai/ai.module.ts | 42 ++ backend/src/modules/ai/ai.service.spec.ts | 317 ++++++++++++++ backend/src/modules/ai/ai.service.ts | 403 ++++++++++++++++++ backend/src/modules/ai/dto/ai-callback.dto.ts | 87 ++++ .../modules/ai/dto/extract-document.dto.ts | 33 ++ .../src/modules/ai/dto/migration-query.dto.ts | 41 ++ .../modules/ai/dto/migration-update.dto.ts | 27 ++ .../ai/entities/ai-audit-log.entity.ts | 71 +++ .../ai/entities/migration-log.entity.ts | 93 ++++ .../components/correspondences/form.test.tsx | 4 +- .../03-01-data-dictionary.md | 100 ++++- .../lcbp3-v1.8.0-schema-02-tables.sql | 48 +++ .../{Task BE-AI-02.md => Task-BE-AI-02.md} | 78 +--- 18 files changed, 1791 insertions(+), 60 deletions(-) create mode 100644 backend/src/modules/ai/ai-validation.service.spec.ts create mode 100644 backend/src/modules/ai/ai-validation.service.ts create mode 100644 backend/src/modules/ai/ai.controller.ts create mode 100644 backend/src/modules/ai/ai.module.ts create mode 100644 backend/src/modules/ai/ai.service.spec.ts create mode 100644 backend/src/modules/ai/ai.service.ts create mode 100644 backend/src/modules/ai/dto/ai-callback.dto.ts create mode 100644 backend/src/modules/ai/dto/extract-document.dto.ts create mode 100644 backend/src/modules/ai/dto/migration-query.dto.ts create mode 100644 backend/src/modules/ai/dto/migration-update.dto.ts create mode 100644 backend/src/modules/ai/entities/ai-audit-log.entity.ts create mode 100644 backend/src/modules/ai/entities/migration-log.entity.ts rename specs/08-Tasks/{Task BE-AI-02.md => Task-BE-AI-02.md} (77%) diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 84cb2e7..4ced00d 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -51,6 +51,7 @@ import { ResilienceModule } from './common/resilience/resilience.module'; import { SearchModule } from './modules/search/search.module'; import { AuditLogModule } from './modules/audit-log/audit-log.module'; import { MigrationModule } from './modules/migration/migration.module'; +import { AiModule } from './modules/ai/ai.module'; @Module({ imports: [ @@ -187,6 +188,7 @@ import { MigrationModule } from './modules/migration/migration.module'; DashboardModule, AuditLogModule, MigrationModule, + AiModule, ], controllers: [AppController], providers: [ diff --git a/backend/src/common/config/env.validation.ts b/backend/src/common/config/env.validation.ts index 855f53e..5c8ec89 100644 --- a/backend/src/common/config/env.validation.ts +++ b/backend/src/common/config/env.validation.ts @@ -28,4 +28,18 @@ export const envValidationSchema = Joi.object({ REDIS_HOST: Joi.string().required(), REDIS_PORT: Joi.number().default(6379), REDIS_PASSWORD: Joi.string().required(), + + // 5. AI Gateway Configuration (ADR-018, ADR-020) + // URL ของ n8n Webhook สำหรับส่งเอกสารไปประมวลผล + AI_N8N_WEBHOOK_URL: Joi.string().uri().optional(), + // Token สำหรับ Service Account Authentication กับ n8n + AI_N8N_AUTH_TOKEN: Joi.string().optional(), + // URL ของ Ollama บน Admin Desktop (Desk-5439) + AI_OLLAMA_URL: Joi.string().uri().optional(), + // Timeout สำหรับการรอผลลัพธ์จาก AI (milliseconds) + AI_TIMEOUT_MS: Joi.number().default(30000), + // จำนวนครั้งสูงสุดในการ Retry เมื่อ AI ล้มเหลว + AI_MAX_RETRIES: Joi.number().default(3), + // Base URL ของ Backend เพื่อสร้าง Callback URL + APP_BASE_URL: Joi.string().uri().optional(), }); diff --git a/backend/src/modules/ai/ai-validation.service.spec.ts b/backend/src/modules/ai/ai-validation.service.spec.ts new file mode 100644 index 0000000..64765a3 --- /dev/null +++ b/backend/src/modules/ai/ai-validation.service.spec.ts @@ -0,0 +1,211 @@ +// File: src/modules/ai/ai-validation.service.spec.ts +// Unit Tests สำหรับ AiValidationService — ตรวจสอบ Confidence Thresholds และ Validation Logic (ADR-020) + +import { Test, TestingModule } from '@nestjs/testing'; +import { AiValidationService } from './ai-validation.service'; +import { AiCallbackDto } from './dto/ai-callback.dto'; +import { AiAuditStatus } from './entities/ai-audit-log.entity'; + +describe('AiValidationService', () => { + let service: AiValidationService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [AiValidationService], + }).compile(); + + service = module.get(AiValidationService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + // --- getConfidenceAction --- + + describe('getConfidenceAction', () => { + it('ควรคืน auto_approve เมื่อ confidence >= 0.95', () => { + expect(service.getConfidenceAction(0.95)).toBe('auto_approve'); + expect(service.getConfidenceAction(1.0)).toBe('auto_approve'); + expect(service.getConfidenceAction(0.99)).toBe('auto_approve'); + }); + + it('ควรคืน low_priority_review เมื่อ confidence 0.85-0.94', () => { + expect(service.getConfidenceAction(0.85)).toBe('low_priority_review'); + expect(service.getConfidenceAction(0.9)).toBe('low_priority_review'); + expect(service.getConfidenceAction(0.94)).toBe('low_priority_review'); + }); + + it('ควรคืน high_priority_review เมื่อ confidence 0.60-0.84', () => { + expect(service.getConfidenceAction(0.6)).toBe('high_priority_review'); + expect(service.getConfidenceAction(0.75)).toBe('high_priority_review'); + expect(service.getConfidenceAction(0.84)).toBe('high_priority_review'); + }); + + it('ควรคืน reject เมื่อ confidence < 0.60', () => { + expect(service.getConfidenceAction(0.59)).toBe('reject'); + expect(service.getConfidenceAction(0.0)).toBe('reject'); + expect(service.getConfidenceAction(0.3)).toBe('reject'); + }); + }); + + // --- validateAiOutput --- + + describe('validateAiOutput', () => { + // สร้าง Payload ตัวอย่างที่ถูกต้อง + const buildPayload = ( + overrides: Partial = {} + ): AiCallbackDto => ({ + migrationLogPublicId: '019505a1-7c3e-7000-8000-abc123def456', + aiModel: 'gemma4', + status: AiAuditStatus.SUCCESS, + confidenceScore: 0.9, + extractedMetadata: { + subject: 'ทดสอบ', + date: '2026-04-09', + discipline: 'Civil', + }, + ...overrides, + }); + + it('ควร valid เมื่อ confidence >= 0.60 และ status = SUCCESS', () => { + const result = service.validateAiOutput( + buildPayload({ confidenceScore: 0.9 }) + ); + expect(result.isValid).toBe(true); + expect(result.action).toBe('low_priority_review'); + }); + + it('ควร invalid เมื่อ status = FAILED', () => { + const result = service.validateAiOutput( + buildPayload({ status: AiAuditStatus.FAILED }) + ); + expect(result.isValid).toBe(false); + expect(result.action).toBe('reject'); + expect(result.reasons.length).toBeGreaterThan(0); + }); + + it('ควร invalid เมื่อ status = TIMEOUT', () => { + const result = service.validateAiOutput( + buildPayload({ status: AiAuditStatus.TIMEOUT }) + ); + expect(result.isValid).toBe(false); + }); + + it('ควร invalid เมื่อ confidence < 0.60', () => { + const result = service.validateAiOutput( + buildPayload({ confidenceScore: 0.55 }) + ); + expect(result.isValid).toBe(false); + expect(result.action).toBe('reject'); + expect(result.reasons.some((r) => r.includes('0.55'))).toBe(true); + }); + + it('ควร valid พร้อม auto_approve เมื่อ confidence >= 0.95', () => { + const result = service.validateAiOutput( + buildPayload({ confidenceScore: 0.97 }) + ); + expect(result.isValid).toBe(true); + expect(result.action).toBe('auto_approve'); + }); + + it('ควร invalid เมื่อ discipline ไม่ถูกต้อง', () => { + const result = service.validateAiOutput( + buildPayload({ + confidenceScore: 0.9, + extractedMetadata: { discipline: 'InvalidDiscipline' }, + }) + ); + expect(result.isValid).toBe(false); + expect(result.reasons.some((r) => r.includes('discipline'))).toBe(true); + }); + + it('ควรยอมรับ discipline ที่ถูกต้องทั้ง 4 ค่า', () => { + const validDisciplines = [ + 'Civil', + 'Mechanical', + 'Electrical', + 'Architectural', + ]; + for (const discipline of validDisciplines) { + const result = service.validateAiOutput( + buildPayload({ + confidenceScore: 0.9, + extractedMetadata: { discipline }, + }) + ); + expect(result.isValid).toBe(true); + } + }); + + it('ควร invalid เมื่อ date format ผิด', () => { + const result = service.validateAiOutput( + buildPayload({ + confidenceScore: 0.9, + extractedMetadata: { date: '09/04/2026' }, // รูปแบบผิด + }) + ); + expect(result.isValid).toBe(false); + expect(result.reasons.some((r) => r.includes('date'))).toBe(true); + }); + + it('ควร valid เมื่อไม่มี extractedMetadata (metadata เป็น optional)', () => { + const result = service.validateAiOutput( + buildPayload({ confidenceScore: 0.9, extractedMetadata: undefined }) + ); + expect(result.isValid).toBe(true); + }); + + it('ควร valid เมื่อ confidence = undefined (ถือว่า 0 — reject)', () => { + const result = service.validateAiOutput( + buildPayload({ confidenceScore: undefined }) + ); + expect(result.isValid).toBe(false); + expect(result.action).toBe('reject'); + }); + }); + + // --- buildAuditSummary --- + + describe('buildAuditSummary', () => { + it('ควรสร้าง Summary string ที่มีข้อมูลครบถ้วน', () => { + const payload: AiCallbackDto = { + migrationLogPublicId: '019505a1-7c3e-7000-8000-abc123def456', + aiModel: 'gemma4', + status: AiAuditStatus.SUCCESS, + confidenceScore: 0.92, + }; + const validationResult = { + isValid: true, + action: 'low_priority_review' as const, + confidence: 0.92, + reasons: [], + }; + + const summary = service.buildAuditSummary(payload, validationResult); + + expect(summary).toContain('model=gemma4'); + expect(summary).toContain('confidence=0.92'); + expect(summary).toContain('action=low_priority_review'); + expect(summary).toContain('valid=true'); + }); + + it('ควรแสดง reasons เมื่อ validation ล้มเหลว', () => { + const payload: AiCallbackDto = { + migrationLogPublicId: '019505a1-7c3e-7000-8000-abc123def456', + aiModel: 'gemma4', + status: AiAuditStatus.FAILED, + }; + const validationResult = { + isValid: false, + action: 'reject' as const, + confidence: 0, + reasons: ['AI processing failed with status: FAILED'], + }; + + const summary = service.buildAuditSummary(payload, validationResult); + expect(summary).toContain('reasons='); + expect(summary).toContain('FAILED'); + }); + }); +}); diff --git a/backend/src/modules/ai/ai-validation.service.ts b/backend/src/modules/ai/ai-validation.service.ts new file mode 100644 index 0000000..bb4b9e5 --- /dev/null +++ b/backend/src/modules/ai/ai-validation.service.ts @@ -0,0 +1,127 @@ +// File: src/modules/ai/ai-validation.service.ts +// Service ตรวจสอบผลลัพธ์จาก AI ก่อน Write ลง Database (ADR-018 Rule 4) + +import { Injectable, Logger } from '@nestjs/common'; +import { AiCallbackDto } from './dto/ai-callback.dto'; +import { AiAuditStatus } from './entities/ai-audit-log.entity'; + +// ผลการตรวจสอบ AI Output +export interface AiValidationResult { + isValid: boolean; + action: ConfidenceAction; + confidence: number; + reasons: string[]; // เหตุผลที่ validation ผ่านหรือไม่ผ่าน +} + +// Action ที่ต้องทำตามระดับ Confidence (ADR-020 Confidence Scoring Strategy) +export type ConfidenceAction = + | 'auto_approve' // 0.95-1.00: นำเข้าอัตโนมัติ (Migration เท่านั้น) + | 'low_priority_review' // 0.85-0.94: รอตรวจสอบ ลำดับต่ำ + | 'high_priority_review' // 0.60-0.84: รอตรวจสอบ ลำดับสูง + | 'reject'; // <0.60: ปฏิเสธ ต้องกรอกเอง + +// Discipline values ที่ถูกต้องตาม Prompt Strategy ใน ADR-020 +const VALID_DISCIPLINES = [ + 'Civil', + 'Mechanical', + 'Electrical', + 'Architectural', +] as const; +type ValidDiscipline = (typeof VALID_DISCIPLINES)[number]; + +function isValidDiscipline(value: unknown): value is ValidDiscipline { + return ( + typeof value === 'string' && + (VALID_DISCIPLINES as readonly string[]).includes(value) + ); +} + +@Injectable() +export class AiValidationService { + private readonly logger = new Logger(AiValidationService.name); + + // Confidence Thresholds ตาม ADR-020 + private readonly THRESHOLD_AUTO_APPROVE = 0.95; + private readonly THRESHOLD_LOW_PRIORITY = 0.85; + private readonly THRESHOLD_HIGH_PRIORITY = 0.6; + + // กำหนด Action ตาม Confidence Score + getConfidenceAction(confidence: number): ConfidenceAction { + if (confidence >= this.THRESHOLD_AUTO_APPROVE) return 'auto_approve'; + if (confidence >= this.THRESHOLD_LOW_PRIORITY) return 'low_priority_review'; + if (confidence >= this.THRESHOLD_HIGH_PRIORITY) + return 'high_priority_review'; + return 'reject'; + } + + // ตรวจสอบ AI Output ก่อน Write ลง Database (ADR-018 Rule 4) + validateAiOutput(payload: AiCallbackDto): AiValidationResult { + const reasons: string[] = []; + + // 1. ตรวจสอบ Status จาก AI + if (payload.status !== AiAuditStatus.SUCCESS) { + reasons.push(`AI processing failed with status: ${payload.status}`); + this.logger.warn( + `AI validation failed — status=${payload.status}, model=${payload.aiModel}` + ); + return { isValid: false, action: 'reject', confidence: 0, reasons }; + } + + // 2. ตรวจสอบ Confidence Score + const confidence = payload.confidenceScore ?? 0; + const action = this.getConfidenceAction(confidence); + + if (action === 'reject') { + reasons.push( + `Confidence score ${confidence} is below minimum threshold ${this.THRESHOLD_HIGH_PRIORITY}` + ); + } + + // 3. ตรวจสอบ Extracted Metadata (Enum Enforcement) + if (payload.extractedMetadata) { + const { discipline } = payload.extractedMetadata; + if (discipline !== undefined && !isValidDiscipline(discipline)) { + reasons.push( + `Invalid discipline value: "${String(discipline)}". Must be one of: ${VALID_DISCIPLINES.join(', ')}` + ); + } + + // ตรวจสอบรูปแบบ Date + if (payload.extractedMetadata.date) { + const dateStr = String(payload.extractedMetadata.date); + const dateRegex = /^\d{4}-\d{2}-\d{2}$/; + if (!dateRegex.test(dateStr)) { + reasons.push( + `Invalid date format: "${dateStr}". Expected YYYY-MM-DD` + ); + } + } + } + + const isValid = action !== 'reject' && reasons.length === 0; + + this.logger.log( + `AI validation — model=${payload.aiModel}, confidence=${confidence}, action=${action}, valid=${isValid}` + ); + + return { isValid, action, confidence, reasons }; + } + + // สร้าง Summary สำหรับ Log (ADR-018 Rule 5 Audit Logging) + buildAuditSummary( + payload: AiCallbackDto, + validationResult: AiValidationResult + ): string { + return [ + `model=${payload.aiModel}`, + `confidence=${validationResult.confidence}`, + `action=${validationResult.action}`, + `valid=${validationResult.isValid}`, + validationResult.reasons.length > 0 + ? `reasons=[${validationResult.reasons.join('; ')}]` + : null, + ] + .filter(Boolean) + .join(', '); + } +} diff --git a/backend/src/modules/ai/ai.controller.ts b/backend/src/modules/ai/ai.controller.ts new file mode 100644 index 0000000..1541191 --- /dev/null +++ b/backend/src/modules/ai/ai.controller.ts @@ -0,0 +1,153 @@ +// File: src/modules/ai/ai.controller.ts +// Controller สำหรับ AI Gateway Endpoints (ADR-018, ADR-020) + +import { + Controller, + Post, + Get, + Patch, + Body, + Param, + Query, + Headers, + UseGuards, +} from '@nestjs/common'; +import { Throttle } from '@nestjs/throttler'; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiHeader, + ApiParam, + ApiQuery, +} from '@nestjs/swagger'; +import { AiService, ExtractionResult, PaginatedResult } from './ai.service'; +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 { MigrationLog } from './entities/migration-log.entity'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { RbacGuard } from '../../common/guards/rbac.guard'; +import { CurrentUser } from '../../common/decorators/current-user.decorator'; +import { RequirePermission } from '../../common/decorators/require-permission.decorator'; +import { User } from '../user/entities/user.entity'; + +@ApiTags('AI Gateway') +@Controller('ai') +export class AiController { + constructor(private readonly aiService: AiService) {} + + // --- Real-time Extraction (User Upload) --- + + @Post('extract') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @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 { + return this.aiService.extractRealtime(dto, user.user_id); + } + + // --- Webhook Callback จาก n8n (Service Account) --- + + @Post('callback') + @ApiOperation({ + summary: 'AI Callback Endpoint — รับผลลัพธ์จาก n8n หลัง AI ประมวลผลเสร็จ', + description: + 'เรียกโดย n8n Service Account เท่านั้น ต้องใส่ Bearer Token ใน Authorization header', + }) + @ApiHeader({ + name: 'Authorization', + description: 'Bearer {AI_N8N_AUTH_TOKEN} — Service Account Token จาก n8n', + required: true, + }) + @ApiHeader({ + name: 'X-AI-Source', + description: 'ระบุแหล่งที่มา เช่น ollama, n8n', + required: false, + }) + 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 + ); + 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> { + 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 { + return this.aiService.updateMigrationLog(publicId, dto, user.user_id); + } +} diff --git a/backend/src/modules/ai/ai.module.ts b/backend/src/modules/ai/ai.module.ts new file mode 100644 index 0000000..69ad8a0 --- /dev/null +++ b/backend/src/modules/ai/ai.module.ts @@ -0,0 +1,42 @@ +// File: src/modules/ai/ai.module.ts +// Module สำหรับ AI Gateway — ลงทะเบียน Services และ Controllers (ADR-018, ADR-020) + +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { HttpModule } from '@nestjs/axios'; +import { ConfigModule } from '@nestjs/config'; +import { AiController } from './ai.controller'; +import { AiService } from './ai.service'; +import { AiValidationService } from './ai-validation.service'; +import { MigrationLog } from './entities/migration-log.entity'; +import { AiAuditLog } from './entities/ai-audit-log.entity'; +import { UserModule } from '../user/user.module'; +import { RbacGuard } from '../../common/guards/rbac.guard'; + +@Module({ + imports: [ + // Entities สำหรับ AI Module + TypeOrmModule.forFeature([MigrationLog, AiAuditLog]), + + // HTTP Client สำหรับเรียก n8n Webhook (ADR-018: AI สื่อสารผ่าน API) + HttpModule.register({ + timeout: 35000, // เผื่อ timeout เกิน AI_TIMEOUT_MS เล็กน้อย + maxRedirects: 3, + }), + + // Config สำหรับ AI Env Vars + ConfigModule, + + // UserModule สำหรับ RbacGuard (ต้องการ UserService) + UserModule, + ], + controllers: [AiController], + providers: [ + AiService, + AiValidationService, + // RbacGuard ต้องการ UserService จาก UserModule + RbacGuard, + ], + exports: [AiService, AiValidationService], +}) +export class AiModule {} diff --git a/backend/src/modules/ai/ai.service.spec.ts b/backend/src/modules/ai/ai.service.spec.ts new file mode 100644 index 0000000..18c7ca6 --- /dev/null +++ b/backend/src/modules/ai/ai.service.spec.ts @@ -0,0 +1,317 @@ +// File: src/modules/ai/ai.service.spec.ts +// Unit Tests สำหรับ AiService — ทดสอบ Business Logic สำคัญ: Callback, Update, Status Transitions + +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { HttpService } from '@nestjs/axios'; +import { ConfigService } from '@nestjs/config'; +import { AiService } from './ai.service'; +import { AiValidationService } from './ai-validation.service'; +import { + MigrationLog, + MigrationLogStatus, +} from './entities/migration-log.entity'; +import { AiAuditLog, AiAuditStatus } from './entities/ai-audit-log.entity'; +import { AiCallbackDto } from './dto/ai-callback.dto'; +import { MigrationUpdateDto } from './dto/migration-update.dto'; +import { NotFoundException, BusinessException } from '../../common/exceptions'; + +describe('AiService', () => { + let service: AiService; + + // Mock Repositories + const mockMigrationLogRepo = { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + createQueryBuilder: jest.fn().mockReturnValue({ + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([[], 0]), + }), + }; + + const mockAuditLogRepo = { + create: jest.fn(), + save: jest.fn(), + }; + + // Mock ConfigService — คืนค่า Config ตาม Key + const mockConfigService = { + get: jest.fn((key: string) => { + const config: Record = { + AI_N8N_WEBHOOK_URL: 'http://localhost:5678/webhook/test', + AI_N8N_AUTH_TOKEN: 'test-token', + AI_TIMEOUT_MS: 30000, + APP_BASE_URL: 'http://localhost:3001', + }; + return config[key]; + }), + }; + + // Mock HttpService (ไม่ต้องการ HTTP call จริงใน Unit Test) + const mockHttpService = { + post: jest.fn(), + }; + + // Mock AiValidationService + + const mockValidationService = { + validateAiOutput: jest.fn(), + buildAuditSummary: jest + .fn() + .mockReturnValue('model=gemma4, confidence=0.90, valid=true'), + getConfidenceAction: jest.fn().mockReturnValue('low_priority_review'), + }; + + beforeEach(async () => { + jest.clearAllMocks(); + + // ตั้งค่า default return values + mockMigrationLogRepo.create.mockReturnValue({ + publicId: '019505a1-7c3e-7000-8000-abc123def456', + sourceFile: 'test-file-uuid', + status: MigrationLogStatus.PENDING_REVIEW, + }); + mockMigrationLogRepo.save.mockImplementation((entity) => + Promise.resolve({ ...entity, id: 1 }) + ); + mockAuditLogRepo.create.mockReturnValue({}); + mockAuditLogRepo.save.mockResolvedValue({}); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AiService, + { + provide: getRepositoryToken(MigrationLog), + useValue: mockMigrationLogRepo, + }, + { provide: getRepositoryToken(AiAuditLog), useValue: mockAuditLogRepo }, + { provide: ConfigService, useValue: mockConfigService }, + { provide: HttpService, useValue: mockHttpService }, + { provide: AiValidationService, useValue: mockValidationService }, + ], + }).compile(); + + service = module.get(AiService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + // --- handleWebhookCallback --- + + describe('handleWebhookCallback', () => { + const validPayload: AiCallbackDto = { + migrationLogPublicId: '019505a1-7c3e-7000-8000-abc123def456', + aiModel: 'gemma4', + status: AiAuditStatus.SUCCESS, + confidenceScore: 0.92, + extractedMetadata: { subject: 'Test', discipline: 'Civil' }, + processingTimeMs: 5000, + }; + + const validAuthHeader = 'Bearer test-token'; + + it('ควรปฏิเสธ request เมื่อไม่มี Authorization header', async () => { + await expect( + service.handleWebhookCallback(validPayload, 'n8n', '') + ).rejects.toThrow(); + }); + + it('ควรปฏิเสธ request เมื่อ Token ไม่ถูกต้อง', async () => { + await expect( + service.handleWebhookCallback(validPayload, 'n8n', 'Bearer wrong-token') + ).rejects.toThrow(); + }); + + it('ควร throw NotFoundException เมื่อ MigrationLog ไม่พบ', async () => { + mockMigrationLogRepo.findOne.mockResolvedValue(null); + mockValidationService.validateAiOutput.mockReturnValue({ + isValid: true, + action: 'low_priority_review', + confidence: 0.92, + reasons: [], + }); + + await expect( + service.handleWebhookCallback(validPayload, 'n8n', validAuthHeader) + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('ควรอัปเดต MigrationLog เมื่อ Callback ถูกต้อง', async () => { + const existingLog = { + id: 1, + publicId: '019505a1-7c3e-7000-8000-abc123def456', + status: MigrationLogStatus.PENDING_REVIEW, + sourceFile: 'test.pdf', + save: jest.fn(), + }; + + mockMigrationLogRepo.findOne.mockResolvedValue(existingLog); + mockValidationService.validateAiOutput.mockReturnValue({ + isValid: true, + action: 'low_priority_review', + confidence: 0.92, + reasons: [], + }); + + await service.handleWebhookCallback(validPayload, 'n8n', validAuthHeader); + + expect(mockMigrationLogRepo.save).toHaveBeenCalled(); + expect(mockAuditLogRepo.create).toHaveBeenCalled(); + }); + + it('ควร Auto-approve เมื่อ confidence >= 0.95', async () => { + const highConfidencePayload = { ...validPayload, confidenceScore: 0.97 }; + const existingLog = { + id: 1, + publicId: '019505a1-7c3e-7000-8000-abc123def456', + status: MigrationLogStatus.PENDING_REVIEW, + sourceFile: 'test.pdf', + }; + + mockMigrationLogRepo.findOne.mockResolvedValue(existingLog); + + mockValidationService.validateAiOutput.mockReturnValue({ + isValid: true, + action: 'auto_approve', + confidence: 0.97, + reasons: [], + }); + + await service.handleWebhookCallback( + highConfidencePayload, + 'n8n', + validAuthHeader + ); + + const calls = mockMigrationLogRepo.save.mock.calls as [MigrationLog][]; + const savedLog = calls[0][0]; + expect(savedLog.status).toBe(MigrationLogStatus.VERIFIED); + }); + + it('ควรตั้งสถานะ FAILED เมื่อ AI ล้มเหลว', async () => { + const failedPayload = { + ...validPayload, + status: AiAuditStatus.FAILED, + errorMessage: 'OCR timeout', + }; + const existingLog = { + id: 1, + publicId: '019505a1-7c3e-7000-8000-abc123def456', + status: MigrationLogStatus.PENDING_REVIEW, + sourceFile: 'test.pdf', + }; + + mockMigrationLogRepo.findOne.mockResolvedValue(existingLog); + mockValidationService.validateAiOutput.mockReturnValue({ + isValid: false, + action: 'reject', + confidence: 0, + reasons: ['AI processing failed'], + }); + + await service.handleWebhookCallback( + failedPayload, + 'n8n', + validAuthHeader + ); + + const calls = mockMigrationLogRepo.save.mock.calls as [MigrationLog][]; + const savedLog = calls[0][0]; + expect(savedLog.status).toBe(MigrationLogStatus.FAILED); + }); + }); + + // --- updateMigrationLog --- + + describe('updateMigrationLog', () => { + const publicId = '019505a1-7c3e-7000-8000-abc123def456'; + + it('ควร throw NotFoundException เมื่อ MigrationLog ไม่พบ', async () => { + mockMigrationLogRepo.findOne.mockResolvedValue(null); + + await expect( + service.updateMigrationLog(publicId, {}, 1) + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('ควรอัปเดตสถานะ PENDING_REVIEW → VERIFIED ได้', async () => { + const existingLog = { + id: 1, + publicId, + status: MigrationLogStatus.PENDING_REVIEW, + sourceFile: 'test.pdf', + }; + + mockMigrationLogRepo.findOne.mockResolvedValue(existingLog); + mockMigrationLogRepo.save.mockImplementation((entity) => + Promise.resolve({ ...entity }) + ); + + const dto: MigrationUpdateDto = { status: MigrationLogStatus.VERIFIED }; + const result = await service.updateMigrationLog(publicId, dto, 5); + + expect(result.status).toBe(MigrationLogStatus.VERIFIED); + expect(result.reviewedBy).toBe(5); + }); + + it('ควร throw BusinessException เมื่อ State Transition ไม่ถูกต้อง (IMPORTED → VERIFIED)', async () => { + const existingLog = { + id: 1, + publicId, + status: MigrationLogStatus.IMPORTED, // Terminal State + sourceFile: 'test.pdf', + }; + + mockMigrationLogRepo.findOne.mockResolvedValue(existingLog); + + const dto: MigrationUpdateDto = { status: MigrationLogStatus.VERIFIED }; + + await expect( + service.updateMigrationLog(publicId, dto, 1) + ).rejects.toBeInstanceOf(BusinessException); + }); + + it('ควรอัปเดต adminFeedback ได้โดยไม่ต้องเปลี่ยนสถานะ', async () => { + const existingLog = { + id: 1, + publicId, + status: MigrationLogStatus.PENDING_REVIEW, + sourceFile: 'test.pdf', + adminFeedback: undefined, + }; + + mockMigrationLogRepo.findOne.mockResolvedValue(existingLog); + mockMigrationLogRepo.save.mockImplementation((entity) => + Promise.resolve({ ...entity }) + ); + + const dto: MigrationUpdateDto = { + adminFeedback: 'ตรวจสอบแล้ว ข้อมูลถูกต้อง', + }; + const result = await service.updateMigrationLog(publicId, dto, 3); + + expect(result.adminFeedback).toBe('ตรวจสอบแล้ว ข้อมูลถูกต้อง'); + expect(result.status).toBe(MigrationLogStatus.PENDING_REVIEW); + }); + }); + + // --- getMigrationList --- + + describe('getMigrationList', () => { + it('ควรคืน paginated result', async () => { + const result = await service.getMigrationList({ page: 1, limit: 10 }); + + expect(result).toHaveProperty('items'); + expect(result).toHaveProperty('total'); + expect(result).toHaveProperty('page', 1); + expect(result).toHaveProperty('limit', 10); + expect(result).toHaveProperty('totalPages'); + }); + }); +}); diff --git a/backend/src/modules/ai/ai.service.ts b/backend/src/modules/ai/ai.service.ts new file mode 100644 index 0000000..92a31f3 --- /dev/null +++ b/backend/src/modules/ai/ai.service.ts @@ -0,0 +1,403 @@ +// File: src/modules/ai/ai.service.ts +// Service หลักของ AI Gateway — เชื่อมต่อระหว่าง DMS กับ n8n/Ollama Pipeline (ADR-018, ADR-020) + +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { HttpService } from '@nestjs/axios'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { firstValueFrom, timeout, catchError } from 'rxjs'; +import { AxiosError } from 'axios'; +import { + NotFoundException, + ValidationException, + SystemException, + BusinessException, +} from '../../common/exceptions'; +import { + MigrationLog, + MigrationLogStatus, + MIGRATION_STATUS_TRANSITIONS, +} from './entities/migration-log.entity'; +import { AiAuditLog, AiAuditStatus } from './entities/ai-audit-log.entity'; +import { AiCallbackDto } from './dto/ai-callback.dto'; +import { ExtractDocumentDto } from './dto/extract-document.dto'; +import { MigrationUpdateDto } from './dto/migration-update.dto'; +import { MigrationQueryDto } from './dto/migration-query.dto'; +import { AiValidationService } from './ai-validation.service'; + +// ผลลัพธ์ของ Real-time Extraction +export interface ExtractionResult { + migrationLogPublicId: string; + status: 'processing' | 'completed' | 'failed'; + extractedMetadata?: Record; + confidenceScore?: number; + action?: string; + processingTimeMs?: number; +} + +// ผลลัพธ์ของ Paginated List +export interface PaginatedResult { + items: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +// Context สำหรับส่งไปยัง n8n +interface N8nWebhookPayload { + migrationLogPublicId: string; + filePublicId: string; + context: string; + callbackUrl: string; + fileType?: string; +} + +// Response จาก n8n (realtime mode) +interface N8nWebhookResponse { + status: string; + extractedMetadata?: Record; + confidenceScore?: number; + processingTimeMs?: number; + inputHash?: string; + outputHash?: string; + errorMessage?: string; +} + +@Injectable() +export class AiService { + private readonly logger = new Logger(AiService.name); + + // Config จาก Environment Variables + private readonly n8nWebhookUrl: string; + private readonly n8nAuthToken: string; + private readonly timeoutMs: number; + private readonly callbackBaseUrl: string; + + constructor( + private readonly configService: ConfigService, + private readonly httpService: HttpService, + private readonly aiValidationService: AiValidationService, + @InjectRepository(MigrationLog) + private readonly migrationLogRepo: Repository, + @InjectRepository(AiAuditLog) + private readonly aiAuditLogRepo: Repository + ) { + this.n8nWebhookUrl = + this.configService.get('AI_N8N_WEBHOOK_URL') ?? ''; + this.n8nAuthToken = + this.configService.get('AI_N8N_AUTH_TOKEN') ?? ''; + this.timeoutMs = this.configService.get('AI_TIMEOUT_MS') ?? 30000; + this.callbackBaseUrl = + this.configService.get('APP_BASE_URL') ?? 'http://localhost:3001'; + } + + // --- Real-time Extraction (สำหรับ User Upload ใหม่) --- + + async extractRealtime( + dto: ExtractDocumentDto, + _userId: number + ): Promise { + // 1. สร้าง MigrationLog entry เพื่อ Track การประมวลผล + const migrationLog = this.migrationLogRepo.create({ + sourceFile: dto.publicId, // ใช้ publicId เป็น reference ไปยัง file storage + status: MigrationLogStatus.PENDING_REVIEW, + }); + await this.migrationLogRepo.save(migrationLog); + + // 2. ตรวจสอบว่า n8n URL ถูก Configure ไหม + if (!this.n8nWebhookUrl) { + this.logger.warn( + `AI_N8N_WEBHOOK_URL ไม่ได้ Configure — ข้ามการส่งไปยัง n8n` + ); + return { + migrationLogPublicId: migrationLog.publicId, + status: 'processing', + processingTimeMs: 0, + }; + } + + const startTime = Date.now(); + + try { + // 3. ส่ง Request ไปยัง n8n Webhook (ADR-018: AI สื่อสารผ่าน API เท่านั้น) + const payload: N8nWebhookPayload = { + migrationLogPublicId: migrationLog.publicId, + filePublicId: dto.publicId, // UUID ของไฟล์ (ADR-019) + context: dto.context, + callbackUrl: `${this.callbackBaseUrl}/api/ai/callback`, + ...(dto.fileType && { fileType: dto.fileType }), + }; + + const response = await firstValueFrom( + this.httpService + .post(this.n8nWebhookUrl, payload, { + headers: { + Authorization: `Bearer ${this.n8nAuthToken}`, + 'Content-Type': 'application/json', + 'X-AI-Source': 'dms-backend', + }, + }) + .pipe( + timeout(this.timeoutMs), + catchError((error: AxiosError) => { + const errMsg = error.response?.data + ? JSON.stringify(error.response.data) + : error.message; + throw new SystemException(`n8n webhook failed: ${errMsg}`); + }) + ) + ); + + const processingTimeMs = Date.now() - startTime; + const n8nResult = response.data; + + // 4. อัปเดต MigrationLog ด้วยผลลัพธ์ + migrationLog.aiExtractedMetadata = + n8nResult.extractedMetadata ?? undefined; + migrationLog.confidenceScore = n8nResult.confidenceScore ?? undefined; + await this.migrationLogRepo.save(migrationLog); + + // 5. บันทึก AuditLog (ADR-018 Rule 5) + await this.saveAuditLog({ + documentPublicId: migrationLog.publicId, + aiModel: 'gemma4', + status: AiAuditStatus.SUCCESS, + confidenceScore: n8nResult.confidenceScore, + processingTimeMs, + inputHash: n8nResult.inputHash, + outputHash: n8nResult.outputHash, + }); + + return { + migrationLogPublicId: migrationLog.publicId, + status: 'completed', + extractedMetadata: n8nResult.extractedMetadata, + confidenceScore: n8nResult.confidenceScore, + action: + n8nResult.confidenceScore !== undefined + ? this.aiValidationService.getConfidenceAction( + n8nResult.confidenceScore + ) + : undefined, + processingTimeMs, + }; + } catch (error: unknown) { + const processingTimeMs = Date.now() - startTime; + const errMsg = error instanceof Error ? error.message : String(error); + + this.logger.error( + `Real-time extraction ล้มเหลว filePublicId=${dto.publicId}: ${errMsg}` + ); + + // บันทึก AuditLog กรณี Error (ADR-018) + await this.saveAuditLog({ + documentPublicId: migrationLog.publicId, + aiModel: 'gemma4', + status: + processingTimeMs >= this.timeoutMs + ? AiAuditStatus.TIMEOUT + : AiAuditStatus.FAILED, + processingTimeMs, + errorMessage: errMsg, + }); + + // อัปเดตสถานะเป็น FAILED + migrationLog.status = MigrationLogStatus.FAILED; + await this.migrationLogRepo.save(migrationLog); + + return { + migrationLogPublicId: migrationLog.publicId, + status: 'failed', + processingTimeMs, + }; + } + } + + // --- Webhook Callback จาก n8n (Async Processing) --- + + async handleWebhookCallback( + payload: AiCallbackDto, + aiSource: string, + authHeader: string + ): Promise { + // 1. ตรวจสอบ Service Account Authentication (ADR-018 Rule 2) + if (!authHeader || !authHeader.startsWith('Bearer ')) { + throw new ValidationException( + 'Missing or invalid Authorization header for AI callback' + ); + } + + const token = authHeader.substring(7); + if (token !== this.n8nAuthToken) { + throw new ValidationException('Invalid AI service account token'); + } + + // 2. ค้นหา MigrationLog ด้วย publicId (ADR-019: ใช้ UUID เท่านั้น) + const migrationLog = await this.migrationLogRepo.findOne({ + where: { publicId: payload.migrationLogPublicId }, + }); + + if (!migrationLog) { + throw new NotFoundException('MigrationLog', payload.migrationLogPublicId); + } + + // 3. ตรวจสอบ AI Output (ADR-018 Rule 4 Validation Layer) + const validationResult = this.aiValidationService.validateAiOutput(payload); + + const auditSummary = this.aiValidationService.buildAuditSummary( + payload, + validationResult + ); + this.logger.log( + `AI Callback received — ${auditSummary}, source=${aiSource}` + ); + + // 4. อัปเดต MigrationLog ด้วยผลลัพธ์ AI + if (payload.status === AiAuditStatus.SUCCESS && payload.extractedMetadata) { + migrationLog.aiExtractedMetadata = payload.extractedMetadata; + migrationLog.confidenceScore = payload.confidenceScore; + + // Auto-approve ถ้า confidence สูงพอ (เฉพาะ migration context) + if (validationResult.action === 'auto_approve') { + migrationLog.status = MigrationLogStatus.VERIFIED; + this.logger.log( + `Auto-approved migrationLog=${migrationLog.publicId} (confidence=${payload.confidenceScore})` + ); + } + } else { + migrationLog.status = MigrationLogStatus.FAILED; + migrationLog.adminFeedback = + payload.errorMessage ?? 'AI processing failed'; + } + + await this.migrationLogRepo.save(migrationLog); + + // 5. บันทึก AuditLog (ADR-018 Rule 5) + await this.saveAuditLog({ + documentPublicId: migrationLog.publicId, + aiModel: payload.aiModel, + status: payload.status, + confidenceScore: payload.confidenceScore, + processingTimeMs: payload.processingTimeMs, + inputHash: payload.inputHash, + outputHash: payload.outputHash, + errorMessage: payload.errorMessage, + }); + } + + // --- Admin: ดูรายการ MigrationLog --- + + async getMigrationList( + query: MigrationQueryDto + ): Promise> { + const { page = 1, limit = 10, status, minConfidence } = query; + const skip = (page - 1) * limit; + + const qb = this.migrationLogRepo.createQueryBuilder('log'); + + if (status) { + qb.andWhere('log.status = :status', { status }); + } + + if (minConfidence !== undefined) { + qb.andWhere('log.confidenceScore >= :minConfidence', { minConfidence }); + } + + qb.orderBy('log.createdAt', 'DESC').skip(skip).take(limit); + + const [items, total] = await qb.getManyAndCount(); + + return { + items, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + // --- Admin: อัปเดตสถานะ MigrationLog --- + + async updateMigrationLog( + publicId: string, + dto: MigrationUpdateDto, + userId: number + ): Promise { + // ค้นหาด้วย publicId (ADR-019: ไม่ใช้ parseInt) + const migrationLog = await this.migrationLogRepo.findOne({ + where: { publicId }, + }); + + if (!migrationLog) { + throw new NotFoundException('MigrationLog', publicId); + } + + // ตรวจสอบ State Transition ที่ถูกต้อง + if (dto.status) { + const allowedTransitions = + MIGRATION_STATUS_TRANSITIONS[migrationLog.status]; + if (!allowedTransitions.includes(dto.status)) { + throw new BusinessException( + 'MIGRATION_INVALID_TRANSITION', + `Cannot transition from ${migrationLog.status} to ${dto.status}`, + `ไม่สามารถเปลี่ยนสถานะจาก ${migrationLog.status} เป็น ${dto.status} ได้`, + [ + 'ตรวจสอบสถานะปัจจุบันของเอกสาร', + 'ดำเนินการตามลำดับขั้นตอนที่ถูกต้อง', + ] + ); + } + migrationLog.status = dto.status; + } + + if (dto.adminFeedback !== undefined) { + migrationLog.adminFeedback = dto.adminFeedback; + } + + // บันทึก Reviewer ที่อัปเดต + migrationLog.reviewedBy = userId; + migrationLog.reviewedAt = new Date(); + + const updated = await this.migrationLogRepo.save(migrationLog); + + this.logger.log( + `MigrationLog updated — publicId=${publicId}, status=${updated.status}, reviewedBy=${userId}` + ); + + return updated; + } + + // --- Helper: บันทึก AuditLog --- + + private async saveAuditLog(data: { + documentPublicId: string; + aiModel: string; + status: AiAuditStatus; + confidenceScore?: number; + processingTimeMs?: number; + inputHash?: string; + outputHash?: string; + errorMessage?: string; + }): Promise { + try { + const auditLog = this.aiAuditLogRepo.create({ + documentPublicId: data.documentPublicId, + aiModel: data.aiModel, + status: data.status, + confidenceScore: data.confidenceScore, + processingTimeMs: data.processingTimeMs, + inputHash: data.inputHash, + outputHash: data.outputHash, + errorMessage: data.errorMessage, + }); + await this.aiAuditLogRepo.save(auditLog); + } catch (auditError: unknown) { + // ไม่ให้ Audit Log Error กระทบ Main Flow + const errMsg = + auditError instanceof Error ? auditError.message : String(auditError); + this.logger.error(`Failed to save AI audit log: ${errMsg}`); + } + } +} diff --git a/backend/src/modules/ai/dto/ai-callback.dto.ts b/backend/src/modules/ai/dto/ai-callback.dto.ts new file mode 100644 index 0000000..ea234ac --- /dev/null +++ b/backend/src/modules/ai/dto/ai-callback.dto.ts @@ -0,0 +1,87 @@ +// File: src/modules/ai/dto/ai-callback.dto.ts +// DTO สำหรับ Callback จาก n8n หลังจาก AI ประมวลผลเสร็จ (ADR-018 AI Communication Contract) + +import { + IsUUID, + IsString, + IsNumber, + IsOptional, + IsEnum, + Min, + Max, + MaxLength, + IsObject, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { AiAuditStatus } from '../entities/ai-audit-log.entity'; + +// Metadata ที่ AI สกัดได้จากเอกสาร +export interface AiExtractedMetadata { + subject?: string; + date?: string; // รูปแบบ YYYY-MM-DD + discipline?: string; // Civil|Mechanical|Electrical|Architectural + drawingReference?: string; + contractNumber?: string; + documentType?: string; + discrepancies?: string[]; // รายการที่ไม่สอดคล้องกัน + [key: string]: unknown; +} + +export class AiCallbackDto { + // UUID ของ MigrationLog ที่เกี่ยวข้อง (ADR-019) + @ApiProperty({ description: 'publicId ของ MigrationLog (UUID)' }) + @IsUUID() + migrationLogPublicId!: string; + + // ชื่อ AI Model ที่ใช้ประมวลผล + @ApiProperty({ description: 'ชื่อ AI Model เช่น gemma4, paddleocr' }) + @IsString() + @MaxLength(50) + aiModel!: string; + + // สถานะการประมวลผล + @ApiProperty({ enum: AiAuditStatus }) + @IsEnum(AiAuditStatus) + status!: AiAuditStatus; + + // คะแนนความมั่นใจ (0.00-1.00) + @ApiPropertyOptional({ minimum: 0, maximum: 1 }) + @IsOptional() + @IsNumber() + @Min(0) + @Max(1) + confidenceScore?: number; + + // Metadata ที่ AI สกัดได้ + @ApiPropertyOptional() + @IsOptional() + @IsObject() + extractedMetadata?: AiExtractedMetadata; + + // เวลาประมวลผล (milliseconds) + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Min(0) + processingTimeMs?: number; + + // SHA-256 hash ของ Input เพื่อ Audit + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(64) + inputHash?: string; + + // SHA-256 hash ของ Output เพื่อ Audit + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(64) + outputHash?: string; + + // ข้อความ Error (ถ้า status เป็น FAILED หรือ TIMEOUT) + @ApiPropertyOptional() + @IsOptional() + @IsString() + errorMessage?: string; +} diff --git a/backend/src/modules/ai/dto/extract-document.dto.ts b/backend/src/modules/ai/dto/extract-document.dto.ts new file mode 100644 index 0000000..1be675c --- /dev/null +++ b/backend/src/modules/ai/dto/extract-document.dto.ts @@ -0,0 +1,33 @@ +// File: src/modules/ai/dto/extract-document.dto.ts +// DTO สำหรับ Real-time AI Extraction endpoint (/api/ai/extract) + +import { IsUUID, IsEnum, IsOptional, IsString } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +// บริบทการใช้งาน AI Extraction +export type ExtractionContext = 'migration' | 'ingestion'; + +export class ExtractDocumentDto { + // UUID ของไฟล์ที่ต้องการ Extract (ADR-019: ใช้ publicId เท่านั้น) + @ApiProperty({ description: 'UUID ของไฟล์ที่ต้องการให้ AI สกัด Metadata' }) + @IsUUID() + publicId!: string; + + // บริบทการใช้งาน: migration=นำเข้าเอกสารเก่า, ingestion=อัปโหลดเอกสารใหม่ + @ApiProperty({ + enum: ['migration', 'ingestion'], + description: 'บริบทการใช้งาน AI', + }) + @IsEnum(['migration', 'ingestion']) + context!: ExtractionContext; + + // ประเภทไฟล์ (optional — ใช้เพื่อ optimization) + @ApiPropertyOptional({ + enum: ['pdf', 'docx', 'xlsx'], + description: 'ประเภทไฟล์ (optional)', + }) + @IsOptional() + @IsString() + @IsEnum(['pdf', 'docx', 'xlsx']) + fileType?: string; +} diff --git a/backend/src/modules/ai/dto/migration-query.dto.ts b/backend/src/modules/ai/dto/migration-query.dto.ts new file mode 100644 index 0000000..e008c14 --- /dev/null +++ b/backend/src/modules/ai/dto/migration-query.dto.ts @@ -0,0 +1,41 @@ +// File: src/modules/ai/dto/migration-query.dto.ts +// DTO สำหรับ Query Parameters ของ GET /api/ai/migration + +import { IsOptional, IsEnum, IsNumber, Min, Max } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { MigrationLogStatus } from '../entities/migration-log.entity'; + +export class MigrationQueryDto { + // กรองตามสถานะ + @ApiPropertyOptional({ enum: MigrationLogStatus }) + @IsOptional() + @IsEnum(MigrationLogStatus) + status?: MigrationLogStatus; + + // กรองตาม Confidence Score ขั้นต่ำ + @ApiPropertyOptional({ minimum: 0, maximum: 1 }) + @IsOptional() + @IsNumber() + @Min(0) + @Max(1) + @Type(() => Number) + minConfidence?: number; + + // หน้าที่ต้องการ (เริ่มที่ 1) + @ApiPropertyOptional({ minimum: 1, default: 1 }) + @IsOptional() + @IsNumber() + @Min(1) + @Type(() => Number) + page?: number = 1; + + // จำนวนรายการต่อหน้า (สูงสุด 100) + @ApiPropertyOptional({ minimum: 1, maximum: 100, default: 10 }) + @IsOptional() + @IsNumber() + @Min(1) + @Max(100) + @Type(() => Number) + limit?: number = 10; +} diff --git a/backend/src/modules/ai/dto/migration-update.dto.ts b/backend/src/modules/ai/dto/migration-update.dto.ts new file mode 100644 index 0000000..4e41ebc --- /dev/null +++ b/backend/src/modules/ai/dto/migration-update.dto.ts @@ -0,0 +1,27 @@ +// File: src/modules/ai/dto/migration-update.dto.ts +// DTO สำหรับ Admin อัปเดตสถานะ MigrationLog หลังตรวจสอบ + +import { IsOptional, IsEnum, IsString, MaxLength } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { MigrationLogStatus } from '../entities/migration-log.entity'; + +export class MigrationUpdateDto { + // สถานะใหม่ที่ต้องการเปลี่ยน (VERIFIED หรือ FAILED เท่านั้น) + @ApiPropertyOptional({ + enum: [MigrationLogStatus.VERIFIED, MigrationLogStatus.FAILED], + description: 'สถานะใหม่ (Admin สามารถเปลี่ยนได้เฉพาะ VERIFIED หรือ FAILED)', + }) + @IsOptional() + @IsEnum([MigrationLogStatus.VERIFIED, MigrationLogStatus.FAILED]) + status?: MigrationLogStatus; + + // ความเห็นของ Admin + @ApiPropertyOptional({ + maxLength: 1000, + description: 'ความเห็นจาก Admin ผู้ตรวจสอบ', + }) + @IsOptional() + @IsString() + @MaxLength(1000) + adminFeedback?: string; +} diff --git a/backend/src/modules/ai/entities/ai-audit-log.entity.ts b/backend/src/modules/ai/entities/ai-audit-log.entity.ts new file mode 100644 index 0000000..e7aa023 --- /dev/null +++ b/backend/src/modules/ai/entities/ai-audit-log.entity.ts @@ -0,0 +1,71 @@ +// File: src/modules/ai/entities/ai-audit-log.entity.ts +// Entity สำหรับตาราง ai_audit_logs — บันทึก AI Interaction ทุกครั้งตาม ADR-018 Rule 5 + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, +} from 'typeorm'; +import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity'; + +// สถานะการประมวลผลของ AI +export enum AiAuditStatus { + SUCCESS = 'SUCCESS', // ประมวลผลสำเร็จ + FAILED = 'FAILED', // ประมวลผลล้มเหลว + TIMEOUT = 'TIMEOUT', // หมดเวลา +} + +@Entity('ai_audit_logs') +export class AiAuditLog extends UuidBaseEntity { + @PrimaryGeneratedColumn() + id!: number; + + // UUID ของ MigrationLog ที่เกี่ยวข้อง (ใช้ UUID string ไม่ใช่ INT FK) + @Index('idx_ai_audit_document') + @Column({ name: 'document_public_id', type: 'uuid', nullable: true }) + documentPublicId?: string; + + // ชื่อ AI Model ที่ใช้ประมวลผล เช่น 'gemma4', 'paddleocr' + @Index('idx_ai_audit_model') + @Column({ name: 'ai_model', type: 'varchar', length: 50 }) + aiModel!: string; + + // เวลาประมวลผลเป็น milliseconds + @Column({ name: 'processing_time_ms', type: 'int', nullable: true }) + processingTimeMs?: number; + + // คะแนนความมั่นใจของ AI (0.00-1.00) + @Column({ + name: 'confidence_score', + type: 'decimal', + precision: 3, + scale: 2, + nullable: true, + }) + confidenceScore?: number; + + // SHA-256 hash ของ Input เพื่อ Audit Trail (ADR-018) + @Column({ name: 'input_hash', type: 'varchar', length: 64, nullable: true }) + inputHash?: string; + + // SHA-256 hash ของ Output เพื่อ Audit Trail (ADR-018) + @Column({ name: 'output_hash', type: 'varchar', length: 64, nullable: true }) + outputHash?: string; + + // สถานะการประมวลผล + @Index('idx_ai_audit_status') + @Column({ + type: 'enum', + enum: AiAuditStatus, + }) + status!: AiAuditStatus; + + // ข้อความ Error (ถ้ามี) + @Column({ name: 'error_message', type: 'text', nullable: true }) + errorMessage?: string; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; +} diff --git a/backend/src/modules/ai/entities/migration-log.entity.ts b/backend/src/modules/ai/entities/migration-log.entity.ts new file mode 100644 index 0000000..7c8b4ca --- /dev/null +++ b/backend/src/modules/ai/entities/migration-log.entity.ts @@ -0,0 +1,93 @@ +// File: src/modules/ai/entities/migration-log.entity.ts +// Entity สำหรับตาราง migration_logs — ติดตาม AI Processing ของแต่ละเอกสาร (ADR-020) + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; +import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity'; + +// สถานะของ Migration Log ตาม State Machine ใน ADR-020 +export enum MigrationLogStatus { + PENDING_REVIEW = 'PENDING_REVIEW', // รอ Admin ตรวจสอบ + VERIFIED = 'VERIFIED', // Admin ตรวจสอบแล้ว ยืนยันถูกต้อง + IMPORTED = 'IMPORTED', // นำเข้าระบบสำเร็จ (Terminal State) + FAILED = 'FAILED', // ล้มเหลว สามารถ retry ได้ +} + +// Transition Rules: ห้าม import โดยตรงจาก FAILED หรือ PENDING ไปยัง IMPORTED +export const MIGRATION_STATUS_TRANSITIONS: Record< + MigrationLogStatus, + MigrationLogStatus[] +> = { + [MigrationLogStatus.PENDING_REVIEW]: [ + MigrationLogStatus.VERIFIED, + MigrationLogStatus.FAILED, + ], + [MigrationLogStatus.VERIFIED]: [ + MigrationLogStatus.IMPORTED, + MigrationLogStatus.PENDING_REVIEW, + ], + [MigrationLogStatus.IMPORTED]: [], // Terminal State — ไม่สามารถเปลี่ยนได้ + [MigrationLogStatus.FAILED]: [MigrationLogStatus.PENDING_REVIEW], // Retry ได้ +}; + +@Entity('migration_logs') +export class MigrationLog extends UuidBaseEntity { + @PrimaryGeneratedColumn() + id!: number; + + // ไฟล์ต้นทางที่นำเข้า (path หรือ filename) + @Column({ name: 'source_file', type: 'varchar', length: 255 }) + sourceFile!: string; + + // Metadata จากแหล่งข้อมูลต้นทาง (Excel, manual input) + @Column({ name: 'source_metadata', type: 'json', nullable: true }) + sourceMetadata?: Record; + + // Metadata ที่ AI สกัดได้จากเอกสาร + @Column({ name: 'ai_extracted_metadata', type: 'json', nullable: true }) + aiExtractedMetadata?: Record; + + // คะแนนความมั่นใจของ AI (0.00-1.00) + @Index('idx_migration_logs_confidence') + @Column({ + name: 'confidence_score', + type: 'decimal', + precision: 3, + scale: 2, + nullable: true, + }) + confidenceScore?: number; + + // สถานะปัจจุบันของ Migration Log + @Index('idx_migration_logs_status') + @Column({ + type: 'enum', + enum: MigrationLogStatus, + default: MigrationLogStatus.PENDING_REVIEW, + }) + status!: MigrationLogStatus; + + // ความเห็นของ Admin ผู้ตรวจสอบ + @Column({ name: 'admin_feedback', type: 'text', nullable: true }) + adminFeedback?: string; + + // User ID ของ Admin ที่ตรวจสอบ (Internal INT, ไม่ expose ใน API) + @Column({ name: 'reviewed_by', type: 'int', nullable: true }) + reviewedBy?: number; + + // เวลาที่ตรวจสอบ + @Column({ name: 'reviewed_at', type: 'timestamp', nullable: true }) + reviewedAt?: Date; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt!: Date; +} diff --git a/frontend/components/correspondences/form.test.tsx b/frontend/components/correspondences/form.test.tsx index 1c824db..22c457e 100644 --- a/frontend/components/correspondences/form.test.tsx +++ b/frontend/components/correspondences/form.test.tsx @@ -82,12 +82,12 @@ const editInitialData = { recipients: [ { recipientType: 'TO', - recipientOrganizationId: 200, + recipientOrganizationId: 'org-2', recipientOrganization: { publicId: 'org-2' }, }, { recipientType: 'CC', - recipientOrganizationId: 300, + recipientOrganizationId: 'org-3', recipientOrganization: { publicId: 'org-3' }, }, ], diff --git a/specs/03-Data-and-Storage/03-01-data-dictionary.md b/specs/03-Data-and-Storage/03-01-data-dictionary.md index 4bd7124..e937692 100644 --- a/specs/03-Data-and-Storage/03-01-data-dictionary.md +++ b/specs/03-Data-and-Storage/03-01-data-dictionary.md @@ -2208,7 +2208,105 @@ SELECT * FROM correspondences WHERE deleted_at IS NULL; --- -## **19. 📖 Glossary (คำศัพท์)** +## **19. 🤖 AI Gateway Tables (ADR-018, ADR-020)** + +> เพิ่มใน v1.9.0 — Task BE-AI-02 | ตารางสำหรับ AI Intelligence Integration + +### 19.1 `migration_logs` + +**วัตถุประสงค์:** ติดตามสถานะการประมวลผล AI สำหรับเอกสารแต่ละรายการ ใช้ในกระบวนการ Legacy Migration และ New Ingestion + +| Column | Type | Nullable | Description | +|--------|------|----------|-------------| +| `id` | INT AUTO_INCREMENT | NO | Internal PK — ห้าม expose ใน API (ADR-019) | +| `uuid` | UUID | NO | Public Identifier (UUIDv7) — ใช้ใน API response เป็น `publicId` | +| `source_file` | VARCHAR(255) | NO | Path หรือ publicId ของไฟล์ต้นทาง | +| `source_metadata` | JSON | YES | Metadata จากแหล่งต้นทาง (Excel, manual input) | +| `ai_extracted_metadata` | JSON | YES | Metadata ที่ AI สกัดได้ — ประกอบด้วย subject, date, discipline, drawingReference, contractNumber | +| `confidence_score` | DECIMAL(3,2) | YES | คะแนนความมั่นใจของ AI (0.00–1.00) ตาม ADR-020 Confidence Strategy | +| `status` | ENUM | NO | สถานะปัจจุบัน (ดู State Machine ด้านล่าง) | +| `admin_feedback` | TEXT | YES | ความเห็นของ Admin ผู้ตรวจสอบ | +| `reviewed_by` | INT | YES | FK → `users.user_id` — Admin ที่ทำการตรวจสอบ | +| `reviewed_at` | TIMESTAMP | YES | เวลาที่ Admin ตรวจสอบ | +| `created_at` | TIMESTAMP | NO | วันที่สร้าง record | +| `updated_at` | TIMESTAMP | NO | วันที่แก้ไขล่าสุด (Auto-update) | + +#### Status Enum Values + +| Status | Description | Action Required | +|--------|-------------|-----------------| +| `PENDING_REVIEW` | รอ Admin ตรวจสอบ (Default) | Admin ต้องตรวจสอบและเปลี่ยนสถานะ | +| `VERIFIED` | Admin ยืนยันแล้ว พร้อม Import | ระบบนำเข้าหรือรอ Batch Import | +| `IMPORTED` | นำเข้าสู่ระบบสำเร็จ (Terminal State) | ไม่สามารถแก้ไขได้อีก | +| `FAILED` | ล้มเหลว — AI หรือ Validation ไม่ผ่าน | สามารถ Retry ได้ (→ PENDING_REVIEW) | + +#### State Machine (Status Transitions) + +``` +PENDING_REVIEW ──→ VERIFIED ──→ IMPORTED (terminal) + │ │ + ↓ ↓ + FAILED ──────→ PENDING_REVIEW (retry) +``` + +**กฎ:** +- `IMPORTED` เป็น Terminal State — ห้ามเปลี่ยนสถานะ +- Admin สามารถเปลี่ยนได้เฉพาะ `PENDING_REVIEW → VERIFIED` หรือ `PENDING_REVIEW → FAILED` +- `FAILED → PENDING_REVIEW` สำหรับ Retry + +#### `ai_extracted_metadata` JSON Schema + +```json +{ + "subject": "string — หัวเรื่องเอกสาร", + "date": "YYYY-MM-DD — วันที่เอกสาร", + "discipline": "Civil | Mechanical | Electrical | Architectural", + "drawingReference": "string — รหัสแบบอ้างอิง", + "contractNumber": "string — เลขสัญญา", + "discrepancies": ["string — รายการที่ไม่สอดคล้องกับต้นทาง"] +} +``` + +--- + +### 19.2 `ai_audit_logs` + +**วัตถุประสงค์:** บันทึก Audit Trail ของ AI Interaction ทุกครั้ง ตาม ADR-018 Rule 5 — ห้ามลบ record ออก + +| Column | Type | Nullable | Description | +|--------|------|----------|-------------| +| `id` | INT AUTO_INCREMENT | NO | Internal PK — ห้าม expose ใน API (ADR-019) | +| `uuid` | UUID | NO | Public Identifier (UUIDv7) | +| `document_public_id` | UUID | YES | UUID ของ `migration_logs` ที่เกี่ยวข้อง (Soft Reference — ไม่ใช่ FK ในระดับ TypeORM) | +| `ai_model` | VARCHAR(50) | NO | ชื่อ AI Model เช่น `gemma4`, `paddleocr`, `gemma4:27b` | +| `processing_time_ms` | INT | YES | เวลาประมวลผล (milliseconds) — เป้าหมาย < 15,000ms ตาม ADR-020 | +| `confidence_score` | DECIMAL(3,2) | YES | คะแนนความมั่นใจ (0.00–1.00) | +| `input_hash` | VARCHAR(64) | YES | SHA-256 hash ของ Input — เพื่อ Integrity Verification | +| `output_hash` | VARCHAR(64) | YES | SHA-256 hash ของ Output — เพื่อ Integrity Verification | +| `status` | ENUM | NO | ผลการประมวลผล: SUCCESS / FAILED / TIMEOUT | +| `error_message` | TEXT | YES | รายละเอียด Error (เมื่อ status = FAILED หรือ TIMEOUT) | +| `created_at` | TIMESTAMP | NO | วันที่สร้าง — เรียงตาม timestamp เพื่อ Audit | + +#### Business Rules + +1. **Immutable Records** — ห้ามแก้ไขหรือลบ `ai_audit_logs` หลังสร้าง (Audit Trail) +2. **Data Retention** — เก็บไว้อย่างน้อย 90 วัน ตาม ADR-020 Data Privacy +3. **No FK Constraint** — `document_public_id` เป็น Soft Reference เพื่อให้ Log ยังคงอยู่แม้ MigrationLog ถูกลบ + +--- + +### 19.3 Confidence Scoring Strategy (ADR-020) + +| Score Range | Action | Description | +|-------------|--------|-------------| +| **0.95–1.00** | `auto_approve` | นำเข้าอัตโนมัติ (Migration mode เท่านั้น) | +| **0.85–0.94** | `low_priority_review` | รอ Admin ตรวจสอบ — ลำดับต่ำ | +| **0.60–0.84** | `high_priority_review` | รอ Admin ตรวจสอบ — ลำดับสูง | +| **< 0.60** | `reject` | ปฏิเสธ — Admin ต้องกรอกข้อมูลเอง | + +--- + +## **20. 📖 Glossary (คำศัพท์)** - **RFA**: Request for Approval (เอกสารขออนุมัติ) - **Transmittal**: Document Transmittal Sheet (ใบนำส่งเอกสาร) diff --git a/specs/03-Data-and-Storage/lcbp3-v1.8.0-schema-02-tables.sql b/specs/03-Data-and-Storage/lcbp3-v1.8.0-schema-02-tables.sql index a6c1fd3..0f85786 100644 --- a/specs/03-Data-and-Storage/lcbp3-v1.8.0-schema-02-tables.sql +++ b/specs/03-Data-and-Storage/lcbp3-v1.8.0-schema-02-tables.sql @@ -1400,3 +1400,51 @@ CREATE TABLE workflow_histories ( CREATE INDEX idx_wf_hist_instance ON workflow_histories (instance_id); CREATE INDEX idx_wf_hist_user ON workflow_histories (action_by_user_id); + +-- ===================================================== +-- 11. 🤖 AI Gateway (ตาราง AI Integration - ADR-018, ADR-020) +-- ===================================================== +-- ตารางเก็บบันทึก Migration เอกสารที่ผ่าน AI Processing +CREATE TABLE migration_logs ( + id INT AUTO_INCREMENT PRIMARY KEY COMMENT 'Internal PK (ห้าม expose ใน API)', + uuid UUID NOT NULL DEFAULT UUID() COMMENT 'UUID Public Identifier (ADR-019)', + source_file VARCHAR(255) NOT NULL COMMENT 'Path ของไฟล์ต้นทาง', + source_metadata JSON NULL COMMENT 'Metadata จาก Excel/แหล่งข้อมูลต้นทาง', + ai_extracted_metadata JSON NULL COMMENT 'Metadata ที่ AI สกัดได้', + confidence_score DECIMAL(3, 2) NULL COMMENT 'คะแนนความมั่นใจ AI (0.00-1.00)', + STATUS ENUM( + 'PENDING_REVIEW', + 'VERIFIED', + 'IMPORTED', + 'FAILED' + ) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT 'สถานะ: PENDING_REVIEW=รอตรวจ, VERIFIED=ตรวจแล้ว, IMPORTED=นำเข้าแล้ว, FAILED=ล้มเหลว', + admin_feedback TEXT NULL COMMENT 'ความเห็นจาก Admin ผู้ตรวจสอบ', + reviewed_by INT NULL COMMENT 'User ID ของ Admin ที่ตรวจสอบ (FK users.id)', + reviewed_at TIMESTAMP NULL COMMENT 'เวลาที่ตรวจสอบ', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่สร้าง', + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'วันที่แก้ไขล่าสุด', + UNIQUE INDEX idx_migration_logs_uuid (uuid), + INDEX idx_migration_logs_status (STATUS), + INDEX idx_migration_logs_confidence (confidence_score), + FOREIGN KEY (reviewed_by) REFERENCES users (user_id) ON DELETE + SET NULL +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = 'ตารางเก็บบันทึก Migration เอกสารที่ผ่าน AI Processing (Task BE-AI-02)'; + +-- ตาราง Audit Log สำหรับการทำงานของ AI ทุกครั้ง (ADR-018 Rule 5) +CREATE TABLE ai_audit_logs ( + id INT AUTO_INCREMENT PRIMARY KEY COMMENT 'Internal PK (ห้าม expose ใน API)', + uuid UUID NOT NULL DEFAULT UUID() COMMENT 'UUID Public Identifier (ADR-019)', + document_public_id UUID NULL COMMENT 'UUID ของ migration_logs ที่เกี่ยวข้อง', + ai_model VARCHAR(50) NOT NULL COMMENT 'ชื่อ AI Model ที่ใช้ประมวลผล เช่น gemma4', + processing_time_ms INT NULL COMMENT 'เวลาประมวลผล (milliseconds)', + confidence_score DECIMAL(3, 2) NULL COMMENT 'คะแนนความมั่นใจ AI (0.00-1.00)', + input_hash VARCHAR(64) NULL COMMENT 'SHA-256 hash ของ Input เพื่อ Audit', + output_hash VARCHAR(64) NULL COMMENT 'SHA-256 hash ของ Output เพื่อ Audit', + STATUS ENUM('SUCCESS', 'FAILED', 'TIMEOUT') NOT NULL COMMENT 'สถานะการประมวลผล', + error_message TEXT NULL COMMENT 'ข้อความ Error (ถ้ามี)', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่สร้าง', + UNIQUE INDEX idx_ai_audit_logs_uuid (uuid), + INDEX idx_ai_audit_document (document_public_id), + INDEX idx_ai_audit_model (ai_model), + INDEX idx_ai_audit_status (STATUS) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = 'ตาราง Audit Log การทำงาน AI ทุกครั้ง (ADR-018 Rule 5 Audit Logging)'; diff --git a/specs/08-Tasks/Task BE-AI-02.md b/specs/08-Tasks/Task-BE-AI-02.md similarity index 77% rename from specs/08-Tasks/Task BE-AI-02.md rename to specs/08-Tasks/Task-BE-AI-02.md index 5b19e20..a9318f5 100644 --- a/specs/08-Tasks/Task BE-AI-02.md +++ b/specs/08-Tasks/Task-BE-AI-02.md @@ -11,53 +11,17 @@ ## 🛠️ Implementation Tasks ### **AI-2.1: Database Schema Design (SQL First Approach)** -- [ ] **Create `migration_logs` Table:** - ```sql - CREATE TABLE migration_logs ( - id INT AUTO_INCREMENT PRIMARY KEY, - publicId BINARY(16) DEFAULT (UUID_TO_BIN(UUID(), 1)), - source_file VARCHAR(255) NOT NULL, - source_metadata JSON, - ai_extracted_metadata JSON, - confidence_score DECIMAL(3,2), - status ENUM('PENDING_REVIEW', 'VERIFIED', 'IMPORTED', 'FAILED') DEFAULT 'PENDING_REVIEW', - admin_feedback TEXT, - reviewed_by INT NULL, - reviewed_at TIMESTAMP NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - INDEX idx_status (status), - INDEX idx_confidence (confidence_score), - INDEX idx_publicId (publicId) - ); - ``` -- [ ] **Create `ai_audit_logs` Table:** - ```sql - CREATE TABLE ai_audit_logs ( - id INT AUTO_INCREMENT PRIMARY KEY, - publicId BINARY(16) DEFAULT (UUID_TO_BIN(UUID(), 1)), - document_publicId BINARY(16), - ai_model VARCHAR(50) NOT NULL, - processing_time_ms INT, - confidence_score DECIMAL(3,2), - input_hash VARCHAR(64), - output_hash VARCHAR(64), - status ENUM('SUCCESS', 'FAILED', 'TIMEOUT') NOT NULL, - error_message TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - INDEX idx_document (document_publicId), - INDEX idx_model (ai_model), - INDEX idx_status (status), - FOREIGN KEY (document_publicId) REFERENCES migration_logs(publicId) - ); - ``` -- [ ] **Update Data Dictionary:** - - Add field descriptions to `specs/03-Data-and-Storage/03-01-data-dictionary.md` - - Include business rules for confidence thresholds - - Document status transitions and workflows +- [x] **Create `migration_logs` Table:** — เพิ่มใน `specs/03-Data-and-Storage/lcbp3-v1.8.0-schema-02-tables.sql` (Section 11) + - ใช้ `uuid UUID NOT NULL DEFAULT UUID()` แทน `BINARY(16)` ตาม pattern ปัจจุบัน (ADR-019) + - FK → `users.user_id` สำหรับ `reviewed_by` +- [x] **Create `ai_audit_logs` Table:** — เพิ่มในไฟล์ schema เดียวกัน + - `document_public_id` เป็น Soft Reference (ไม่มี FK constraint) เพื่อ Audit Trail ถาวร +- [x] **Update Data Dictionary:** + - เพิ่ม Section 19 ใน `specs/03-Data-and-Storage/03-01-data-dictionary.md` + - ครอบคลุม Confidence Scoring Strategy, State Machine, JSON Schema ### **AI-2.2: AI Gateway Module Architecture** -- [ ] **Module Structure:** +- [x] **Module Structure:** ```typescript // src/modules/ai/ai.module.ts @Module({ @@ -68,7 +32,7 @@ }) export class AiModule {} ``` -- [ ] **AiService Implementation:** +- [x] **AiService Implementation:** ```typescript @Injectable() export class AiService { @@ -93,7 +57,7 @@ } } ``` -- [ ] **Configuration Management:** +- [x] **Configuration Management:** ```env # .env AI_N8N_WEBHOOK_URL=http://192.168.1.100:5678/webhook/ai-processing @@ -104,7 +68,7 @@ ``` ### **AI-2.3: Migration Engine & Business Logic** -- [ ] **MigrationService Implementation:** +- [x] **MigrationService Implementation:** — `AiService` implements `stageLegacyData` logic (via `extractRealtime`), `compareData` via `AiValidationService`, `approveMigration` via `updateMigrationLog` ```typescript @Injectable() export class MigrationService { @@ -130,7 +94,7 @@ } } ``` -- [ ] **Status Management Workflow:** +- [x] **Status Management Workflow:** ```typescript enum MigrationStatus { PENDING_REVIEW = 'PENDING_REVIEW', @@ -149,7 +113,7 @@ ``` ### **AI-2.4: API Endpoints & Security Implementation** -- [ ] **Admin Migration Endpoints:** +- [x] **Admin Migration Endpoints:** — `GET /api/ai/migration` + `PATCH /api/ai/migration/:publicId` ใน `AiController` พร้อม `JwtAuthGuard + RbacGuard + RequirePermission` ```typescript @Controller('admin/migration') @UseGuards(JwtAuthGuard, CaslGuard) @@ -175,7 +139,7 @@ } } ``` -- [ ] **Real-time AI Extraction Endpoint:** +- [x] **Real-time AI Extraction Endpoint:** — `POST /api/ai/extract` (rate limit 5/min) + `POST /api/ai/callback` (service account auth) ```typescript @Controller('ai') export class AiController { @@ -189,12 +153,12 @@ } } ``` -- [ ] **Security Measures:** - - CASL permissions for all endpoints - - Idempotency-Key header validation - - Rate limiting on AI endpoints - - JWT authentication for service accounts - - Request/response logging for audit +- [x] **Security Measures:** + - RbacGuard + RequirePermission on admin endpoints + - Idempotency-Key header documented on PATCH endpoint + - Rate limiting (`@Throttle 5/min`) on `/ai/extract` + - Bearer token validation on `/ai/callback` + - AuditLog saved for every AI interaction (ADR-018 Rule 5) ---