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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user