690409:0953 Done Task-BE-AI-02
CI / CD Pipeline / build (push) Successful in 4m30s
CI / CD Pipeline / deploy (push) Successful in 1m6s

This commit is contained in:
2026-04-09 09:53:57 +07:00
parent 4f34aeae6b
commit 99c8d61856
18 changed files with 1791 additions and 60 deletions
+2
View File
@@ -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(', ');
}
}
+153
View File
@@ -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);
}
}
+42
View File
@@ -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 {}
+317
View File
@@ -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');
});
});
});
+403
View File
@@ -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.001.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.951.00** | `auto_approve` | นำเข้าอัตโนมัติ (Migration mode เท่านั้น) |
| **0.850.94** | `low_priority_review` | รอ Admin ตรวจสอบ — ลำดับต่ำ |
| **0.600.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)
--- ---