From 001237ea35d4b507d2bdcbb5a2700ac9607197e9 Mon Sep 17 00:00:00 2001 From: admin Date: Mon, 25 May 2026 13:20:17 +0700 Subject: [PATCH] 690525:1320 ADR-028-228-migration #06 --- .../file-storage/file-storage.service.spec.ts | 7 + .../file-storage/file-storage.service.ts | 31 ++- .../modules/ai/ai-settings.service.spec.ts | 14 ++ backend/src/modules/ai/ai-settings.service.ts | 176 ++++++++++++++++ backend/src/modules/ai/ai.controller.ts | 110 ++++++++++ backend/src/modules/ai/ai.module.ts | 3 + .../ai/entities/ai-available-model.entity.ts | 61 ++++++ .../ai/processors/ai-batch.processor.ts | 6 +- .../ai/processors/ai-realtime.processor.ts | 4 +- .../src/modules/ai/services/ollama.service.ts | 54 +++-- frontend/app/(admin)/admin/ai/page.tsx | 148 +++++++++++++- frontend/lib/services/admin-ai.service.ts | 55 +++++ memory/agent-memory.md | 149 +++++++++++++- ...25-create-ai-available-models.rollback.sql | 8 + .../2026-05-25-create-ai-available-models.sql | 43 ++++ ...-ai-permissions-to-superadmin.rollback.sql | 15 ++ ...-25-grant-ai-permissions-to-superadmin.sql | 23 +++ .../03-Data-and-Storage/n8n.workflow.v2.json | 188 +++++++++--------- 18 files changed, 967 insertions(+), 128 deletions(-) create mode 100644 backend/src/modules/ai/entities/ai-available-model.entity.ts create mode 100644 specs/03-Data-and-Storage/deltas/2026-05-25-create-ai-available-models.rollback.sql create mode 100644 specs/03-Data-and-Storage/deltas/2026-05-25-create-ai-available-models.sql create mode 100644 specs/03-Data-and-Storage/deltas/2026-05-25-grant-ai-permissions-to-superadmin.rollback.sql create mode 100644 specs/03-Data-and-Storage/deltas/2026-05-25-grant-ai-permissions-to-superadmin.sql diff --git a/backend/src/common/file-storage/file-storage.service.spec.ts b/backend/src/common/file-storage/file-storage.service.spec.ts index 9f8ecac2..e3173e34 100644 --- a/backend/src/common/file-storage/file-storage.service.spec.ts +++ b/backend/src/common/file-storage/file-storage.service.spec.ts @@ -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), + })), }, }, { diff --git a/backend/src/common/file-storage/file-storage.service.ts b/backend/src/common/file-storage/file-storage.service.ts index 27a80321..28a27cd0 100644 --- a/backend/src/common/file-storage/file-storage.service.ts +++ b/backend/src/common/file-storage/file-storage.service.ts @@ -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 { - 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, diff --git a/backend/src/modules/ai/ai-settings.service.spec.ts b/backend/src/modules/ai/ai-settings.service.spec.ts index 5ef43dc7..63c94c84 100644 --- a/backend/src/modules/ai/ai-settings.service.spec.ts +++ b/backend/src/modules/ai/ai-settings.service.spec.ts @@ -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(); diff --git a/backend/src/modules/ai/ai-settings.service.ts b/backend/src/modules/ai/ai-settings.service.ts index eb08b0ca..60bb08dd 100644 --- a/backend/src/modules/ai/ai-settings.service.ts +++ b/backend/src/modules/ai/ai-settings.service.ts @@ -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, + @InjectRepository(AiAvailableModel) + private readonly modelRepo: Repository, @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 { + return this.modelRepo.find({ + order: { isDefault: 'DESC', modelName: 'ASC' }, + }); + } + + /** ดึงรายการโมเดล AI ที่ active เท่านั้น */ + async getActiveModels(): Promise { + return this.modelRepo.find({ + where: { isActive: true }, + order: { modelName: 'ASC' }, + }); + } + + /** ดึงโมเดล AI ที่ใช้งานอยู่ปัจจุบัน (active model) */ + async getActiveModel(): Promise { + 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 { + 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 => { + 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 { + 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 { + 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 { + 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; + } } diff --git a/backend/src/modules/ai/ai.controller.ts b/backend/src/modules/ai/ai.controller.ts index f416aed0..91d6759a 100644 --- a/backend/src/modules/ai/ai.controller.ts +++ b/backend/src/modules/ai/ai.controller.ts @@ -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 { + await this.aiSettingsService.removeModel(modelName, user.user_id); + } + @Get('admin/health') @UseGuards(JwtAuthGuard, RbacGuard) @ApiBearerAuth() diff --git a/backend/src/modules/ai/ai.module.ts b/backend/src/modules/ai/ai.module.ts index ff5744f3..58401a1b 100644 --- a/backend/src/modules/ai/ai.module.ts +++ b/backend/src/modules/ai/ai.module.ts @@ -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, diff --git a/backend/src/modules/ai/entities/ai-available-model.entity.ts b/backend/src/modules/ai/entities/ai-available-model.entity.ts new file mode 100644 index 00000000..b8df6563 --- /dev/null +++ b/backend/src/modules/ai/entities/ai-available-model.entity.ts @@ -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; +} diff --git a/backend/src/modules/ai/processors/ai-batch.processor.ts b/backend/src/modules/ai/processors/ai-batch.processor.ts index 84bae441..899bff7d 100644 --- a/backend/src/modules/ai/processors/ai-batch.processor.ts +++ b/backend/src/modules/ai/processors/ai-batch.processor.ts @@ -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, diff --git a/backend/src/modules/ai/processors/ai-realtime.processor.ts b/backend/src/modules/ai/processors/ai-realtime.processor.ts index dd343a93..6a025d26 100644 --- a/backend/src/modules/ai/processors/ai-realtime.processor.ts +++ b/backend/src/modules/ai/processors/ai-realtime.processor.ts @@ -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), diff --git a/backend/src/modules/ai/services/ollama.service.ts b/backend/src/modules/ai/services/ollama.service.ts index 1ad642b7..4f82b9a9 100644 --- a/backend/src/modules/ai/services/ollama.service.ts +++ b/backend/src/modules/ai/services/ollama.service.ts @@ -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( 'OLLAMA_URL', this.configService.get('AI_HOST_URL', 'http://localhost:11434') ); - this.mainModel = this.configService.get( + // Default fallback model (ADR-023A: gemma4:e2b) + this.defaultMainModel = this.configService.get( 'OLLAMA_MODEL_MAIN', - 'gemma4:e4b' + 'gemma4:e2b' ); this.embedModel = this.configService.get( 'OLLAMA_MODEL_EMBED', @@ -37,16 +44,31 @@ export class OllamaService { this.timeoutMs = this.configService.get('AI_TIMEOUT_MS', 30000); } - /** สร้างข้อความตอบกลับจาก gemma4:e4b หรือค่า ENV ที่กำหนด */ + /** ดึงชื่อโมเดลที่ใช้งานอยู่ (จาก DB หรือ ENV fallback) */ + private async getActiveModelName(): Promise { + 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 { + 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 { + 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, }; } diff --git a/frontend/app/(admin)/admin/ai/page.tsx b/frontend/app/(admin)/admin/ai/page.tsx index 9ca5ff43..f6a468f5 100644 --- a/frontend/app/(admin)/admin/ai/page.tsx +++ b/frontend/app/(admin)/admin/ai/page.tsx @@ -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(false); const [ocrProgress, setOcrProgress] = useState(0); const [ocrStatusText, setOcrStatusText] = useState(''); + + // 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({ queryKey: ['admin-sandbox-projects'], queryFn: async () => { @@ -58,6 +70,37 @@ export default function AiAdminConsolePage() { const handleToggle = async (enabled: boolean): Promise => { await toggleMutation.mutateAsync(enabled); }; + + const handleModelChange = async (modelName: string): Promise => { + try { + await adminAiService.setActiveModel(modelName); + toast.success(`เปลี่ยนโมเดลเป็น ${modelName} สำเร็จ`); + await refetchModels(); + } catch { + toast.error('ไม่สามารถเปลี่ยนโมเดลได้'); + } + }; + + const handleToggleModel = async (modelName: string): Promise => { + try { + await adminAiService.toggleModelActive(modelName); + toast.success(`เปลี่ยนสถานะโมเดล ${modelName} สำเร็จ`); + await refetchModels(); + } catch { + toast.error('ไม่สามารถเปลี่ยนสถานะโมเดลได้'); + } + }; + + const handleRemoveModel = async (modelName: string): Promise => { + if (!confirm(`ต้องการลบโมเดล ${modelName} ใช่หรือไม่?`)) return; + try { + await adminAiService.removeModel(modelName); + toast.success(`ลบโมเดล ${modelName} สำเร็จ`); + await refetchModels(); + } catch { + toast.error('ไม่สามารถลบโมเดลได้'); + } + }; const handleRefreshAll = async (): Promise => { await Promise.all([refetch(), refetchHealth()]); }; @@ -389,6 +432,107 @@ export default function AiAdminConsolePage() { )} + + {/* AI Model Management Card (ADR-027) */} + + + + + AI Model Management + ADR-027 + + + +
+
+ + +
+
+ โมเดลปัจจุบัน: {activeModel || 'Loading...'} +
+
+ +
+

