690525:1320 ADR-028-228-migration #06
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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');
|
||||
+15
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user