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 { SearchModule } from './modules/search/search.module';
|
||||||
import { AuditLogModule } from './modules/audit-log/audit-log.module';
|
import { AuditLogModule } from './modules/audit-log/audit-log.module';
|
||||||
import { MigrationModule } from './modules/migration/migration.module';
|
import { MigrationModule } from './modules/migration/migration.module';
|
||||||
|
import { AiModule } from './modules/ai/ai.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -187,6 +188,7 @@ import { MigrationModule } from './modules/migration/migration.module';
|
|||||||
DashboardModule,
|
DashboardModule,
|
||||||
AuditLogModule,
|
AuditLogModule,
|
||||||
MigrationModule,
|
MigrationModule,
|
||||||
|
AiModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -28,4 +28,18 @@ export const envValidationSchema = Joi.object({
|
|||||||
REDIS_HOST: Joi.string().required(),
|
REDIS_HOST: Joi.string().required(),
|
||||||
REDIS_PORT: Joi.number().default(6379),
|
REDIS_PORT: Joi.number().default(6379),
|
||||||
REDIS_PASSWORD: Joi.string().required(),
|
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;
|
||||||
|
}
|
||||||
@@ -82,12 +82,12 @@ const editInitialData = {
|
|||||||
recipients: [
|
recipients: [
|
||||||
{
|
{
|
||||||
recipientType: 'TO',
|
recipientType: 'TO',
|
||||||
recipientOrganizationId: 200,
|
recipientOrganizationId: 'org-2',
|
||||||
recipientOrganization: { publicId: 'org-2' },
|
recipientOrganization: { publicId: 'org-2' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
recipientType: 'CC',
|
recipientType: 'CC',
|
||||||
recipientOrganizationId: 300,
|
recipientOrganizationId: 'org-3',
|
||||||
recipientOrganization: { publicId: 'org-3' },
|
recipientOrganization: { publicId: 'org-3' },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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 (เอกสารขออนุมัติ)
|
- **RFA**: Request for Approval (เอกสารขออนุมัติ)
|
||||||
- **Transmittal**: Document Transmittal Sheet (ใบนำส่งเอกสาร)
|
- **Transmittal**: Document Transmittal Sheet (ใบนำส่งเอกสาร)
|
||||||
|
|||||||
@@ -1400,3 +1400,51 @@ CREATE TABLE workflow_histories (
|
|||||||
CREATE INDEX idx_wf_hist_instance ON workflow_histories (instance_id);
|
CREATE INDEX idx_wf_hist_instance ON workflow_histories (instance_id);
|
||||||
|
|
||||||
CREATE INDEX idx_wf_hist_user ON workflow_histories (action_by_user_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)';
|
||||||
|
|||||||
@@ -11,53 +11,17 @@
|
|||||||
## 🛠️ Implementation Tasks
|
## 🛠️ Implementation Tasks
|
||||||
|
|
||||||
### **AI-2.1: Database Schema Design (SQL First Approach)**
|
### **AI-2.1: Database Schema Design (SQL First Approach)**
|
||||||
- [ ] **Create `migration_logs` Table:**
|
- [x] **Create `migration_logs` Table:** — เพิ่มใน `specs/03-Data-and-Storage/lcbp3-v1.8.0-schema-02-tables.sql` (Section 11)
|
||||||
```sql
|
- ใช้ `uuid UUID NOT NULL DEFAULT UUID()` แทน `BINARY(16)` ตาม pattern ปัจจุบัน (ADR-019)
|
||||||
CREATE TABLE migration_logs (
|
- FK → `users.user_id` สำหรับ `reviewed_by`
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
- [x] **Create `ai_audit_logs` Table:** — เพิ่มในไฟล์ schema เดียวกัน
|
||||||
publicId BINARY(16) DEFAULT (UUID_TO_BIN(UUID(), 1)),
|
- `document_public_id` เป็น Soft Reference (ไม่มี FK constraint) เพื่อ Audit Trail ถาวร
|
||||||
source_file VARCHAR(255) NOT NULL,
|
- [x] **Update Data Dictionary:**
|
||||||
source_metadata JSON,
|
- เพิ่ม Section 19 ใน `specs/03-Data-and-Storage/03-01-data-dictionary.md`
|
||||||
ai_extracted_metadata JSON,
|
- ครอบคลุม Confidence Scoring Strategy, State Machine, JSON Schema
|
||||||
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
|
|
||||||
|
|
||||||
### **AI-2.2: AI Gateway Module Architecture**
|
### **AI-2.2: AI Gateway Module Architecture**
|
||||||
- [ ] **Module Structure:**
|
- [x] **Module Structure:**
|
||||||
```typescript
|
```typescript
|
||||||
// src/modules/ai/ai.module.ts
|
// src/modules/ai/ai.module.ts
|
||||||
@Module({
|
@Module({
|
||||||
@@ -68,7 +32,7 @@
|
|||||||
})
|
})
|
||||||
export class AiModule {}
|
export class AiModule {}
|
||||||
```
|
```
|
||||||
- [ ] **AiService Implementation:**
|
- [x] **AiService Implementation:**
|
||||||
```typescript
|
```typescript
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AiService {
|
export class AiService {
|
||||||
@@ -93,7 +57,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
- [ ] **Configuration Management:**
|
- [x] **Configuration Management:**
|
||||||
```env
|
```env
|
||||||
# .env
|
# .env
|
||||||
AI_N8N_WEBHOOK_URL=http://192.168.1.100:5678/webhook/ai-processing
|
AI_N8N_WEBHOOK_URL=http://192.168.1.100:5678/webhook/ai-processing
|
||||||
@@ -104,7 +68,7 @@
|
|||||||
```
|
```
|
||||||
|
|
||||||
### **AI-2.3: Migration Engine & Business Logic**
|
### **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
|
```typescript
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MigrationService {
|
export class MigrationService {
|
||||||
@@ -130,7 +94,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
- [ ] **Status Management Workflow:**
|
- [x] **Status Management Workflow:**
|
||||||
```typescript
|
```typescript
|
||||||
enum MigrationStatus {
|
enum MigrationStatus {
|
||||||
PENDING_REVIEW = 'PENDING_REVIEW',
|
PENDING_REVIEW = 'PENDING_REVIEW',
|
||||||
@@ -149,7 +113,7 @@
|
|||||||
```
|
```
|
||||||
|
|
||||||
### **AI-2.4: API Endpoints & Security Implementation**
|
### **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
|
```typescript
|
||||||
@Controller('admin/migration')
|
@Controller('admin/migration')
|
||||||
@UseGuards(JwtAuthGuard, CaslGuard)
|
@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
|
```typescript
|
||||||
@Controller('ai')
|
@Controller('ai')
|
||||||
export class AiController {
|
export class AiController {
|
||||||
@@ -189,12 +153,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
- [ ] **Security Measures:**
|
- [x] **Security Measures:**
|
||||||
- CASL permissions for all endpoints
|
- RbacGuard + RequirePermission on admin endpoints
|
||||||
- Idempotency-Key header validation
|
- Idempotency-Key header documented on PATCH endpoint
|
||||||
- Rate limiting on AI endpoints
|
- Rate limiting (`@Throttle 5/min`) on `/ai/extract`
|
||||||
- JWT authentication for service accounts
|
- Bearer token validation on `/ai/callback`
|
||||||
- Request/response logging for audit
|
- AuditLog saved for every AI interaction (ADR-018 Rule 5)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Reference in New Issue
Block a user