รายการโมเดลทั้งหมด

+
+ {availableModels.length === 0 ? ( +

ไม่มีโมเดลในระบบ

+ ) : ( + availableModels.map((model) => ( +
+
+ + {model.isActive ? 'Active' : 'Inactive'} + + {model.modelName} + {model.isDefault && ( + Default + )} + {activeModel === model.modelName && ( + Current + )} +
+
+ {!model.isDefault && ( + <> + + + + )} +
+
+ )) + )} +
+
+
+
+
diff --git a/frontend/lib/services/admin-ai.service.ts b/frontend/lib/services/admin-ai.service.ts index 754e1819..eb0427f5 100644 --- a/frontend/lib/services/admin-ai.service.ts +++ b/frontend/lib/services/admin-ai.service.ts @@ -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 = (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 => { + const { data } = await api.get('/ai/admin/models'); + return extractData(data); + }, + + getActiveModel: async (): Promise => { + const { data } = await api.get('/ai/admin/models/active'); + return extractData(data); + }, + + setActiveModel: async (modelName: string): Promise => { + const { data } = await api.post('/ai/admin/models/active', { modelName }); + return extractData(data); + }, + + addModel: async ( + model: Omit + ): 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 => { + await api.delete(`/ai/admin/models/${encodeURIComponent(modelName)}`); + }, }; diff --git a/memory/agent-memory.md b/memory/agent-memory.md index c186d2d5..2c591828 100644 --- a/memory/agent-memory.md +++ b/memory/agent-memory.md @@ -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 = ← ถูกต้อง +``` + +#### 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` diff --git a/specs/03-Data-and-Storage/deltas/2026-05-25-create-ai-available-models.rollback.sql b/specs/03-Data-and-Storage/deltas/2026-05-25-create-ai-available-models.rollback.sql new file mode 100644 index 00000000..2e1b784e --- /dev/null +++ b/specs/03-Data-and-Storage/deltas/2026-05-25-create-ai-available-models.rollback.sql @@ -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; diff --git a/specs/03-Data-and-Storage/deltas/2026-05-25-create-ai-available-models.sql b/specs/03-Data-and-Storage/deltas/2026-05-25-create-ai-available-models.sql new file mode 100644 index 00000000..bd13a77d --- /dev/null +++ b/specs/03-Data-and-Storage/deltas/2026-05-25-create-ai-available-models.sql @@ -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'); diff --git a/specs/03-Data-and-Storage/deltas/2026-05-25-grant-ai-permissions-to-superadmin.rollback.sql b/specs/03-Data-and-Storage/deltas/2026-05-25-grant-ai-permissions-to-superadmin.rollback.sql new file mode 100644 index 00000000..915221f4 --- /dev/null +++ b/specs/03-Data-and-Storage/deltas/2026-05-25-grant-ai-permissions-to-superadmin.rollback.sql @@ -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' +); diff --git a/specs/03-Data-and-Storage/deltas/2026-05-25-grant-ai-permissions-to-superadmin.sql b/specs/03-Data-and-Storage/deltas/2026-05-25-grant-ai-permissions-to-superadmin.sql new file mode 100644 index 00000000..ff806342 --- /dev/null +++ b/specs/03-Data-and-Storage/deltas/2026-05-25-grant-ai-permissions-to-superadmin.sql @@ -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; diff --git a/specs/03-Data-and-Storage/n8n.workflow.v2.json b/specs/03-Data-and-Storage/n8n.workflow.v2.json index b3f3778f..6d602840 100644 --- a/specs/03-Data-and-Storage/n8n.workflow.v2.json +++ b/specs/03-Data-and-Storage/n8n.workflow.v2.json @@ -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" } ] -} +} \ No newline at end of file