690525:1320 ADR-028-228-migration #06
CI / CD Pipeline / build (push) Successful in 4m18s
CI / CD Pipeline / deploy (push) Successful in 7m41s

This commit is contained in:
2026-05-25 13:20:17 +07:00
parent dcd1a9855e
commit 001237ea35
18 changed files with 967 additions and 128 deletions
@@ -46,6 +46,13 @@ describe('FileStorageService', () => {
find: jest.fn(),
findOne: jest.fn(),
remove: jest.fn(),
createQueryBuilder: jest.fn(() => ({
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
getOne: jest.fn().mockResolvedValue(null),
})),
},
},
{
@@ -56,19 +56,36 @@ export class FileStorageService {
/**
* Phase 1: Upload (บันทึกไฟล์ลง Temp)
* Idempotent: ถ้าไฟล์ checksum เดิมยังอยู่ใน temp (ไม่หมดอายุ) จะคืน record เดิมแทน
* ป้องกัน orphan files จาก n8n retry
*/
async upload(file: Express.Multer.File, userId: number): Promise<Attachment> {
const tempId = uuidv4();
// Fix: แปลงชื่อไฟล์จาก Latin1 → UTF-8 (Multer/busboy decodes as Latin1 by default)
const originalFilename = this.fixMulterFilename(file.originalname);
const fileExt = path.extname(originalFilename);
const storedFilename = `${uuidv4()}${fileExt}`;
const tempPath = path.join(this.tempDir, storedFilename);
// 1. คำนวณ Checksum (SHA-256) เพื่อความปลอดภัยและความถูกต้องของไฟล์
const checksum = this.calculateChecksum(file.buffer);
// 2. บันทึกไฟล์ลง Disk (Temp Folder)
// 2. Checksum-based dedup — ถ้า temp attachment เดิมยังไม่หมดอายุ คืน record เดิมทันที
const existing = await this.attachmentRepository
.createQueryBuilder('a')
.where('a.checksum = :checksum', { checksum })
.andWhere('a.isTemporary = :isTemp', { isTemp: true })
.andWhere('a.uploadedByUserId = :userId', { userId })
.andWhere('a.expiresAt > :now', { now: new Date() })
.getOne();
if (existing) {
this.logger.log(
`Dedup upload: returning existing temp attachment publicId=${existing.publicId} checksum=${checksum}`
);
return existing;
}
const fileExt = path.extname(originalFilename);
const storedFilename = `${uuidv4()}${fileExt}`;
const tempPath = path.join(this.tempDir, storedFilename);
// 3. บันทึกไฟล์ลง Disk (Temp Folder)
try {
await fs.writeFile(tempPath, file.buffer);
} catch (error) {
@@ -76,7 +93,7 @@ export class FileStorageService {
throw new BadRequestException('File upload failed');
}
// 3. สร้าง Record ใน Database
// 4. สร้าง Record ใน Database
const attachment = this.attachmentRepository.create({
originalFilename,
storedFilename: storedFilename,
@@ -84,7 +101,7 @@ export class FileStorageService {
mimeType: file.mimetype,
fileSize: file.size,
isTemporary: true,
tempId: tempId,
tempId: uuidv4(),
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // หมดอายุใน 24 ชม.
checksum: checksum,
uploadedByUserId: userId,
@@ -6,6 +6,7 @@ import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { AiSettingsService } from './ai-settings.service';
import { SystemSetting } from './entities/system-setting.entity';
import { AiAvailableModel } from './entities/ai-available-model.entity';
const DEFAULT_REDIS_TOKEN = 'default_IORedisModuleConnectionToken';
@@ -18,6 +19,15 @@ describe('AiSettingsService', () => {
},
};
const mockAiModelRepo = {
find: jest.fn(),
findOne: jest.fn(),
save: jest.fn(),
create: jest.fn(),
update: jest.fn(),
softDelete: jest.fn(),
};
const mockRedis = {
get: jest.fn(),
set: jest.fn(),
@@ -35,6 +45,10 @@ describe('AiSettingsService', () => {
provide: getRepositoryToken(SystemSetting),
useValue: mockSettingRepo,
},
{
provide: getRepositoryToken(AiAvailableModel),
useValue: mockAiModelRepo,
},
{ provide: DEFAULT_REDIS_TOKEN, useValue: mockRedis },
],
}).compile();
@@ -2,6 +2,7 @@
// Change Log
// - 2026-05-21: เพิ่ม service สำหรับอ่าน/เขียน AI feature toggle พร้อม Redis cache.
// - 2026-05-22: เพิ่ม try-catch ใน getAiFeaturesEnabled() เพื่อความยืดหยุ่นในกรณีที่ฐานข้อมูลยังไม่ได้อัปเกรดตาราง system_settings
// - 2026-05-25: เพิ่ม methods สำหรับจัดการรายการโมเดล AI แบบไดนามิก (ADR-027)
import { Injectable, Logger } from '@nestjs/common';
import { InjectRedis } from '@nestjs-modules/ioredis';
@@ -9,11 +10,17 @@ import { InjectRepository } from '@nestjs/typeorm';
import type Redis from 'ioredis';
import { EntityManager, Repository } from 'typeorm';
import { SystemSetting } from './entities/system-setting.entity';
import { AiAvailableModel } from './entities/ai-available-model.entity';
import { NotFoundException } from '../../common/exceptions';
const AI_FEATURES_ENABLED_KEY = 'AI_FEATURES_ENABLED';
const AI_FEATURES_ENABLED_CACHE_KEY = 'system_settings:AI_FEATURES_ENABLED';
const AI_FEATURES_ENABLED_TTL_SECONDS = 30;
const AI_ACTIVE_MODEL_KEY = 'AI_ACTIVE_MODEL';
const AI_ACTIVE_MODEL_CACHE_KEY = 'system_settings:AI_ACTIVE_MODEL';
const AI_ACTIVE_MODEL_TTL_SECONDS = 30;
/** Service สำหรับจัดการ system_settings ที่เกี่ยวข้องกับ AI Admin Console */
@Injectable()
export class AiSettingsService {
@@ -22,6 +29,8 @@ export class AiSettingsService {
constructor(
@InjectRepository(SystemSetting)
private readonly settingRepo: Repository<SystemSetting>,
@InjectRepository(AiAvailableModel)
private readonly modelRepo: Repository<AiAvailableModel>,
@InjectRedis() private readonly redis: Redis
) {}
@@ -113,4 +122,171 @@ export class AiSettingsService {
private toMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
// --- AI Model Management (ADR-027) ---
/** ดึงรายการโมเดล AI ทั้งหมดที่ใช้งานได้ (รวมถึงที่ไม่ active) */
async getAvailableModels(): Promise<AiAvailableModel[]> {
return this.modelRepo.find({
order: { isDefault: 'DESC', modelName: 'ASC' },
});
}
/** ดึงรายการโมเดล AI ที่ active เท่านั้น */
async getActiveModels(): Promise<AiAvailableModel[]> {
return this.modelRepo.find({
where: { isActive: true },
order: { modelName: 'ASC' },
});
}
/** ดึงโมเดล AI ที่ใช้งานอยู่ปัจจุบัน (active model) */
async getActiveModel(): Promise<string> {
try {
const cachedValue = await this.redis.get(AI_ACTIVE_MODEL_CACHE_KEY);
if (cachedValue) return cachedValue;
const setting = await this.settingRepo.findOne({
where: { settingKey: AI_ACTIVE_MODEL_KEY },
});
const activeModel = setting?.settingValue ?? 'gemma4:e2b';
await this.redis.set(
AI_ACTIVE_MODEL_CACHE_KEY,
activeModel,
'EX',
AI_ACTIVE_MODEL_TTL_SECONDS
);
return activeModel;
} catch (error: unknown) {
this.logger.error(`Failed to get active model: ${this.toMessage(error)}`);
return 'gemma4:e2b';
}
}
/** ตั้งค่าโมเดล AI ที่ใช้งาน (global) */
async setActiveModel(modelName: string, userId: number): Promise<string> {
const model = await this.modelRepo.findOne({
where: { modelName, isActive: true },
});
if (!model) {
throw new NotFoundException('AiAvailableModel', modelName);
}
await this.settingRepo.manager.transaction(
async (manager: EntityManager): Promise<void> => {
const repo = manager.getRepository(SystemSetting);
const existing = await repo.findOne({
where: { settingKey: AI_ACTIVE_MODEL_KEY },
});
const setting =
existing ??
repo.create({
settingKey: AI_ACTIVE_MODEL_KEY,
dataType: 'string',
category: 'ai',
description: 'โมเดล AI ที่ใช้งานอยู่ในระบบ (global)',
isPublic: true,
});
setting.settingValue = modelName;
setting.updatedBy = userId;
await repo.save(setting);
}
);
await this.redis.del(AI_ACTIVE_MODEL_CACHE_KEY);
this.logger.log(
`Active AI model changed to ${modelName} by user ${userId}`
);
return modelName;
}
/** เพิ่มโมเดล AI ใหม่เข้าระบบ (Superadmin only) */
async addModel(
data: {
modelName: string;
modelVersion: string;
description?: string;
vramGb?: number;
},
userId: number
): Promise<AiAvailableModel> {
const existing = await this.modelRepo.findOne({
where: { modelName: data.modelName },
withDeleted: true,
});
if (existing) {
throw new Error(`Model ${data.modelName} already exists`);
}
const model = this.modelRepo.create({
...data,
isActive: true,
isDefault: false,
createdBy: userId,
});
const saved = await this.modelRepo.save(model);
this.logger.log(`New AI model added: ${data.modelName} by user ${userId}`);
return saved;
}
/** ลบโมเดล AI (soft delete) */
async removeModel(modelName: string, userId: number): Promise<void> {
const model = await this.modelRepo.findOne({
where: { modelName },
});
if (!model) {
throw new NotFoundException('AiAvailableModel', modelName);
}
if (model.isDefault) {
throw new Error('Cannot remove default model');
}
const activeModel = await this.getActiveModel();
if (activeModel === modelName) {
throw new Error('Cannot remove currently active model');
}
await this.modelRepo.softRemove(model);
this.logger.log(`AI model removed: ${modelName} by user ${userId}`);
}
/** เปลี่ยนสถานะ active/inactive ของโมเดล */
async toggleModelActive(
modelName: string,
userId: number
): Promise<AiAvailableModel> {
const model = await this.modelRepo.findOne({
where: { modelName },
});
if (!model) {
throw new NotFoundException('AiAvailableModel', modelName);
}
if (model.isDefault && model.isActive) {
throw new Error('Cannot deactivate default model');
}
const activeModel = await this.getActiveModel();
if (activeModel === modelName && model.isActive) {
throw new Error('Cannot deactivate currently active model');
}
model.isActive = !model.isActive;
model.updatedBy = userId;
const saved = await this.modelRepo.save(model);
this.logger.log(
`AI model ${modelName} active status changed to ${model.isActive} by user ${userId}`
);
return saved;
}
}
+110
View File
@@ -282,6 +282,116 @@ export class AiController {
return { aiFeaturesEnabled };
}
// ─── AI Model Management (ADR-027) ─────────────────────────────────────────
@Get('admin/models')
@UseGuards(JwtAuthGuard, RbacGuard)
@ApiBearerAuth()
@RequirePermission('system.manage_all')
@ApiOperation({
summary: 'AI Models — ดึงรายการโมเดล AI ทั้งหมดที่ใช้งานได้',
})
async getAvailableModels() {
const models = await this.aiSettingsService.getAvailableModels();
const activeModel = await this.aiSettingsService.getActiveModel();
return { models, activeModel };
}
@Get('admin/models/active')
@UseGuards(JwtAuthGuard, RbacGuard)
@ApiBearerAuth()
@RequirePermission('system.manage_all')
@ApiOperation({
summary: 'AI Active Model — ดึงโมเดล AI ที่ใช้งานอยู่ปัจจุบัน',
})
async getActiveModel() {
const activeModel = await this.aiSettingsService.getActiveModel();
return { activeModel };
}
@Post('admin/models/active')
@UseGuards(JwtAuthGuard, RbacGuard)
@ApiBearerAuth()
@RequirePermission('system.manage_all')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'AI Set Active Model — ตั้งค่าโมเดล AI ที่ใช้งาน (global)',
})
async setActiveModel(
@Body() dto: { modelName: string },
@CurrentUser() user: User
) {
const activeModel = await this.aiSettingsService.setActiveModel(
dto.modelName,
user.user_id
);
return { activeModel };
}
@Post('admin/models')
@UseGuards(JwtAuthGuard, RbacGuard)
@ApiBearerAuth()
@RequirePermission('system.manage_all')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({
summary: 'AI Add Model — เพิ่มโมเดล AI ใหม่เข้าระบบ (Superadmin only)',
})
async addModel(
@Body()
dto: {
modelName: string;
modelVersion: string;
description?: string;
vramGb?: number;
},
@CurrentUser() user: User
) {
const model = await this.aiSettingsService.addModel(dto, user.user_id);
return { model };
}
@Patch('admin/models/:modelName/toggle')
@UseGuards(JwtAuthGuard, RbacGuard)
@ApiBearerAuth()
@RequirePermission('system.manage_all')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'AI Toggle Model — เปลี่ยนสถานะ active/inactive ของโมเดล',
})
@ApiParam({
name: 'modelName',
description: 'ชื่อโมเดล เช่น gemma4:e4b',
})
async toggleModelActive(
@Param('modelName') modelName: string,
@CurrentUser() user: User
) {
const model = await this.aiSettingsService.toggleModelActive(
modelName,
user.user_id
);
return { model };
}
@Delete('admin/models/:modelName')
@UseGuards(JwtAuthGuard, RbacGuard)
@ApiBearerAuth()
@RequirePermission('system.manage_all')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({
summary: 'AI Remove Model — ลบโมเดล AI (soft delete)',
})
@ApiParam({
name: 'modelName',
description: 'ชื่อโมเดลที่ต้องการลบ',
})
async removeModel(
@Param('modelName') modelName: string,
@CurrentUser() user: User
): Promise<void> {
await this.aiSettingsService.removeModel(modelName, user.user_id);
}
@Get('admin/health')
@UseGuards(JwtAuthGuard, RbacGuard)
@ApiBearerAuth()
+3
View File
@@ -7,6 +7,7 @@
// - 2026-05-21: ลงทะเบียน SystemSetting, AiSettingsService และ AiEnabledGuard สำหรับ ADR-027.
// - 2026-05-22: นำเข้าและลงทะเบียน CleanupTempFilesWorker (T016) เพื่อลบไฟล์แนบชั่วคราวหมดอายุ
// - 2026-05-23: ลงทะเบียน MigrationProgress + AiMigrationCheckpointService (ADR-023A)
// - 2026-05-25: ลงทะเบียน AiAvailableModel สำหรับ AI Model Management (ADR-027).
// Module สำหรับ AI Gateway — ลงทะเบียน Services และ Controllers (ADR-023)
import { Logger, Module, OnModuleInit } from '@nestjs/common';
@@ -36,6 +37,7 @@ import { AiAuditLog } from './entities/ai-audit-log.entity';
import { MigrationReviewRecord } from './entities/migration-review.entity';
import { MigrationProgress } from './entities/migration-progress.entity';
import { SystemSetting } from './entities/system-setting.entity';
import { AiAvailableModel } from './entities/ai-available-model.entity';
import { AiMigrationCheckpointService } from './ai-migration-checkpoint.service';
import { AiEnabledGuard } from './guards/ai-enabled.guard';
import { UserModule } from '../user/user.module';
@@ -72,6 +74,7 @@ import {
MigrationReviewRecord,
MigrationProgress,
SystemSetting,
AiAvailableModel,
Attachment,
Project,
Organization,
@@ -0,0 +1,61 @@
// File: src/modules/ai/entities/ai-available-model.entity.ts
// Change Log:
// - 2026-05-25: สร้าง Entity AiAvailableModel สำหรับจัดการรายการโมเดล AI แบบไดนามิก (ADR-027)
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
/** Entity สำหรับเก็บรายการโมเดล AI ที่ให้เลือกใช้งานในระบบ */
@Entity('ai_available_models')
@Index(['isActive'])
@Index(['isDefault'])
export class AiAvailableModel {
@PrimaryGeneratedColumn()
id!: number;
@Column({ name: 'model_name', unique: true, length: 100 })
modelName!: string;
@Column({ name: 'model_version', length: 50 })
modelVersion!: string;
@Column({ length: 500, nullable: true })
description?: string;
@Column({
name: 'vram_gb',
type: 'decimal',
precision: 4,
scale: 2,
nullable: true,
})
vramGb?: number;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
@Column({ name: 'is_default', type: 'boolean', default: false })
isDefault!: boolean;
@Column({ name: 'created_by', nullable: true })
createdBy?: number;
@Column({ name: 'updated_by', nullable: true })
updatedBy?: number;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date;
@DeleteDateColumn({ name: 'deleted_at' })
deletedAt?: Date;
}
@@ -395,7 +395,7 @@ Return ONLY a valid JSON object matching this schema. Do NOT include markdown co
});
await this.saveAiAuditLog({
documentPublicId,
aiModel: this.ollamaService.getMainModelName(),
aiModel: await this.ollamaService.getMainModelName(),
status: AiAuditStatus.FAILED,
errorMessage: errMsg,
processingTimeMs: Date.now() - startTime,
@@ -421,7 +421,7 @@ Return ONLY a valid JSON object matching this schema. Do NOT include markdown co
});
await this.saveAiAuditLog({
documentPublicId,
aiModel: this.ollamaService.getMainModelName(),
aiModel: await this.ollamaService.getMainModelName(),
status: AiAuditStatus.FAILED,
errorMessage: errMsg,
processingTimeMs: Date.now() - startTime,
@@ -466,7 +466,7 @@ Return ONLY a valid JSON object matching this schema. Do NOT include markdown co
});
await this.saveAiAuditLog({
documentPublicId,
aiModel: this.ollamaService.getMainModelName(),
aiModel: await this.ollamaService.getMainModelName(),
status: AiAuditStatus.SUCCESS,
aiSuggestionJson: extractedMetadata,
confidenceScore: confidence,
@@ -114,7 +114,7 @@ export class AiRealtimeProcessor extends WorkerHost {
this.aiAuditLogRepo.create({
documentPublicId: job.data.documentPublicId,
aiModel: 'gemma4',
modelName: this.ollamaService.getMainModelName(),
modelName: await this.ollamaService.getMainModelName(),
aiSuggestionJson: normalizedSuggestion,
confidenceScore: this.extractConfidence(normalizedSuggestion),
processingTimeMs: Date.now() - startTime,
@@ -136,7 +136,7 @@ export class AiRealtimeProcessor extends WorkerHost {
this.aiAuditLogRepo.create({
documentPublicId: job.data.documentPublicId,
aiModel: 'gemma4',
modelName: this.ollamaService.getMainModelName(),
modelName: await this.ollamaService.getMainModelName(),
processingTimeMs: Date.now() - startTime,
status: AiAuditStatus.FAILED,
errorMessage: err instanceof Error ? err.message : String(err),
@@ -2,10 +2,12 @@
// Change Log
// - 2026-05-15: เพิ่ม Ollama service สำหรับ ADR-023A 2-model stack.
// - 2026-05-21: เพิ่ม checkHealth สำหรับตรวจสอบสุขภาพและความเร็ว (Latency) ของ Ollama
// - 2026-05-25: เพิ่มการใช้งานโมเดลจาก DB (AiSettingsService) แทน ENV เท่านั้น (ADR-027).
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger, Optional } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
import { AiSettingsService } from '../ai-settings.service';
export interface OllamaGenerateOptions {
timeoutMs?: number;
@@ -17,18 +19,23 @@ export interface OllamaGenerateOptions {
export class OllamaService {
private readonly logger = new Logger(OllamaService.name);
private readonly ollamaUrl: string;
private readonly mainModel: string;
private readonly defaultMainModel: string;
private readonly embedModel: string;
private readonly timeoutMs: number;
constructor(private readonly configService: ConfigService) {
constructor(
private readonly configService: ConfigService,
@Optional()
private readonly aiSettingsService?: AiSettingsService
) {
this.ollamaUrl = this.configService.get<string>(
'OLLAMA_URL',
this.configService.get<string>('AI_HOST_URL', 'http://localhost:11434')
);
this.mainModel = this.configService.get<string>(
// Default fallback model (ADR-023A: gemma4:e2b)
this.defaultMainModel = this.configService.get<string>(
'OLLAMA_MODEL_MAIN',
'gemma4:e4b'
'gemma4:e2b'
);
this.embedModel = this.configService.get<string>(
'OLLAMA_MODEL_EMBED',
@@ -37,16 +44,31 @@ export class OllamaService {
this.timeoutMs = this.configService.get<number>('AI_TIMEOUT_MS', 30000);
}
/** สร้างข้อความตอบกลับจาก gemma4:e4b หรือค่า ENV ที่กำหนด */
/** ดึงชื่อโมเดลที่ใช้งานอยู่ (จาก DB หรือ ENV fallback) */
private async getActiveModelName(): Promise<string> {
if (this.aiSettingsService) {
try {
return await this.aiSettingsService.getActiveModel();
} catch (err: unknown) {
this.logger.warn(
`Failed to get active model from DB: ${err instanceof Error ? err.message : String(err)}`
);
}
}
return this.defaultMainModel;
}
/** สร้างข้อความตอบกลับจากโมเดลที่กำหนด (DB หรือ ENV fallback) */
async generate(
prompt: string,
options: OllamaGenerateOptions = {}
): Promise<string> {
const modelName = await this.getActiveModelName();
try {
const response = await axios.post<{ response: string }>(
`${this.ollamaUrl}/api/generate`,
{
model: this.mainModel,
model: modelName,
prompt,
stream: false,
},
@@ -58,7 +80,7 @@ export class OllamaService {
return response.data.response ?? '';
} catch (err) {
this.logger.error(
'Ollama generate failed',
`Ollama generate failed with model ${modelName}`,
err instanceof Error ? err.stack : String(err)
);
throw err;
@@ -83,9 +105,14 @@ export class OllamaService {
}
}
/** คืนชื่อ main model สำหรับ audit log */
getMainModelName(): string {
return this.mainModel;
/** คืนชื่อ main model สำหรับ audit log (async เพราะต้องเช็ค DB) */
async getMainModelName(): Promise<string> {
return this.getActiveModelName();
}
/** คืนชื่อ main model แบบ sync (fallback สำหรับกรณีที่ไม่ต้องการ async) */
getMainModelNameSync(): string {
return this.defaultMainModel;
}
/** คืนชื่อ embedding model สำหรับ audit log */
@@ -101,13 +128,14 @@ export class OllamaService {
error?: string;
}> {
const startTime = Date.now();
const activeModel = await this.getActiveModelName();
try {
await axios.get(`${this.ollamaUrl}/api/tags`, { timeout: 5000 });
const latencyMs = Date.now() - startTime;
return {
status: 'HEALTHY',
latencyMs,
models: [this.mainModel, this.embedModel],
models: [activeModel, this.embedModel],
};
} catch (err: unknown) {
const latencyMs = Date.now() - startTime;
@@ -120,7 +148,7 @@ export class OllamaService {
return {
status: isTimeout ? 'DEGRADED' : 'DOWN',
latencyMs,
models: [this.mainModel, this.embedModel],
models: [activeModel, this.embedModel],
error,
};
}
+146 -2
View File
@@ -6,10 +6,11 @@
// - 2026-05-21: เพิ่ม RAG Playground Sandbox tab สำหรับ Superadmin (T037, T038).
// - 2026-05-21: เพิ่ม OCR Sandbox tab พร้อมการอัปเดตสถานะและการแสดงผล JSON แบบมีสีสำหรับ Superadmin (T043-T045).
// - 2026-05-21: แก้ไข ESLint error เกี่ยวกับ any type และ console.error statement ให้ตรงตามมาตรฐาน Tier 1/2
// - 2026-05-25: เพิ่ม AI Model Management UI สำหรับเลือกโมเดลแบบไดนามิก (ADR-027).
import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Brain, Loader2, Power, ShieldCheck, Cpu, Database, Activity, Search, Info, HelpCircle, AlertCircle } from 'lucide-react';
import { Brain, Loader2, Power, ShieldCheck, Cpu, Database, Activity, Search, Info, HelpCircle, AlertCircle, Settings2, Trash2 } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@@ -20,7 +21,7 @@ import { Textarea } from '@/components/ui/textarea';
import { Progress } from '@/components/ui/progress';
import { useAiStatus, useToggleAiFeatures, useAiHealth } from '@/hooks/use-ai-status';
import { projectService } from '@/lib/services/project.service';
import { adminAiService, AiSandboxJobResult } from '@/lib/services/admin-ai.service';
import { adminAiService, AiSandboxJobResult, AiAvailableModel } from '@/lib/services/admin-ai.service';
import { toast } from 'sonner';
interface SandboxProject {
@@ -48,6 +49,17 @@ export default function AiAdminConsolePage() {
const [isOcrPolling, setIsOcrPolling] = useState<boolean>(false);
const [ocrProgress, setOcrProgress] = useState<number>(0);
const [ocrStatusText, setOcrStatusText] = useState<string>('');
// AI Model Management State (ADR-027)
const { data: aiModelsData, refetch: refetchModels } = useQuery<{ models: AiAvailableModel[]; activeModel: string }>({
queryKey: ['ai-available-models'],
queryFn: async () => {
return await adminAiService.getAvailableModels();
},
});
const availableModels = aiModelsData?.models ?? [];
const activeModel = aiModelsData?.activeModel ?? '';
const { data: projects = [], isLoading: isProjectsLoading } = useQuery<SandboxProject[]>({
queryKey: ['admin-sandbox-projects'],
queryFn: async () => {
@@ -58,6 +70,37 @@ export default function AiAdminConsolePage() {
const handleToggle = async (enabled: boolean): Promise<void> => {
await toggleMutation.mutateAsync(enabled);
};
const handleModelChange = async (modelName: string): Promise<void> => {
try {
await adminAiService.setActiveModel(modelName);
toast.success(`เปลี่ยนโมเดลเป็น ${modelName} สำเร็จ`);
await refetchModels();
} catch {
toast.error('ไม่สามารถเปลี่ยนโมเดลได้');
}
};
const handleToggleModel = async (modelName: string): Promise<void> => {
try {
await adminAiService.toggleModelActive(modelName);
toast.success(`เปลี่ยนสถานะโมเดล ${modelName} สำเร็จ`);
await refetchModels();
} catch {
toast.error('ไม่สามารถเปลี่ยนสถานะโมเดลได้');
}
};
const handleRemoveModel = async (modelName: string): Promise<void> => {
if (!confirm(`ต้องการลบโมเดล ${modelName} ใช่หรือไม่?`)) return;
try {
await adminAiService.removeModel(modelName);
toast.success(`ลบโมเดล ${modelName} สำเร็จ`);
await refetchModels();
} catch {
toast.error('ไม่สามารถลบโมเดลได้');
}
};
const handleRefreshAll = async (): Promise<void> => {
await Promise.all([refetch(), refetchHealth()]);
};
@@ -389,6 +432,107 @@ export default function AiAdminConsolePage() {
)}
</CardContent>
</Card>
{/* AI Model Management Card (ADR-027) */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Settings2 className="h-5 w-5" />
AI Model Management
<Badge variant="outline" className="text-[10px]">ADR-027</Badge>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div className="space-y-2 flex-1">
<label htmlFor="model-select" className="text-sm font-medium text-foreground">
AI (Global)
</label>
<Select
value={activeModel}
onValueChange={handleModelChange}
>
<SelectTrigger id="model-select" className="w-full sm:w-[300px]">
<SelectValue placeholder="-- เลือกโมเดล --" />
</SelectTrigger>
<SelectContent>
{availableModels
.filter((m) => m.isActive)
.map((model) => (
<SelectItem key={model.modelName} value={model.modelName}>
{model.modelName}
{model.isDefault && (
<Badge variant="secondary" className="ml-2 text-[10px]">Default</Badge>
)}
{model.vramGb && (
<span className="ml-1 text-muted-foreground">({model.vramGb}GB)</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="text-sm text-muted-foreground">
: <Badge variant="default">{activeModel || 'Loading...'}</Badge>
</div>
</div>
<div className="border-t pt-4">
<h4 className="text-sm font-medium mb-3"></h4>
<div className="space-y-2">
{availableModels.length === 0 ? (
<p className="text-sm text-muted-foreground"></p>
) : (
availableModels.map((model) => (
<div
key={model.modelName}
className="flex items-center justify-between p-2 rounded border bg-background/50"
>
<div className="flex items-center gap-2">
<Badge
variant={model.isActive ? 'default' : 'secondary'}
className="text-[10px]"
>
{model.isActive ? 'Active' : 'Inactive'}
</Badge>
<span className="text-sm font-medium">{model.modelName}</span>
{model.isDefault && (
<Badge variant="outline" className="text-[10px]">Default</Badge>
)}
{activeModel === model.modelName && (
<Badge variant="default" className="text-[10px] bg-emerald-500">Current</Badge>
)}
</div>
<div className="flex items-center gap-2">
{!model.isDefault && (
<>
<Button
variant="ghost"
size="sm"
onClick={() => handleToggleModel(model.modelName)}
disabled={activeModel === model.modelName && model.isActive}
>
{model.isActive ? 'Deactivate' : 'Activate'}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveModel(model.modelName)}
disabled={model.isDefault || activeModel === model.modelName}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</>
)}
</div>
</div>
))
)}
</div>
</div>
</CardContent>
</Card>
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
+55
View File
@@ -4,6 +4,7 @@
// - 2026-05-21: เพิ่ม service method `getHealth` สำหรับดึงข้อมูลสุขภาพของระบบ AI (T028).
// - 2026-05-21: เพิ่ม API service สำหรับ Superadmin Sandbox RAG (T037).
// - 2026-05-21: เพิ่ม service method `submitSandboxExtract` สำหรับอัปโหลดไฟล์ใน OCR Sandbox (T043).
// - 2026-05-25: เพิ่ม methods สำหรับจัดการโมเดล AI แบบไดนามิก (ADR-027).
import api from '../api/client';
@@ -59,6 +60,27 @@ export interface AiSandboxJobResult {
completedAt?: string;
}
export interface AiAvailableModel {
id: number;
modelName: string;
modelVersion: string;
description?: string;
vramGb?: number;
isActive: boolean;
isDefault: boolean;
createdAt: string;
updatedAt: string;
}
export interface AiModelsResponse {
models: AiAvailableModel[];
activeModel: string;
}
export interface AiActiveModelResponse {
activeModel: string;
}
const extractData = <T>(value: unknown): T => {
if (value && typeof value === 'object' && 'data' in value) {
return (value as { data: T }).data;
@@ -110,4 +132,37 @@ export const adminAiService = {
});
return extractData<{ requestPublicId: string; jobId: string; status: string }>(data);
},
// --- AI Model Management (ADR-027) ---
getAvailableModels: async (): Promise<AiModelsResponse> => {
const { data } = await api.get('/ai/admin/models');
return extractData<AiModelsResponse>(data);
},
getActiveModel: async (): Promise<AiActiveModelResponse> => {
const { data } = await api.get('/ai/admin/models/active');
return extractData<AiActiveModelResponse>(data);
},
setActiveModel: async (modelName: string): Promise<AiActiveModelResponse> => {
const { data } = await api.post('/ai/admin/models/active', { modelName });
return extractData<AiActiveModelResponse>(data);
},
addModel: async (
model: Omit<AiAvailableModel, 'id' | 'createdAt' | 'updatedAt'>
): Promise<{ model: AiAvailableModel }> => {
const { data } = await api.post('/ai/admin/models', model);
return extractData<{ model: AiAvailableModel }>(data);
},
toggleModelActive: async (modelName: string): Promise<{ model: AiAvailableModel }> => {
const { data } = await api.patch(`/ai/admin/models/${encodeURIComponent(modelName)}/toggle`);
return extractData<{ model: AiAvailableModel }>(data);
},
removeModel: async (modelName: string): Promise<void> => {
await api.delete(`/ai/admin/models/${encodeURIComponent(modelName)}`);
},
};
+142 -7
View File
@@ -3,6 +3,10 @@
- 2026-05-23: Initialized long-term memory system with core project rules, Windows environment settings, and constraints.
- 2026-05-23 (Session 2): N8N Workflow Refactor — QuizMe session, decisions locked, สร้าง CONTEXT-N8N-Refactor.md, สร้าง n8n.workflow.v2.json (ADR-023A compliant), อัพเดต 03-05 และ 03-06.
- 2026-05-24: เพิ่ม sections: Known Commands, Current Decisions, Do/Don't Quick Reference, Environment & Services, Recent Rollouts.
- 2026-05-25 (Session 3): แก้ไขบัค `tempAttachmentId` ใน Migration Queue — เปลี่ยนจาก Integer ที่ไม่มีอยู่จริงเป็น UUID string (ADR-019), อัปเดต DTO, Service UUID-to-INT resolution, และ n8n.workflow.v2.json.
- 2026-05-25 (Session 4): Normalize migration error logging ตาม AGENTS.md — แก้ n8n `Log Error to CSV`/`Log Error to DB`, harden backend `logError()`, เพิ่ม `job_id` ใน migration_errors SQL/delta, และเพิ่ม regression test.
- 2026-05-25 (Session 5): N8N Workflow Debug — แก้ Submit AI Job (jsonBody serialization + RBAC permission gap) และเพิ่ม checksum-based dedup ใน FileStorageService.upload().
- 2026-05-25 (Session 6): AI Model Management (ADR-027) — เพิ่มระบบเลือกโมเดล AI แบบไดนามิกผ่าน AI Admin Console: สร้าง `ai_available_models` table + entity, extend `AiSettingsService` ด้วย methods CRUD โมเดล, add REST endpoints, update frontend UI ด้วย Select dropdown และ model list management, update `OllamaService` ใช้ DB-configured model แทน ENV เท่านั้น.
-->
# 🧠 Agent Long-term Project Memory
@@ -11,6 +15,15 @@
> **Version:** 1.9.6 (Last Synced: 2026-05-23)
> **Stack:** NestJS 11 + Next.js 16 + TypeScript + MariaDB 11.8 + Redis + BullMQ + Elasticsearch + Ollama (on-prem AI)
> [!IMPORTANT]
> **Project memory นี้ต้องใช้งานภายใต้ `AGENTS.md` เสมอ**
>
> - ให้ใช้ `AGENTS.md` เป็นกฎหลักก่อน memory ทุกครั้ง
> - ถ้า memory เก่าหรือ session note ขัดกับ `AGENTS.md` ให้ยึด `AGENTS.md`
> - งาน schema ต้องทำตาม ADR-009 ผ่าน SQL/delta เท่านั้น
> - งาน UUID/Public API ต้องทำตาม ADR-019 โดยใช้ `publicId` และห้าม `parseInt()` บน UUID
> - งาน n8n / AI migration ต้องอยู่ในขอบเขต ADR-023A และ mutation ต้องมี `Idempotency-Key`
---
## 🧭 1. กฎการรันคำสั่งและการทำงานบนระบบ (OS Rules & Sandbox Constraints)
@@ -194,11 +207,13 @@ QDRANT_URL
## 🚀 8. Recent Rollouts
| วันที่ | Version | รายการ | สถานะ |
| ---------- | ------- | ------------------------------------------------------------------------------- | --------------------------- |
| 2026-05-23 | v1.9.6 | Specs reorganization (`100/200/300-*` folders), AGENTS.md v1.9.6 update | ✅ Complete |
| 2026-05-23 | v1.9.6 | N8N Workflow v2 (`n8n.workflow.v2.json`) — ADR-023A compliant, ลบ Ollama direct | ⏳ Pending import to n8n UI |
| 2026-05-24 | v1.9.6 | AGENTS.md Project Memory Override rule (Windsurf / Antigravity / Codex) | ✅ Complete |
| วันที่ | Version | รายการ | สถานะ |
| ---------- | ------- | --------------------------------------------------------------------------------------------- | --------------------------- |
| 2026-05-23 | v1.9.6 | Specs reorganization (`100/200/300-*` folders), AGENTS.md v1.9.6 update | ✅ Complete |
| 2026-05-23 | v1.9.6 | N8N Workflow v2 (`n8n.workflow.v2.json`) — ADR-023A compliant, ลบ Ollama direct | ⏳ Pending import to n8n UI |
| 2026-05-24 | v1.9.6 | AGENTS.md Project Memory Override rule (Windsurf / Antigravity / Codex) | ✅ Complete |
| 2026-05-25 | v1.9.6 | Migration Queue attachment UUID fix — DTO + Service + n8n.workflow.v2.json (Session 3) | ✅ Complete (tsc verified) |
| 2026-05-25 | v1.9.6 | Migration error normalization + `job_id` logging — workflow + backend + SQL/delta (Session 4) | ✅ Complete |
---
@@ -210,7 +225,7 @@ QDRANT_URL
- อัปเดตกฎ `AGENTS.md` และ `GEMINI.md` ให้ตรงกับมาตรฐานใหม่
- ริเริ่มระบบ `memory/agent-memory.md`
### Session 2 — 2026-05-23 (N8N Workflow Refactor) ← **ล่าสุด**
### Session 2 — 2026-05-23 (N8N Workflow Refactor)
#### Decisions ที่ Lock แล้ว (จาก QuizMe Session)
@@ -256,13 +271,131 @@ Form Trigger → Set Config → Health/Token Check → Fetch Master Data
→ Save Checkpoint → Delay → Loop
```
### Session 3 — 2026-05-24 (Migration Queue Attachment UUID Bug Fix)
#### ปัญหาที่พบ (Root Cause)
ไฟล์ `n8n.workflow.v2.json` (โหนด `Insert Review Queue`) ส่งค่า `tempAttachmentId` โดยใช้ `{{parseInt($json.attachmentId)}}` ซึ่งพยายามแปลง UUID string เป็นตัวเลข ผลลัพธ์คือค่า `NaN` หรือตัวเลขที่ผิดพลาด (เช่น `"0195..."``19`) ทำให้คอลัมน์ `temp_attachment_id` ใน `migration_review_queue` เป็น `NULL` เสมอ — ละเมิด ADR-019 Tier 1 Blocker
#### การแก้ไข (Fix)
| ไฟล์ | การเปลี่ยนแปลง |
| ----------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| `backend/src/modules/ai/dto/migration-checkpoint.dto.ts` | ปรับ `tempAttachmentId` เป็น `@IsOptional()` รองรับทั้ง UUID string และ Integer PK |
| `backend/src/modules/ai/ai-migration-checkpoint.service.ts` | เพิ่ม UUID→INT resolution: `SELECT id FROM attachments WHERE uuid = ? LIMIT 1` |
| `specs/03-Data-and-Storage/n8n.workflow.v2.json` | เปลี่ยนส่ง `temp_attachment_public_id` (UUID string) แทน `parseInt(...)` ที่ผิด |
#### Pattern ที่ตกลง (Locked)
```
n8n ส่ง: { tempAttachmentId: "019505a1-7c3e-7000-..." } ← UUID string
Backend รับ: ตรวจสอบประเภท → ถ้าเป็น string → query DB → ได้ INT id จริง
DB บันทึก: migration_review_queue.temp_attachment_id = <INT> ← ถูกต้อง
```
#### Verification
- `npx tsc --noEmit` — ✅ ผ่าน ไม่มี type error
- ตรวจสอบ logic ใน Service แล้ว ไม่มีการเขียนทับ `tempAttachmentId` ด้วย `undefined` (guard check แล้ว)
### Session 4 — 2026-05-25 (Migration Error Normalization ตาม AGENTS.md) ← **ล่าสุด**
#### ปัญหาที่พบ (Root Cause)
- `Log Error to CSV` และ `Log Error to DB` ใน `n8n.workflow.v2.json` ส่ง `error_type` บางค่าไม่ตรง enum ของ `migration_errors`
- ค่าที่พบจริงและต้อง normalize: `AI_JOB_FAILED`, `PARSE_ERROR`, `TOKEN_EXPIRED`
- backend `AiMigrationCheckpointService.logError()` เดิม insert ค่า `dto.errorType` ตรง ๆ ทำให้เสี่ยง DB enum reject
- ตาราง `migration_errors` เดิมไม่มี `job_id` แม้ workflow/DTO จะมี `jobId` อยู่แล้ว ทำให้ trace กลับไป BullMQ job ไม่ครบ
#### การแก้ไข (Fix)
| ไฟล์ | การเปลี่ยนแปลง |
| -------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| `specs/03-Data-and-Storage/n8n.workflow.v2.json` | normalize `error_type`, `document_number`, `error`, `job_id` ก่อนเขียน CSV/DB |
| `backend/src/modules/ai/ai-migration-checkpoint.service.ts` | map/validate `errorType` ซ้ำก่อน insert และเพิ่ม `job_id` ใน SQL insert |
| `backend/src/modules/migration/entities/migration-error.entity.ts` | เพิ่ม field `jobId?: string` |
| `specs/03-Data-and-Storage/lcbp3-v1.9.0-migration.sql` | เพิ่มคอลัมน์ `job_id VARCHAR(100) NULL` และ index |
| `specs/03-Data-and-Storage/deltas/2026-05-22-drop-migration-tables.rollback.sql` | อัปเดต table definition ของ `migration_errors` ให้มี `job_id` |
| `specs/03-Data-and-Storage/deltas/2026-05-24-add-migration-errors-job-id.sql` | เพิ่ม delta สำหรับ add `job_id` |
| `specs/03-Data-and-Storage/deltas/2026-05-24-add-migration-errors-job-id.rollback.sql` | เพิ่ม rollback สำหรับ drop `job_id` |
| `backend/src/modules/ai/ai-migration-checkpoint.service.spec.ts` | เพิ่ม regression tests สำหรับ error normalization + `job_id` |
#### Mapping ที่ Lock แล้ว
```
AI_JOB_FAILED -> API_ERROR
PARSE_ERROR -> AI_PARSE_ERROR
TOKEN_EXPIRED -> API_ERROR
unsupported value -> UNKNOWN
```
#### กฎใช้งานต่อไป
- ให้ถือ enum ของ `migration_errors.error_type` เป็น source of truth เสมอ
- workflow ต้อง normalize ก่อนส่งเข้า backend และ backend ต้อง normalize ซ้ำอีกชั้น
- ห้ามพึ่ง DB enum reject เป็น validation mechanism
- การเพิ่มคอลัมน์ `job_id` ต้องทำผ่าน SQL/delta ตาม ADR-009 เท่านั้น
#### Verification
- workflow normalization assertion — ✅ ผ่าน
- `pnpm --filter backend build` — ✅ ผ่าน
- `pnpm --filter backend test -- --runTestsByPath src/modules/ai/ai-migration-checkpoint.service.spec.ts` — ✅ ผ่าน
- regression seam ที่เพิ่มยืนยัน:
- `AI_JOB_FAILED` map เป็น `API_ERROR`
- unsupported error type fallback เป็น `UNKNOWN`
---
### Session 5 — 2026-05-25 (N8N Submit AI Job Debug + Upload Dedup) ← **ล่าสุด**
#### ปัญหาที่พบ (Root Cause)
**Bug 1: `Submit AI Job` → 400 Bad Request**
- n8n HTTP Request node `typeVersion: 4.1` เมื่อ `specifyBody: "json"` และ `jsonBody` เป็น expression ที่ return **object** → n8n ส่ง body เป็น `"[object Object]"` แทน JSON string
- แก้ด้วย `JSON.stringify($json.submit_payload)`
**Bug 2: `Submit AI Job` → 403 Forbidden**
- `migration_bot` (user_id=5, role_id=1/Superadmin) ไม่มี `ai.suggest` ใน `role_permissions`
- Root cause: Seed script `INSERT INTO role_permissions SELECT 1, permission_id FROM permissions WHERE is_active = 1` รันก่อน `ai.*` permissions (id 181-186) ถูก insert เข้า `permissions` table
- แก้ด้วย delta SQL grant ai.\* ให้ role_id=1
**Bug 3: Upload ซ้ำเมื่อ n8n retry**
- `FileStorageService.upload()` เดิมไม่มี dedup → ทุก retry สร้าง orphan temp attachment ใหม่
- แก้ด้วย checksum-based dedup: query หา temp record ที่มี checksum+userId เดิมและยังไม่หมดอายุ → คืน record เดิมแทน
#### การแก้ไข (Fix)
| ไฟล์ | การเปลี่ยนแปลง |
| --------------------------------------------------------------------------------------------- | ------------------------------------------------------------- |
| `specs/03-Data-and-Storage/n8n.workflow.v2.json` | `jsonBody` เปลี่ยนเป็น `JSON.stringify($json.submit_payload)` |
| `specs/03-Data-and-Storage/deltas/2026-05-25-grant-ai-permissions-to-superadmin.sql` | INSERT IGNORE ai.\* permissions สำหรับ role_id=1 (Superadmin) |
| `specs/03-Data-and-Storage/deltas/2026-05-25-grant-ai-permissions-to-superadmin.rollback.sql` | Rollback DELETE สำหรับ delta ข้างบน |
| `backend/src/common/file-storage/file-storage.service.ts` | เพิ่ม checksum dedup ใน `upload()` ก่อน write file |
#### กฎที่ Lock แล้ว
- `jsonBody` ใน n8n HTTP Request `typeVersion >= 4.1` ต้องใช้ `JSON.stringify(...)` เมื่อ `specifyBody: "json"` และค่าเป็น object
- ทุกครั้งที่เพิ่ม permission ใหม่ใน `permissions` table ต้อง grant ให้ Superadmin (role_id=1) ด้วยทันที — ห้ามปล่อยให้ขาดหาย
- `FileStorageService.upload()` เป็น idempotent ผ่าน SHA-256 checksum + userId + expiresAt
#### Verification ที่ยังต้องทำ
- รัน delta SQL ใน MariaDB (ถ้ายังไม่รัน): `2026-05-25-grant-ai-permissions-to-superadmin.sql`
- Import `n8n.workflow.v2.json` ใหม่เข้า n8n UI
- `pnpm --filter backend test -- file-storage` — ยืนยัน checksum dedup
---
## 🎯 10. แผนงานขั้นต่อไป (Next Session Focus)
### N8N Migration (งานหลักที่เหลือ)
- [ ] **Import `n8n.workflow.v2.json`** เข้า n8n UI และทดสอบ End-to-End
- [ ] **Import `n8n.workflow.v2.json`** เข้า n8n UI และทดสอบ End-to-End (มี fix จาก Session 3, 4, 5 แล้ว)
- [ ] **ทดสอบ End-to-End จริง** — รัน n8n กับ Excel ตัวอย่าง → ตรวจสอบว่า `Submit AI Job` ผ่าน, `migration_review_queue` มีข้อมูล, `migration_errors.job_id` ถูกบันทึก
- [ ] **ตรวจสอบ `ai-realtime` processor** ว่า return `suggestedTags[]` พร้อม `isNew` flag
- [ ] **Frontend Editable Review Form** (Pipeline B) — pre-fill AI suggestions + tag suggestion UI
- [ ] **Dry Run** กับ Excel จริงก่อน Production Migration
@@ -270,3 +403,5 @@ Form Trigger → Set Config → Health/Token Check → Fetch Master Data
### งานทั่วไป
- [ ] รักษาความเป็นระเบียบและอัปเดต `memory/agent-memory.md` ทุกครั้งที่ Task สำคัญเสร็จ
- [ ] เพิ่ม unit test สำหรับ `upsertQueueRecord` ใน `ai-migration-checkpoint.service.spec.ts` (UUID→INT path)
- [ ] เพิ่ม unit test สำหรับ checksum dedup ใน `file-storage.service.spec.ts`
@@ -0,0 +1,8 @@
-- Rollback: Drop ai_available_models table
-- Date: 2026-05-25
-- Remove system setting first
DELETE FROM system_settings WHERE setting_key = 'AI_ACTIVE_MODEL';
-- Drop table
DROP TABLE IF EXISTS ai_available_models;
@@ -0,0 +1,43 @@
-- Delta: Create ai_available_models table for dynamic AI model selection
-- Date: 2026-05-25
-- Author: AI Assistant
-- Related: ADR-027 AI Admin Console - Dynamic model control
-- Create table for available AI models
CREATE TABLE IF NOT EXISTS ai_available_models (
id INT AUTO_INCREMENT PRIMARY KEY,
model_name VARCHAR(100) NOT NULL COMMENT 'ชื่อโมเดล เช่น gemma4:e2b, gemma4:e4b',
model_version VARCHAR(50) NOT NULL COMMENT 'เวอร์ชั่นของโมเดล',
description VARCHAR(500) NULL COMMENT 'รายละเอียดโมเดล',
vram_gb DECIMAL(4,2) NULL COMMENT 'VRAM ที่ใช้โดยประมาณ (GB)',
is_active BOOLEAN DEFAULT TRUE COMMENT 'สถานะใช้งาน',
is_default BOOLEAN DEFAULT FALSE COMMENT 'โมเดลเริ่มต้น',
created_by INT NULL,
updated_by INT NULL,
created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
deleted_at DATETIME(3) NULL,
UNIQUE KEY uk_model_name (model_name),
INDEX idx_is_active (is_active),
INDEX idx_is_default (is_default)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='ตารางเก็บรายการโมเดล AI ที่ให้เลือกใช้งานในระบบ (ADR-027)';
-- Insert default models per ADR-023A
INSERT INTO ai_available_models (model_name, model_version, description, vram_gb, is_active, is_default) VALUES
('gemma4:e2b', 'e2b', 'Gemma 4 E2B - 2-bit quantized, ~2GB VRAM, recommended per ADR-023A', 2.00, TRUE, TRUE),
('gemma4:e4b', 'e4b', 'Gemma 4 E4B - 4-bit quantized, ~4GB VRAM', 4.00, TRUE, FALSE);
-- Add system setting for active model (reference to ai_available_models)
INSERT INTO system_settings (setting_key, setting_value, data_type, category, description, is_public, created_at, updated_at)
SELECT
'AI_ACTIVE_MODEL',
(SELECT model_name FROM ai_available_models WHERE is_default = TRUE LIMIT 1),
'string',
'ai',
'โมเดล AI ที่ใช้งานอยู่ในระบบ (global)',
TRUE,
CURRENT_TIMESTAMP(3),
CURRENT_TIMESTAMP(3)
WHERE NOT EXISTS (SELECT 1 FROM system_settings WHERE setting_key = 'AI_ACTIVE_MODEL');
@@ -0,0 +1,15 @@
-- File: specs/03-Data-and-Storage/deltas/2026-05-25-grant-ai-permissions-to-superadmin.rollback.sql
-- Change Log:
-- - 2026-05-25: Rollback — ลบ ai.* permissions ออกจาก role_id=1 (Superadmin)
-- ==========================================================
DELETE rp FROM role_permissions rp
JOIN permissions p ON rp.permission_id = p.permission_id
WHERE rp.role_id = 1
AND p.permission_name IN (
'ai.suggest',
'ai.rag_query',
'ai.migration_manage',
'ai.audit_log_delete',
'ai.read_analytics',
'ai.delete_audit'
);
@@ -0,0 +1,23 @@
-- File: specs/03-Data-and-Storage/deltas/2026-05-25-grant-ai-permissions-to-superadmin.sql
-- Change Log:
-- - 2026-05-25: Grant ai.* permissions ให้ Superadmin (role_id=1) ที่ขาดหายไปจาก seed
-- ==========================================================
-- Root Cause:
-- Seed script (lcbp3-v1.9.0-seed-permissions.sql) รัน:
-- INSERT INTO role_permissions SELECT 1, permission_id FROM permissions WHERE is_active = 1
-- ก่อนที่ ai.* permissions (permission_id 181-186) จะถูก INSERT เข้า permissions table
-- ทำให้ role_id=1 (Superadmin) ไม่มี ai.* ใน role_permissions
-- ผลกระทบ: migration_bot (user_id=5, role_id=1) ถูก RbacGuard block ที่ POST /api/ai/jobs
-- ==========================================================
INSERT IGNORE INTO role_permissions (role_id, permission_id)
SELECT 1, permission_id
FROM permissions
WHERE permission_name IN (
'ai.suggest',
'ai.rag_query',
'ai.migration_manage',
'ai.audit_log_delete',
'ai.read_analytics',
'ai.delete_audit'
)
AND is_active = 1;
+94 -94
View File
@@ -20,28 +20,28 @@
},
"options": {}
},
"id": "a0346819-4e97-4208-99c8-e4f958d652fe",
"id": "8cf0c7a9-7166-463f-8b8b-b622bf46c605",
"name": "Form Trigger",
"type": "n8n-nodes-base.formTrigger",
"typeVersion": 2.2,
"position": [
-9280,
4400
-11376,
6272
],
"webhookId": "dd44ab55-df6b-4f3b-a740-6a993ca7ded0",
"webhookId": "3698859a-0217-4675-ae92-eb67e4242236",
"notes": "เปิด URL เพื่อตั้งค่าก่อนรัน"
},
{
"parameters": {
"jsCode": "// ============================================\n// CONFIGURATION v2.0 — ADR-023A Compliant\n// n8n เรียกแค่ DMS Backend API เท่านั้น — ห้ามเรียก Ollama โดยตรง\n// ============================================\nconst formData = $('Form Trigger').first()?.json || {};\nconst batchSizeInput = parseInt(String(formData['Batch Size'] || '0'));\nconst excelFileInput = String(formData['Excel File Path'] || '').trim();\nconst jwtTokenInput = 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Im1pZ3JhdGlvbl9ib3QiLCJzdWIiOjUsInNjb3BlIjoiR2xvYmFsIiwiaWF0IjoxNzc5NTQwNDk4LCJleHAiOjQ5MzUzMDA0OTh9.TZVzG8u4Cyz8hi0cU3eGaeXinKWKHN3HtYnDcS5p_5I';\nconst resolvedExcelFile = excelFileInput || '/home/node/.n8n-files/staging_ai/C22024.xlsx';\n\n// Validate Excel file path to prevent path traversal\nif (resolvedExcelFile.includes('..') || resolvedExcelFile.includes('~')) {\n throw new Error('Invalid Excel file path: path traversal detected');\n}\n\nif (!jwtTokenInput || !jwtTokenInput.startsWith('Bearer ')) {\n throw new Error('JWT Token is required and must start with \"Bearer \"');\n}\n\nconst BATCH_ID = (() => {\n // ใช้ Excel filename เป็น BATCH_ID เพื่อให้ checkpoint ทำงานถูกต้อง\n // เปลี่ยน Excel file = BATCH_ID ใหม่ = เริ่มใหม่ (ปลอดภัย)\n // ใช้ Excel file เดิม = BATCH_ID เดิม = resume ได้\n const filename = resolvedExcelFile.split('/').pop().split('\\\\').pop();\n const baseName = filename.replace(/\\.xlsx?$/i, '');\n return baseName + '-MIGRATION';\n})();\n\nconst CONFIG = {\n // Backend Settings\n BACKEND_URL: 'http://backend:3000',\n MIGRATION_TOKEN: jwtTokenInput,\n\n // Batch Settings\n BATCH_SIZE: batchSizeInput > 0 ? batchSizeInput : 10,\n BATCH_ID: BATCH_ID,\n DELAY_MS: 60000,\n\n // AI Job Settings (ADR-023A)\n AI_JOB_POLL_INTERVAL_MS: 5000,\n AI_JOB_TIMEOUT_MS: 120000,\n\n // Confidence Thresholds\n CONFIDENCE_HIGH: 0.85,\n CONFIDENCE_LOW: 0.60,\n\n // Paths (Container paths — ต้องตรงกับ volume mount ใน docker-compose)\n EXCEL_FILE: resolvedExcelFile,\n SOURCE_PDF_DIR: '/home/node/.n8n-files/staging_ai/Incoming/08C.2/2567',\n LOG_PATH: '/home/node/.n8n-files/migration_logs',\n};\n\nreturn [{ json: { config: CONFIG } }];"
},
"id": "65143f3f-b0ee-45e6-b0f9-bf57546b7482",
"id": "c785a235-4992-4396-884d-4c2137fb2304",
"name": "Set Configuration",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-9088,
4400
-11184,
6272
],
"notes": "กำหนดค่า Configuration ทั้งหมด — แก้ไขที่นี่เท่านั้น (ไม่มี Ollama config แล้ว)"
},
@@ -52,13 +52,13 @@
"timeout": 5000
}
},
"id": "b6295d67-9a34-4550-8e96-096a59a88053",
"id": "0c23585c-33e3-4eaa-bc54-3cff5079745f",
"name": "Check Backend Health",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.1,
"position": [
-8912,
4400
-11008,
6272
],
"notes": "ตรวจสอบ Backend พร้อมใช้งาน"
},
@@ -78,13 +78,13 @@
"timeout": 5000
}
},
"id": "92b9007e-9ad1-40f6-8e37-cec5113f6b30",
"id": "69db8e96-2203-47fc-b8a8-c6573c53178e",
"name": "Validate Token",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.1,
"position": [
-8736,
4400
-10832,
6272
],
"notes": "FR-010a: ตรวจสอบ MIGRATION_TOKEN ก่อนเริ่ม Batch"
},
@@ -104,13 +104,13 @@
"timeout": 10000
}
},
"id": "a9c11f42-b63b-412f-b919-88dbc7ab095e",
"id": "c9e3959b-4019-4ec6-b254-4a89156ca0d0",
"name": "Fetch Categories",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.1,
"position": [
-9088,
4592
-11184,
6464
],
"notes": "ดึง Categories จาก Backend"
},
@@ -130,13 +130,13 @@
"timeout": 10000
}
},
"id": "108423e3-90de-49a3-b950-8f78cd231b84",
"id": "e3ed851e-1ef2-42d4-84bd-df29cfe6e13e",
"name": "Fetch Tags",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.1,
"position": [
-8912,
4592
-11008,
6464
],
"notes": "ดึง Tags ที่มีอยู่แล้วจาก Backend"
},
@@ -144,13 +144,13 @@
"parameters": {
"jsCode": "const fs = require('fs');\nconst config = $('Set Configuration').first().json.config;\n\ntry {\n if (!fs.existsSync(config.EXCEL_FILE)) {\n throw new Error(`Excel file not found at: ${config.EXCEL_FILE}`);\n }\n if (!fs.existsSync(config.SOURCE_PDF_DIR)) {\n throw new Error(`PDF Source directory not found at: ${config.SOURCE_PDF_DIR}`);\n }\n \n const files = fs.readdirSync(config.SOURCE_PDF_DIR);\n \n // Check write permission to log path\n fs.writeFileSync(`${config.LOG_PATH}/.preflight_ok`, new Date().toISOString());\n \n // Grab categories\n let categories = ['Correspondence','RFA','Drawing','Transmittal','Report','Other'];\n try {\n const upstreamData = $('Fetch Categories').first()?.json?.data;\n if (upstreamData && Array.isArray(upstreamData)) {\n categories = upstreamData.map(c => c.name || c.type || c);\n }\n } catch(e) {}\n \n // Grab existing tags\n let existingTags = [];\n try {\n const tagData = $('Fetch Tags').first()?.json?.data || [];\n existingTags = Array.isArray(tagData)\n ? tagData.map(t => ({ publicId: t.publicId, tagName: t.tag_name || t.name || '' })).filter(t => t.tagName && t.publicId)\n : [];\n } catch(e) {}\n \n return [{ json: { \n preflight_ok: true, \n pdf_count_in_source: files.length,\n excel_target: config.EXCEL_FILE,\n system_categories: categories,\n existing_tags: existingTags,\n timestamp: new Date().toISOString()\n }}];\n} catch (err) {\n throw new Error(`Pre-flight check failed: ${err.message}`);\n}"
},
"id": "d8e2efec-7fb4-44c3-b544-0dc51dcfd3fc",
"id": "6568ed41-da80-4f00-9378-905cc95d86f0",
"name": "File Mount Check",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-8736,
4592
-10832,
6464
],
"notes": "ตรวจสอบ File System + รวบรวม master data สำหรับ AI payload"
},
@@ -159,13 +159,13 @@
"fileSelector": "={{ $json.excel_target }}",
"options": {}
},
"id": "c2a6ca17-aae8-4213-93ab-8179e1febfb3",
"id": "1802f33d-7fc6-42cb-897b-e312dc7c9626",
"name": "Read Excel Binary",
"type": "n8n-nodes-base.readWriteFile",
"typeVersion": 1,
"position": [
-9088,
4784
-11184,
6656
],
"notes": "ดึงไฟล์ Excel ขึ้นมาไว้ในหน่วยความจำ"
},
@@ -173,13 +173,13 @@
"parameters": {
"options": {}
},
"id": "ebfdde99-92a1-4060-9867-2f330b13554f",
"id": "af6a69f5-7f04-40f6-9608-1cfe231ee85f",
"name": "Read Excel",
"type": "n8n-nodes-base.spreadsheetFile",
"typeVersion": 2,
"position": [
-8912,
4784
-11008,
6656
],
"notes": "แปลงข้อมูล Excel เป็น JSON Data"
},
@@ -199,13 +199,13 @@
"timeout": 10000
}
},
"id": "9cf3971c-287a-42ab-9327-72e1a4d8011c",
"id": "ebddd4ee-0f4e-471b-9f01-179dbcb7f0ba",
"name": "Read Checkpoint",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.1,
"position": [
-8720,
4784
-10832,
6672
],
"alwaysOutputData": true,
"notes": "GET /api/ai/migration/checkpoint/:batchId — ADR-023A (ไม่ใช้ MySQL โดยตรง)"
@@ -214,13 +214,13 @@
"parameters": {
"jsCode": "const cpJson = $input.first()?.json || {};\nconst startIndex = cpJson.data?.lastProcessedIndex ?? cpJson.lastProcessedIndex ?? cpJson.data?.last_processed_index ?? cpJson.last_processed_index ?? 0;\nconst config = $('Set Configuration').first().json.config;\n\nconst allItems = $('Read Excel').all().map(i => i.json);\nconst remaining = allItems.slice(startIndex);\nconst currentBatch = remaining.slice(0, config.BATCH_SIZE);\n\n// Exit condition: ไม่มี records เหลือ\nif (currentBatch.length === 0) {\n return [{ json: { batch_complete: true, total_processed: startIndex } }];\n}\n\nconst normalize = (str) => {\n if (!str) return '';\n return String(str).normalize('NFC').trim();\n};\n\nreturn currentBatch.map((item, i) => {\n const getVal = (possibleKeys) => {\n const exactMatch = possibleKeys.find(k => item[k] !== undefined);\n if (exactMatch) return item[exactMatch];\n const lowerTrimmedKeys = Object.keys(item).map(k => ({ original: k, parsed: k.toLowerCase().trim() }));\n for (const pk of possibleKeys) {\n const found = lowerTrimmedKeys.find(k => k.parsed === pk.toLowerCase().trim());\n if (found) return item[found.original];\n }\n return '';\n };\n\n const docNum = getVal(['document_number', 'correspondence_number', 'Document Number', 'Corr. No.']);\n const excelFileName = getVal(['File name', 'file_name', 'File Name', 'filename']);\n \n if (!excelFileName) {\n throw new Error(`Missing 'File name' column for row ${i + startIndex + 1}, document: ${docNum}`);\n }\n \n return {\n json: {\n batch_complete: false,\n document_number: normalize(docNum),\n title: normalize(getVal(['title', 'Title', 'Subject', 'subject'])),\n legacy_number: normalize(getVal(['legacy_number', 'Legacy Number', 'Response Doc.'])),\n excel_revision: getVal(['revision', 'Revision', 'rev']) || 1,\n original_index: startIndex + i,\n batch_id: config.BATCH_ID,\n file_name: normalize(excelFileName),\n issued_date: normalize(getVal(['issued_date', 'Issued_date', 'Issued Date', 'date', 'Date'])),\n received_date: normalize(getVal(['received_date', 'Received_date', 'Received Date'])),\n sender: normalize(getVal(['sender', 'Sender', 'From', 'from'])),\n receiver: normalize(getVal(['receiver', 'Receiver', 'To', 'to'])),\n }\n };\n});"
},
"id": "c101543e-a3a2-4538-a99c-0419df50948f",
"id": "b488f4ac-4864-4a16-ac26-71bd850fa49b",
"name": "Process Batch + Encoding",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-8528,
4400
-11184,
6880
],
"alwaysOutputData": true,
"notes": "ตัด Batch + Normalize UTF-8 + Exit condition เมื่อ records หมด"
@@ -229,13 +229,13 @@
"parameters": {
"jsCode": "const fs = require('fs');\nconst path = require('path');\nconst config = $('Set Configuration').first().json.config;\n\nconst items = $input.all();\nif (!items || items.length === 0) return [];\n\n// ตรวจสอบ exit condition\nif (items[0]?.json?.batch_complete === true) {\n return items;\n}\n\nconst validated = [];\nconst errors = [];\n\nfor (const item of items) {\n const fileName = item.json?.file_name;\n if (!fileName) {\n errors.push({ ...item, json: { ...item.json, file_valid: false, error: 'file_name is missing', error_type: 'MISSING_FILENAME' } });\n continue;\n }\n \n let safeName = path.basename(String(fileName)).normalize('NFC');\n if (!safeName.toLowerCase().endsWith('.pdf')) safeName += '.pdf';\n const filePath = path.resolve(config.SOURCE_PDF_DIR, safeName);\n \n if (!filePath.startsWith(path.resolve(config.SOURCE_PDF_DIR))) {\n errors.push({ ...item, json: { ...item.json, file_valid: false, error: 'Path traversal detected', error_type: 'SECURITY' } });\n continue;\n }\n \n try {\n if (fs.existsSync(filePath)) {\n const stats = fs.statSync(filePath);\n validated.push({ ...item, json: { ...item.json, file_valid: true, file_path: filePath, file_size: stats.size } });\n } else {\n errors.push({ ...item, json: { ...item.json, file_valid: false, error: `File not found: ${safeName}`, error_type: 'FILE_NOT_FOUND' } });\n }\n } catch (err) {\n errors.push({ ...item, json: { ...item.json, file_valid: false, error: err.message, error_type: 'FILE_ERROR' } });\n }\n}\n\nreturn [...validated, ...errors];"
},
"id": "282e1c8a-74b6-4ae7-a50b-3e2ff7b1558d",
"id": "e831cd28-880c-4f93-99fc-901383d1cda3",
"name": "File Validator",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-8512,
4800
-10640,
6272
],
"notes": "ตรวจสอบไฟล์ PDF ใน Directory"
},
@@ -244,13 +244,13 @@
"fileSelector": "={{ $json.file_path }}",
"options": {}
},
"id": "ab2d3ff8-9862-4a5c-a21d-5e04004d6a40",
"id": "20625b33-17e8-4730-94f1-37c9b6c24df9",
"name": "Read PDF File",
"type": "n8n-nodes-base.readWriteFile",
"typeVersion": 1,
"position": [
-8288,
4640
-10640,
6464
],
"onError": "continueErrorOutput",
"notes": "อ่าน PDF Binary เพื่อเตรียม Upload"
@@ -287,13 +287,13 @@
"timeout": 300000
}
},
"id": "0346a124-aaf3-49de-9181-77cb5e80a4d3",
"id": "2677c8b3-223a-4952-b4e2-41b92bd7f059",
"name": "Upload PDF to Backend",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
-8080,
4400
-10416,
6272
],
"onError": "continueErrorOutput",
"notes": "ADR-023A: อัพโหลด PDF เข้า Backend Temp Storage → รับ temp_attachment_id"
@@ -302,13 +302,13 @@
"parameters": {
"jsCode": "const config = $('Set Configuration').first().json.config;\nconst uploadResponse = $input.first()?.json || {};\nconst metaItem = $('File Validator').all()[$input.context?.itemIndex ?? 0]?.json || {};\nconst mountCheckData = $('File Mount Check').first()?.json || {};\n\n// Backend returns { data: { publicId, tempId, ... } } per ADR-019 (id is @Exclude'd)\nconst tempAttachmentPublicId = uploadResponse?.data?.publicId || uploadResponse?.publicId;\nif (!tempAttachmentPublicId) {\n throw new Error(`Upload failed — no temp_attachment_public_id returned. Upload response: ${JSON.stringify(uploadResponse)}`);\n}\n\n// Validate required fields per DTO\nconst docNumber = String(metaItem.document_number || '').trim();\nconst docTitle = String(metaItem.title || '').trim();\nif (!docNumber) {\n throw new Error(`document_number is empty for item: ${JSON.stringify(metaItem)}`);\n}\nif (!docTitle) {\n throw new Error(`title is empty for document: ${docNumber}`);\n}\n\n// Normalize existingTags to match TagOptionDto (tagName is required)\nconst existingTags = (mountCheckData.existing_tags || [])\n .filter(t => t.tagName && t.tagName.trim())\n .map(t => ({\n publicId: t.publicId || undefined,\n tagName: String(t.tagName).trim(),\n colorCode: t.colorCode || undefined,\n }));\n\n// สร้าง SubmitAiJobDto ตาม Backend DTO\nconst submitPayload = {\n type: 'migrate-document',\n payload: {\n tempAttachmentId: String(tempAttachmentPublicId),\n documentNumber: docNumber,\n title: docTitle,\n batchId: String(config.BATCH_ID),\n existingTags: existingTags,\n systemCategories: mountCheckData.system_categories || [],\n }\n};\n\n// Idempotency-Key: deterministic format ตาม FR-001a\nconst idempotencyKey = `${config.BATCH_ID}:${docNumber}`;\n\nreturn [{\n json: {\n ...metaItem,\n temp_attachment_public_id: tempAttachmentPublicId,\n submit_payload: submitPayload,\n idempotency_key: idempotencyKey,\n }\n}];"
},
"id": "1b7e6adb-b7e2-40dd-b472-3a5a79861bfb",
"id": "fad3f363-a595-4a43-a48f-cefb258c2687",
"name": "Build AI Job Payload",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-7888,
4304
-10400,
6496
],
"notes": "สร้าง SubmitAiJobDto payload + Idempotency-Key ตาม FR-001a"
},
@@ -331,18 +331,18 @@
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ $json.submit_payload }}",
"jsonBody": "={{ JSON.stringify($json.submit_payload) }}",
"options": {
"timeout": 30000
}
},
"id": "f2a5ee75-cd14-4118-925d-43182735aa75",
"id": "7fb24018-9739-4ae9-8650-d118d73e38d7",
"name": "Submit AI Job",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.1,
"position": [
-7744,
4384
-10400,
6672
],
"onError": "continueErrorOutput",
"notes": "ADR-023A: POST /api/ai/jobs — Backend จัดการ Ollama ผ่าน BullMQ"
@@ -351,13 +351,13 @@
"parameters": {
"jsCode": "// Poll GET /api/ai/jobs/{jobId} ทุก 5 วินาที จน status = completed หรือ timeout 120 วินาที\nconst config = $('Set Configuration').first().json.config;\nconst submitResponse = $input.first()?.json || {};\nconst originalMeta = $('Build AI Job Payload').all()[$input.context?.itemIndex ?? 0]?.json || {};\n\nconst jobId = submitResponse?.jobId || submitResponse?.data?.jobId;\nif (!jobId) {\n // FR-010b: ถ้า 401 → mark TOKEN_EXPIRED\n if (submitResponse?.statusCode === 401 || submitResponse?.error?.status === 401) {\n throw new Error('TOKEN_EXPIRED: 401 Unauthorized — กรุณา renew MIGRATION_TOKEN แล้ว resume');\n }\n throw new Error(`Submit AI Job failed — no jobId returned for: ${originalMeta.document_number}`);\n}\n\nconst pollIntervalMs = config.AI_JOB_POLL_INTERVAL_MS || 5000;\nconst timeoutMs = config.AI_JOB_TIMEOUT_MS || 120000;\nconst startTime = Date.now();\n\nwhile (true) {\n if (Date.now() - startTime > timeoutMs) {\n throw new Error(`AI Job timeout after ${timeoutMs}ms for jobId: ${jobId}, document: ${originalMeta.document_number}`);\n }\n \n const statusResponse = await $helpers.httpRequest({\n method: 'GET',\n url: `${config.BACKEND_URL}/api/ai/jobs/${jobId}`,\n headers: { 'Authorization': config.MIGRATION_TOKEN },\n json: true\n });\n \n const status = statusResponse?.status || statusResponse?.data?.status;\n \n if (status === 'completed') {\n return [{\n json: {\n ...originalMeta,\n job_id: jobId,\n ai_result: statusResponse?.result || statusResponse?.data?.result || {},\n ai_status: 'completed',\n }\n }];\n }\n \n if (status === 'failed') {\n return [{\n json: {\n ...originalMeta,\n job_id: jobId,\n ai_status: 'failed',\n parse_error: `AI Job failed: ${JSON.stringify(statusResponse?.error || statusResponse?.data?.error || 'unknown')}`,\n error_type: 'AI_JOB_FAILED',\n route_index: 3\n }\n }];\n }\n \n // status = 'processing' — รอแล้ว poll ใหม่\n await new Promise(resolve => setTimeout(resolve, pollIntervalMs));\n}"
},
"id": "f95bbb5f-6bde-4146-b577-8d824776029e",
"id": "2abd110c-292e-46e2-9033-18d38963161d",
"name": "Poll AI Job Status",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-7552,
4368
-10208,
6272
],
"notes": "Poll GET /api/ai/jobs/{jobId} ทุก 5 วินาที (timeout 120 วินาที)"
},
@@ -365,13 +365,13 @@
"parameters": {
"jsCode": "const config = $('Set Configuration').first().json.config;\nconst items = $input.all();\nconst results = [];\n\nconst CATEGORY_TO_TYPE_CODE = {\n 'Correspondence': 'LETTER',\n 'RFA': 'RFA',\n 'Transmittal': 'TRANSMITTAL',\n 'Drawing': 'OTHER',\n 'Report': 'OTHER',\n 'Other': 'OTHER',\n};\n\nfor (const item of items) {\n const data = item.json;\n \n // ถ้า poll ส่ง error กลับมาโดยตรง (route_index = 3)\n if (data.route_index === 3 || data.ai_status === 'failed') {\n results.push({ json: { ...data, route_index: 3 } });\n continue;\n }\n \n const ai = data.ai_result || {};\n \n if (!ai || typeof ai !== 'object') {\n results.push({ json: { ...data, parse_error: 'Empty or invalid ai_result', error_type: 'PARSE_ERROR', route_index: 3 } });\n continue;\n }\n \n // Enum Validation for Category\n const systemCategories = ['Correspondence','RFA','Drawing','Transmittal','Report','Other'];\n let finalCategory = ai.suggested_category || ai.category || 'Correspondence';\n if (!systemCategories.includes(finalCategory)) {\n finalCategory = String(data.document_number || '').includes('-RFA-') ? 'RFA' : 'Correspondence';\n }\n const typeCode = CATEGORY_TO_TYPE_CODE[finalCategory] || 'LETTER';\n \n // Tag normalization — is_new flag\n const suggestedTags = Array.isArray(ai.suggested_tags)\n ? ai.suggested_tags.map(t => ({\n tagName: String(t.tagName || t.tag_name || ''),\n publicId: t.publicId || t.public_id || undefined,\n isNew: Boolean(t.isNew ?? t.is_new ?? !t.publicId),\n colorCode: t.colorCode || t.color_code || 'default',\n })).filter(t => t.tagName)\n : [];\n \n const confidence = Number(ai.confidence || 0);\n \n const normalizedAi = {\n ...ai,\n suggested_category: finalCategory,\n type_code: typeCode,\n confidence,\n suggested_tags: suggestedTags,\n };\n \n let route_index;\n let review_reason = '';\n let reject_reason = '';\n \n if (confidence >= config.CONFIDENCE_HIGH && ai.is_valid !== false) {\n route_index = 0; // Auto Ready\n } else if (confidence >= config.CONFIDENCE_LOW) {\n route_index = 1; // Flagged\n review_reason = `Confidence ${confidence.toFixed(2)} < ${config.CONFIDENCE_HIGH}`;\n } else {\n route_index = 2; // Rejected\n reject_reason = ai.is_valid === false ? 'AI marked invalid' : `Confidence ${confidence.toFixed(2)} < ${config.CONFIDENCE_LOW}`;\n }\n \n results.push({\n json: {\n ...data,\n ai_result: normalizedAi,\n route_index,\n review_reason,\n reject_reason,\n }\n });\n}\n\nreturn results;"
},
"id": "6bdcd7fd-0a8a-4f21-91bd-ed5fcbda860c",
"id": "419d8d1a-06c7-4cca-b1ed-53cf0a632c7e",
"name": "Parse & Validate AI Response",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-7376,
4368
-10208,
6496
],
"notes": "Parse AI result + Confidence routing prep + Tag normalization (isNew flag)"
},
@@ -479,13 +479,13 @@
},
"options": {}
},
"id": "35ad90cd-bb71-44a6-8851-ae4ea6ed4747",
"id": "5abd2557-cb16-4102-aca3-db8e8d009286",
"name": "Route by Confidence",
"type": "n8n-nodes-base.switch",
"typeVersion": 3.2,
"position": [
-7216,
4512
-10048,
6560
]
},
{
@@ -512,13 +512,13 @@
"timeout": 10000
}
},
"id": "f26c6512-8f2d-4cca-a643-65ae7255c37a",
"id": "48a0cc23-8219-45bc-b68b-4bf37ca68364",
"name": "Insert Review Queue (Auto)",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.1,
"position": [
-6976,
4496
-9888,
6272
],
"onError": "continueErrorOutput",
"notes": "POST /api/ai/migration/queue/record (PENDING) — ADR-023A"
@@ -547,13 +547,13 @@
"timeout": 10000
}
},
"id": "b720ce71-d0f4-402a-9bd3-077cf1730977",
"id": "2fc51220-e960-4ba5-a8ba-72dc7d786359",
"name": "Insert Review Queue (Flagged)",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.1,
"position": [
-6944,
4720
-9808,
6576
],
"onError": "continueErrorOutput",
"notes": "POST /api/ai/migration/queue/record (PENDING_REVIEW) — ADR-023A"
@@ -562,13 +562,13 @@
"parameters": {
"jsCode": "const fs = require('fs');\nconst item = $input.first();\nconst config = $('Set Configuration').first().json.config;\n\nconst csvPath = `${config.LOG_PATH}/reject_log_${config.BATCH_ID}.csv`;\nconst header = 'timestamp,document_number,title,reject_reason,ai_confidence,job_id\\n';\nconst esc = (s) => `\"${String(s || '').replace(/\"/g, '\"\"')}\"`;\n\nif (!fs.existsSync(csvPath)) {\n fs.writeFileSync(csvPath, header, 'utf8');\n}\n\nconst line = [\n new Date().toISOString(),\n esc(item.json.document_number),\n esc(item.json.title),\n esc(item.json.reject_reason),\n item.json.ai_result?.confidence ?? 'N/A',\n esc(item.json.job_id || '')\n].join(',') + '\\n';\n\nfs.appendFileSync(csvPath, line, 'utf8');\n\nreturn [$input.first()];"
},
"id": "6e83de99-1244-4478-9a33-e779cdb9504a",
"id": "62c48542-9b36-4db3-83ef-23bf1af39875",
"name": "Log Reject to CSV",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-6944,
4848
-9808,
6784
],
"notes": "บันทึกรายการที่ถูกปฏิเสธลง CSV"
},
@@ -576,13 +576,13 @@
"parameters": {
"jsCode": "const fs = require('fs');\nconst items = $input.all();\nconst config = $('Set Configuration').first().json.config;\n\nconst ERROR_TYPE_MAP = {\n AI_JOB_FAILED: 'API_ERROR',\n PARSE_ERROR: 'AI_PARSE_ERROR',\n TOKEN_EXPIRED: 'API_ERROR',\n};\nconst ALLOWED_ERROR_TYPES = new Set([\n 'FILE_NOT_FOUND',\n 'MISSING_FILENAME',\n 'FILE_ERROR',\n 'AI_PARSE_ERROR',\n 'API_ERROR',\n 'DB_ERROR',\n 'SECURITY',\n 'UNKNOWN',\n]);\nconst normalizeErrorType = (type) => {\n const mappedType = ERROR_TYPE_MAP[type] || type || 'UNKNOWN';\n return ALLOWED_ERROR_TYPES.has(mappedType) ? mappedType : 'UNKNOWN';\n};\n\nconst csvPath = `${config.LOG_PATH}/error_log_${config.BATCH_ID}.csv`;\nconst header = 'timestamp,document_number,error_type,error_message,job_id\\n';\nconst esc = (s) => `\"${String(s || '').replace(/\"/g, '\"\"')}\"`;\n\nif (!fs.existsSync(csvPath)) {\n fs.writeFileSync(csvPath, header, 'utf8');\n}\n\nfor (const item of items) {\n item.json.document_number = item.json.document_number || 'WORKFLOW';\n item.json.error_type = normalizeErrorType(item.json.error_type);\n item.json.error = item.json.error || item.json.parse_error || item.json.message || '';\n item.json.job_id = item.json.job_id || '';\n\n const line = [\n new Date().toISOString(),\n esc(item.json.document_number),\n esc(item.json.error_type),\n esc(item.json.error),\n esc(item.json.job_id)\n ].join(',') + '\\n';\n fs.appendFileSync(csvPath, line, 'utf8');\n}\n\nreturn items;"
},
"id": "511428dc-3aad-4de1-a9dc-9a87c791371e",
"id": "724da26e-5c71-4146-8a4c-a16299dfae17",
"name": "Log Error to CSV",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-7936,
4800
-10416,
6864
],
"notes": "บันทึก Error ลง CSV"
},
@@ -610,13 +610,13 @@
"timeout": 10000
}
},
"id": "7e8f3617-a6e4-4d40-922d-eb93ae91e690",
"id": "aa49b719-9133-4ab8-af24-27754c4b4cd2",
"name": "Log Error to DB",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.1,
"position": [
-7232,
4944
-10208,
6864
],
"onError": "continueErrorOutput",
"notes": "POST /api/ai/migration/errors — ADR-023A"
@@ -645,13 +645,13 @@
"timeout": 10000
}
},
"id": "9471990c-1abe-42f8-8062-bd68cb9ad985",
"id": "702e0d86-786c-4731-89bb-a7b80aa4d425",
"name": "Save Checkpoint",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.1,
"position": [
-6784,
4640
-9664,
6384
],
"onError": "continueErrorOutput",
"notes": "POST /api/ai/migration/checkpoint — ADR-023A"
@@ -661,13 +661,13 @@
"amount": "={{$('Set Configuration').first().json.config.DELAY_MS / 1000}}",
"unit": "seconds"
},
"id": "fcf3e098-93f6-42ee-b930-aa0bc84d3ed7",
"id": "f8eadb50-d241-423c-a62e-4c87fd96c3be",
"name": "Delay",
"type": "n8n-nodes-base.wait",
"typeVersion": 1,
"position": [
-6640,
4880
-9536,
6880
],
"webhookId": "38e97a99-4dcc-4b63-977a-a02945a1c369",
"notes": "หน่วงเวลาระหว่าง Records"
@@ -728,13 +728,13 @@
},
"options": {}
},
"id": "e489d28d-37e8-4204-bba8-07a5226b1275",
"id": "0ef559e7-8ffc-4bf5-b6ef-2d5e2d44d57a",
"name": "Check Batch Complete",
"type": "n8n-nodes-base.switch",
"typeVersion": 3.2,
"position": [
-8512,
4608
-11008,
6880
]
},
{
@@ -761,13 +761,13 @@
"timeout": 10000
}
},
"id": "171b627f-3b00-4fbf-86b7-6076fdc29d19",
"id": "c059f49b-a184-43ff-aef4-a84077c10524",
"name": "Mark Batch Complete",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.1,
"position": [
-8304,
4400
-10784,
6864
],
"notes": "Update checkpoint status to COMPLETED when batch finishes"
}
@@ -1154,24 +1154,24 @@
"executionOrder": "v1",
"binaryMode": "separate"
},
"versionId": "efadd20e-a46b-4354-8f75-ec1de215d065",
"versionId": "62952ca6-0590-44c3-a53e-9909eea209c7",
"meta": {
"templateCredsSetupCompleted": true,
"instanceId": "9e70e47c1eaf3bac72f497ddfbde0983f840f7d0f059537f7e37dd70de18ecb7"
},
"id": "4LlPbAKU5BZLgiTg",
"tags": [
{
"updatedAt": "2026-05-23T12:26:47.389Z",
"createdAt": "2026-05-23T12:26:47.389Z",
"id": "jNSEtctPbU5leFPw",
"name": "v2"
},
{
"updatedAt": "2026-05-23T12:26:47.393Z",
"createdAt": "2026-05-23T12:26:47.393Z",
"id": "mGZTyPfxbcsAuFpR",
"name": "migration"
},
{
"updatedAt": "2026-05-23T12:26:47.389Z",
"createdAt": "2026-05-23T12:26:47.389Z",
"id": "jNSEtctPbU5leFPw",
"name": "v2"
}
]
}
}