690409:0953 Done Task-BE-AI-02
This commit is contained in:
@@ -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: [
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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>(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> = {}
|
||||
): 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(', ');
|
||||
}
|
||||
}
|
||||
@@ -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<ExtractionResult> {
|
||||
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<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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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<string, string | number> = {
|
||||
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>(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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
confidenceScore?: number;
|
||||
action?: string;
|
||||
processingTimeMs?: number;
|
||||
}
|
||||
|
||||
// ผลลัพธ์ของ Paginated List
|
||||
export interface PaginatedResult<T> {
|
||||
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<string, unknown>;
|
||||
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<MigrationLog>,
|
||||
@InjectRepository(AiAuditLog)
|
||||
private readonly aiAuditLogRepo: Repository<AiAuditLog>
|
||||
) {
|
||||
this.n8nWebhookUrl =
|
||||
this.configService.get<string>('AI_N8N_WEBHOOK_URL') ?? '';
|
||||
this.n8nAuthToken =
|
||||
this.configService.get<string>('AI_N8N_AUTH_TOKEN') ?? '';
|
||||
this.timeoutMs = this.configService.get<number>('AI_TIMEOUT_MS') ?? 30000;
|
||||
this.callbackBaseUrl =
|
||||
this.configService.get<string>('APP_BASE_URL') ?? 'http://localhost:3001';
|
||||
}
|
||||
|
||||
// --- Real-time Extraction (สำหรับ User Upload ใหม่) ---
|
||||
|
||||
async extractRealtime(
|
||||
dto: ExtractDocumentDto,
|
||||
_userId: number
|
||||
): Promise<ExtractionResult> {
|
||||
// 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<N8nWebhookResponse>(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<void> {
|
||||
// 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<PaginatedResult<MigrationLog>> {
|
||||
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<MigrationLog> {
|
||||
// ค้นหาด้วย 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<void> {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
// Metadata ที่ AI สกัดได้จากเอกสาร
|
||||
@Column({ name: 'ai_extracted_metadata', type: 'json', nullable: true })
|
||||
aiExtractedMetadata?: Record<string, unknown>;
|
||||
|
||||
// คะแนนความมั่นใจของ 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;
|
||||
}
|
||||
Reference in New Issue
Block a user