690603:2041 ADR-034-134 #01
This commit is contained in:
@@ -99,14 +99,15 @@ describe('AiSettingsService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('ควรใช้ gemma4:e4b เป็นค่า active model เริ่มต้นเมื่อยังไม่มี system setting', async () => {
|
||||
it('ควรใช้ typhoon2.5-np-dms:latest (DEFAULT_MODEL) เป็นค่า active model เริ่มต้นเมื่อยังไม่มี system setting (ADR-034)', async () => {
|
||||
mockRedis.get.mockResolvedValue(null);
|
||||
mockSettingRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.getActiveModel()).resolves.toBe('gemma4:e4b');
|
||||
await expect(service.getActiveModel()).resolves.toBe(
|
||||
'typhoon2.5-np-dms:latest'
|
||||
);
|
||||
expect(mockRedis.set).toHaveBeenCalledWith(
|
||||
'system_settings:AI_ACTIVE_MODEL',
|
||||
'gemma4:e4b',
|
||||
'typhoon2.5-np-dms:latest',
|
||||
'EX',
|
||||
30
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// - 2026-05-21: เพิ่ม service สำหรับอ่าน/เขียน AI feature toggle พร้อม Redis cache.
|
||||
// - 2026-05-22: เพิ่ม try-catch ใน getAiFeaturesEnabled() เพื่อความยืดหยุ่นในกรณีที่ฐานข้อมูลยังไม่ได้อัปเกรดตาราง system_settings
|
||||
// - 2026-05-25: เพิ่ม methods สำหรับจัดการรายการโมเดล AI แบบไดนามิก (ADR-027)
|
||||
// - 2026-06-03: เพิ่ม DEFAULT_MODEL และ OCR_MODEL static constants ตาม ADR-034 (เปลี่ยนจาก gemma4:e4b เป็น typhoon2.5-np-dms)
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRedis } from '@nestjs-modules/ioredis';
|
||||
@@ -24,6 +25,11 @@ const AI_ACTIVE_MODEL_TTL_SECONDS = 30;
|
||||
/** Service สำหรับจัดการ system_settings ที่เกี่ยวข้องกับ AI Admin Console */
|
||||
@Injectable()
|
||||
export class AiSettingsService {
|
||||
/** โมเดล AI หลักสำหรับ Extraction, RAG Q&A, AI Suggestion (ADR-034) */
|
||||
static readonly DEFAULT_MODEL = 'typhoon2.5-np-dms:latest';
|
||||
|
||||
/** โมเดล OCR ภาษาไทย — unload หลังใช้งาน (keep_alive=0) (ADR-034) */
|
||||
static readonly OCR_MODEL = 'typhoon-np-dms-ocr:latest';
|
||||
private readonly logger = new Logger(AiSettingsService.name);
|
||||
|
||||
constructor(
|
||||
@@ -150,7 +156,8 @@ export class AiSettingsService {
|
||||
where: { settingKey: AI_ACTIVE_MODEL_KEY },
|
||||
});
|
||||
|
||||
const activeModel = setting?.settingValue ?? 'gemma4:e4b';
|
||||
const activeModel =
|
||||
setting?.settingValue ?? AiSettingsService.DEFAULT_MODEL;
|
||||
await this.redis.set(
|
||||
AI_ACTIVE_MODEL_CACHE_KEY,
|
||||
activeModel,
|
||||
@@ -160,7 +167,7 @@ export class AiSettingsService {
|
||||
return activeModel;
|
||||
} catch (error: unknown) {
|
||||
this.logger.error(`Failed to get active model: ${this.toMessage(error)}`);
|
||||
return 'gemma4:e4b';
|
||||
return AiSettingsService.DEFAULT_MODEL;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// - 2026-05-21: แก้ไข ESLint unsafe return error ใน getSystemHealth โดยใช้ interface SystemHealthResponse
|
||||
// - 2026-05-29: เพิ่ม OcrService.checkHealth() เข้า getSystemHealth() เพื่อแสดงสถานะ OCR sidecar
|
||||
// - 2026-06-02: ปรับปรุง activateAiModel ให้มีการโหลดและยืนยันโมเดลล่วงหน้าแบบ Synchronous (T008, ADR-033) และล้างโมเดลตัวเก่าออกเพื่อประหยัด VRAM (Suggestion 1)
|
||||
// - 2026-06-03: ADR-034 — เพิ่ม activeModels field (เอา mainModel+ocrModel) ใน SystemHealthResponse
|
||||
import { Injectable, Logger, Optional } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
@@ -123,6 +124,10 @@ export interface AiJobStatusResult {
|
||||
}
|
||||
|
||||
export interface SystemHealthResponse {
|
||||
activeModels: {
|
||||
main: string;
|
||||
ocr: string;
|
||||
};
|
||||
ollama: {
|
||||
status: string;
|
||||
latencyMs: number;
|
||||
@@ -867,6 +872,14 @@ export class AiService {
|
||||
this.getQueueMetrics(this.aiBatchQueue),
|
||||
]);
|
||||
const health = {
|
||||
activeModels: {
|
||||
main: this.ollamaService
|
||||
? this.ollamaService.getMainModelName()
|
||||
: AiSettingsService.DEFAULT_MODEL,
|
||||
ocr: this.ollamaService
|
||||
? this.ollamaService.getOcrModelName()
|
||||
: AiSettingsService.OCR_MODEL,
|
||||
},
|
||||
ollama,
|
||||
qdrant,
|
||||
ocr,
|
||||
|
||||
@@ -7,12 +7,17 @@
|
||||
// - 2026-05-27: เพิ่ม Mock สำหรับ getActive และ resolveContext ของ AiPromptsService เพื่อรองรับ Context-Aware Prompt (T017)
|
||||
// - 2026-05-28: เพิ่ม test สำหรับ EC-001 (NEW_TAG_SUGGESTED) และ EC-002 (UNRESOLVED_SENDER/RECIPIENT_UUID)
|
||||
// - 2026-05-29: แก้ไข mockAttachmentRepo เพิ่ม property manager เพื่อรองรับ jest.spyOn ใน EC-001, EC-002, และ migrate-document tests
|
||||
// - 2026-06-03: ADR-034 — เพิ่ม OCR_JOB_TYPES import, mock unloadModel/loadModel/getOcrModelName, อัปเดต getMainModelName เป็น typhoon2.5, เพิ่ม test ocr-extract model switching
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Job } from 'bullmq';
|
||||
import { AiBatchProcessor, AiBatchJobData } from './ai-batch.processor';
|
||||
import {
|
||||
AiBatchProcessor,
|
||||
AiBatchJobData,
|
||||
OCR_JOB_TYPES,
|
||||
} from './ai-batch.processor';
|
||||
import { EmbeddingService } from '../services/embedding.service';
|
||||
import { AiRagService } from '../ai-rag.service';
|
||||
import { Attachment } from '../../../common/file-storage/entities/attachment.entity';
|
||||
@@ -57,7 +62,10 @@ describe('AiBatchProcessor', () => {
|
||||
}),
|
||||
};
|
||||
const mockOllamaService = {
|
||||
getMainModelName: jest.fn().mockReturnValue('gemma4:e4b'),
|
||||
getMainModelName: jest.fn().mockReturnValue('typhoon2.5-np-dms:latest'),
|
||||
getOcrModelName: jest.fn().mockReturnValue('typhoon-np-dms-ocr:latest'),
|
||||
loadModel: jest.fn().mockResolvedValue(true),
|
||||
unloadModel: jest.fn().mockResolvedValue(true),
|
||||
generate: jest.fn().mockResolvedValue(
|
||||
JSON.stringify({
|
||||
documentNumber: 'LCBP3-CIV-001',
|
||||
@@ -174,6 +182,49 @@ describe('AiBatchProcessor', () => {
|
||||
attachmentRepo = module.get(getRepositoryToken(Attachment));
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
it('OCR_JOB_TYPES ควรมี ocr-extract เป็นสมาชิก (ADR-034)', () => {
|
||||
expect(OCR_JOB_TYPES).toContain('ocr-extract');
|
||||
});
|
||||
it('ocr-extract: ควร unload main → load OCR (keep_alive:0) → generate → reload main (ADR-034)', async () => {
|
||||
const job = {
|
||||
id: 'job-ocr-extract',
|
||||
data: {
|
||||
jobType: 'ocr-extract',
|
||||
documentPublicId: 'doc-ocr-uuid-001',
|
||||
projectPublicId: 'proj-uuid-456',
|
||||
payload: { prompt: 'Extract OCR text from this document.' },
|
||||
idempotencyKey: 'idem-ocr-001',
|
||||
},
|
||||
} as unknown as Job<AiBatchJobData>;
|
||||
await processor.process(job);
|
||||
expect(mockOllamaService.unloadModel).toHaveBeenCalledWith(
|
||||
'typhoon2.5-np-dms:latest'
|
||||
);
|
||||
expect(mockOllamaService.loadModel).toHaveBeenCalledWith(
|
||||
'typhoon-np-dms-ocr:latest',
|
||||
0
|
||||
);
|
||||
expect(mockOllamaService.generate).toHaveBeenCalledWith(
|
||||
'Extract OCR text from this document.',
|
||||
expect.objectContaining({
|
||||
model: 'typhoon-np-dms-ocr:latest',
|
||||
timeoutMs: 120000,
|
||||
})
|
||||
);
|
||||
expect(mockOllamaService.loadModel).toHaveBeenCalledWith(
|
||||
'typhoon2.5-np-dms:latest',
|
||||
-1
|
||||
);
|
||||
expect(mockRedis.setex).toHaveBeenCalledWith(
|
||||
'ai:ocr:result:doc-ocr-uuid-001',
|
||||
3600,
|
||||
expect.stringContaining('typhoon-np-dms-ocr:latest')
|
||||
);
|
||||
expect(attachmentRepo.update).toHaveBeenCalledWith(
|
||||
{ publicId: 'doc-ocr-uuid-001' },
|
||||
{ aiProcessingStatus: 'DONE' }
|
||||
);
|
||||
});
|
||||
it('ควรสามารถเรียก process embed-document และอัปเดตสถานะใน database', async () => {
|
||||
const job = {
|
||||
id: 'job-embed',
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
// - 2026-05-25: เพิ่ม AiPromptsService เพื่อดึง Dynamic Prompt สำหรับ OCR extraction ใน sandbox และ migration pipeline
|
||||
// - 2026-05-26: แก้ไข bug lockDuration=30000ms ทำให้ sandbox-extract job stall เมื่อ Ollama ใช้เวลา >30s — เพิ่ม lockDuration: 150000
|
||||
// - 2026-05-28: EC-001 ใช้ findOrSuggestTags เพื่อตรวจจับ Tag ใหม่และบันทึก aiIssues; EC-002 ตรวจสอบ UUID ของผู้ส่ง/ผู้รับ และ Flag เมื่อหาไม่พบ
|
||||
// - 2026-06-03: ADR-034 — เพิ่ม 'ocr-extract' job type + OCR_JOB_TYPES constant + processOcrExtract() ที่มี model switching logic (unload main → load OCR → generate → reload main)
|
||||
|
||||
import { Processor, WorkerHost } from '@nestjs/bullmq';
|
||||
import { Logger } from '@nestjs/common';
|
||||
@@ -49,6 +50,7 @@ interface MigrateDocumentMetadata extends Record<string, unknown> {
|
||||
|
||||
export type AiBatchJobType =
|
||||
| 'ocr'
|
||||
| 'ocr-extract'
|
||||
| 'extract-metadata'
|
||||
| 'embed-document'
|
||||
| 'sandbox-rag'
|
||||
@@ -57,6 +59,11 @@ export type AiBatchJobType =
|
||||
| 'sandbox-ai-extract'
|
||||
| 'migrate-document';
|
||||
|
||||
/** รายการ job types ที่ต้องใช้ Typhoon OCR model — จะ trigger model switching (ADR-034) */
|
||||
export const OCR_JOB_TYPES: ReadonlyArray<AiBatchJobType> = [
|
||||
'ocr-extract',
|
||||
] as const;
|
||||
|
||||
export interface AiBatchJobData {
|
||||
jobType: AiBatchJobType;
|
||||
documentPublicId: string;
|
||||
@@ -177,6 +184,13 @@ export class AiBatchProcessor extends WorkerHost {
|
||||
await this.setAiProcessingStatus(job.data.documentPublicId, 'DONE');
|
||||
}
|
||||
return;
|
||||
case 'ocr-extract':
|
||||
this.logger.log(
|
||||
`OCR-extract (Typhoon OCR) job processing — jobId=${String(job.id)}`
|
||||
);
|
||||
await this.processOcrExtract(job.data);
|
||||
await this.setAiProcessingStatus(job.data.documentPublicId, 'DONE');
|
||||
return;
|
||||
case 'extract-metadata':
|
||||
this.logger.log(
|
||||
`Metadata extraction job processing — jobId=${String(job.id)}`
|
||||
@@ -296,6 +310,45 @@ export class AiBatchProcessor extends WorkerHost {
|
||||
);
|
||||
}
|
||||
|
||||
/** ประมวลผล ocr-extract job ด้วย Typhoon OCR model — model switching ตาม ADR-034:
|
||||
* unload main → load OCR (keep_alive:0) → generate OCR → OCR auto-unloads → reload main */
|
||||
private async processOcrExtract(data: AiBatchJobData): Promise<void> {
|
||||
const { documentPublicId, payload } = data;
|
||||
const mainModel = this.ollamaService.getMainModelName();
|
||||
const ocrModel = this.ollamaService.getOcrModelName();
|
||||
const prompt = (payload.prompt as string) || '';
|
||||
this.logger.log(
|
||||
`[ModelSwitch] Unloading ${mainModel} — documentPublicId=${documentPublicId}`
|
||||
);
|
||||
await this.ollamaService.unloadModel(mainModel);
|
||||
this.logger.log(`[ModelSwitch] Loading ${ocrModel} (keep_alive:0)`);
|
||||
await this.ollamaService.loadModel(ocrModel, 0);
|
||||
let ocrText = '';
|
||||
try {
|
||||
this.logger.log(`[ModelSwitch] Running OCR extraction with ${ocrModel}`);
|
||||
ocrText = await this.ollamaService.generate(prompt, {
|
||||
model: ocrModel,
|
||||
timeoutMs: 120000,
|
||||
});
|
||||
} finally {
|
||||
this.logger.log(`[ModelSwitch] Reloading ${mainModel} (keep_alive:-1)`);
|
||||
await this.ollamaService.loadModel(mainModel, -1);
|
||||
}
|
||||
await this.redis.setex(
|
||||
`ai:ocr:result:${documentPublicId}`,
|
||||
3600,
|
||||
JSON.stringify({
|
||||
documentPublicId,
|
||||
ocrText,
|
||||
model: ocrModel,
|
||||
completedAt: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
this.logger.log(
|
||||
`[ModelSwitch] OCR-extract complete — documentPublicId=${documentPublicId}`
|
||||
);
|
||||
}
|
||||
|
||||
/** ประมวลผล sandbox OCR + Metadata extraction โดยไม่บันทึกลง database */
|
||||
private async processSandboxExtract(data: AiBatchJobData): Promise<void> {
|
||||
const { idempotencyKey, payload, projectPublicId } = data;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// File: src/modules/ai/processors/ai-realtime.processor.ts
|
||||
// Change Log
|
||||
// - 2026-05-15: เพิ่ม processor สำหรับ ai-realtime queue และ pause/resume ai-batch ตาม ADR-023A.
|
||||
// - 2026-06-03: ADR-034 — เปลี่ยน aiModel ใน audit log จาก hardcode 'gemma4' เป็น ollamaService.getMainModelName()
|
||||
|
||||
import {
|
||||
Processor,
|
||||
@@ -113,7 +114,7 @@ export class AiRealtimeProcessor extends WorkerHost {
|
||||
await this.aiAuditLogRepo.save(
|
||||
this.aiAuditLogRepo.create({
|
||||
documentPublicId: job.data.documentPublicId,
|
||||
aiModel: 'gemma4',
|
||||
aiModel: this.ollamaService.getMainModelName(),
|
||||
modelName: this.ollamaService.getMainModelName(),
|
||||
aiSuggestionJson: normalizedSuggestion,
|
||||
confidenceScore: this.extractConfidence(normalizedSuggestion),
|
||||
@@ -135,7 +136,7 @@ export class AiRealtimeProcessor extends WorkerHost {
|
||||
await this.aiAuditLogRepo.save(
|
||||
this.aiAuditLogRepo.create({
|
||||
documentPublicId: job.data.documentPublicId,
|
||||
aiModel: 'gemma4',
|
||||
aiModel: this.ollamaService.getMainModelName(),
|
||||
modelName: this.ollamaService.getMainModelName(),
|
||||
processingTimeMs: Date.now() - startTime,
|
||||
status: AiAuditStatus.FAILED,
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// File: src/modules/ai/services/ollama.service.spec.ts
|
||||
// Change Log:
|
||||
// - 2026-06-03: สร้าง unit test สำหรับ OllamaService ครอบคลุม generate() model option,
|
||||
// getOcrModelName(), และ loadModel() keepAlive param ตาม ADR-034
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios from 'axios';
|
||||
import { OllamaService } from './ollama.service';
|
||||
|
||||
jest.mock('axios');
|
||||
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
||||
|
||||
describe('OllamaService (ADR-034)', () => {
|
||||
let service: OllamaService;
|
||||
const configValues: Record<string, unknown> = {
|
||||
OLLAMA_URL: 'http://localhost:11434',
|
||||
OLLAMA_MODEL_MAIN: 'typhoon2.5-np-dms:latest',
|
||||
OLLAMA_MODEL_OCR: 'typhoon-np-dms-ocr:latest',
|
||||
OLLAMA_MODEL_EMBED: 'nomic-embed-text',
|
||||
AI_TIMEOUT_MS: 30000,
|
||||
};
|
||||
const mockConfigService = {
|
||||
get: jest.fn(<T>(key: string, defaultValue?: T): T | undefined => {
|
||||
return (configValues[key] as T | undefined) ?? defaultValue;
|
||||
}),
|
||||
};
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
OllamaService,
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
],
|
||||
}).compile();
|
||||
service = module.get<OllamaService>(OllamaService);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
describe('getMainModelName()', () => {
|
||||
it('ควรคืน typhoon2.5-np-dms:latest เป็น main model (ADR-034)', () => {
|
||||
expect(service.getMainModelName()).toBe('typhoon2.5-np-dms:latest');
|
||||
});
|
||||
});
|
||||
describe('getOcrModelName()', () => {
|
||||
it('ควรคืน typhoon-np-dms-ocr:latest เป็น OCR model (ADR-034)', () => {
|
||||
expect(service.getOcrModelName()).toBe('typhoon-np-dms-ocr:latest');
|
||||
});
|
||||
});
|
||||
describe('generate()', () => {
|
||||
it('ควรใช้ mainModel เมื่อ options.model ไม่ได้ระบุ', async () => {
|
||||
mockedAxios.post = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ data: { response: 'test response' } });
|
||||
await service.generate('test prompt');
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/generate'),
|
||||
expect.objectContaining({ model: 'typhoon2.5-np-dms:latest' }),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
it('ควรใช้ options.model เมื่อระบุ model อื่น (ADR-034 model switching)', async () => {
|
||||
mockedAxios.post = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ data: { response: 'ocr result' } });
|
||||
await service.generate('ocr prompt', {
|
||||
model: 'typhoon-np-dms-ocr:latest',
|
||||
});
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/generate'),
|
||||
expect.objectContaining({ model: 'typhoon-np-dms-ocr:latest' }),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('loadModel()', () => {
|
||||
it('ควรส่ง keep_alive: -1 เป็น default เมื่อไม่ระบุ keepAlive', async () => {
|
||||
mockedAxios.get = jest.fn().mockResolvedValueOnce({
|
||||
data: {
|
||||
models: [
|
||||
{
|
||||
name: 'typhoon2.5-np-dms:latest',
|
||||
model: 'typhoon2.5-np-dms:latest',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
mockedAxios.post = jest.fn().mockResolvedValueOnce({ data: {} });
|
||||
await service.loadModel('typhoon2.5-np-dms:latest');
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/generate'),
|
||||
expect.objectContaining({ keep_alive: -1 }),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
it('ควรส่ง keep_alive: 0 เมื่อ keepAlive=0 (OCR model switching, ADR-034)', async () => {
|
||||
mockedAxios.get = jest.fn().mockResolvedValueOnce({
|
||||
data: {
|
||||
models: [
|
||||
{
|
||||
name: 'typhoon-np-dms-ocr:latest',
|
||||
model: 'typhoon-np-dms-ocr:latest',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
mockedAxios.post = jest.fn().mockResolvedValueOnce({ data: {} });
|
||||
await service.loadModel('typhoon-np-dms-ocr:latest', 0);
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/generate'),
|
||||
expect.objectContaining({ keep_alive: 0 }),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
it('ควรคืน false เมื่อ model ไม่ได้ติดตั้งใน Ollama', async () => {
|
||||
mockedAxios.get = jest.fn().mockResolvedValueOnce({
|
||||
data: { models: [{ name: 'other-model', model: 'other-model' }] },
|
||||
});
|
||||
const result = await service.loadModel('typhoon-np-dms-ocr:latest', 0);
|
||||
expect(result).toBe(false);
|
||||
expect(mockedAxios.post).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@
|
||||
// - 2026-05-15: เพิ่ม Ollama service สำหรับ ADR-023A 2-model stack.
|
||||
// - 2026-05-21: เพิ่ม checkHealth สำหรับตรวจสอบสุขภาพและความเร็ว (Latency) ของ Ollama
|
||||
// - 2026-06-02: เพิ่ม loadModel() preloading, ดึงจริงจาก /api/ps และเพิ่ม unloadModel() เพื่อล้างหน่วยความจำ GPU/VRAM (ADR-033, Suggestion 1)
|
||||
// - 2026-06-03: ADR-034 — เปลี่ยน default model เป็น typhoon2.5-np-dms; เพิ่ม ocrModel field, keepAlive param ใน loadModel(), model option ใน OllamaGenerateOptions, getOcrModelName()
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
@@ -11,6 +12,8 @@ import axios from 'axios';
|
||||
export interface OllamaGenerateOptions {
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
/** ชื่อ model ที่ต้องการใช้ — ถ้าไม่ระบุ จะใช้ mainModel เป็นค่าเริ่มต้น (ADR-034) */
|
||||
model?: string;
|
||||
}
|
||||
|
||||
/** บริการเรียก Ollama local-only บน Admin Desktop ตาม ADR-023A */
|
||||
@@ -19,6 +22,7 @@ export class OllamaService {
|
||||
private readonly logger = new Logger(OllamaService.name);
|
||||
private readonly ollamaUrl: string;
|
||||
private readonly mainModel: string;
|
||||
private readonly ocrModel: string;
|
||||
private readonly embedModel: string;
|
||||
private readonly timeoutMs: number;
|
||||
|
||||
@@ -29,7 +33,11 @@ export class OllamaService {
|
||||
);
|
||||
this.mainModel = this.configService.get<string>(
|
||||
'OLLAMA_MODEL_MAIN',
|
||||
'gemma4:e4b'
|
||||
'typhoon2.5-np-dms:latest'
|
||||
);
|
||||
this.ocrModel = this.configService.get<string>(
|
||||
'OLLAMA_MODEL_OCR',
|
||||
'typhoon-np-dms-ocr:latest'
|
||||
);
|
||||
this.embedModel = this.configService.get<string>(
|
||||
'OLLAMA_MODEL_EMBED',
|
||||
@@ -38,7 +46,7 @@ export class OllamaService {
|
||||
this.timeoutMs = this.configService.get<number>('AI_TIMEOUT_MS', 30000);
|
||||
}
|
||||
|
||||
/** สร้างข้อความตอบกลับจาก gemma4:e4b หรือค่า ENV ที่กำหนด */
|
||||
/** สร้างข้อความตอบกลับด้วย typhoon2.5-np-dms:latest หรือโมเดลที่ระบุใน options.model / ENV */
|
||||
async generate(
|
||||
prompt: string,
|
||||
options: OllamaGenerateOptions = {}
|
||||
@@ -47,7 +55,7 @@ export class OllamaService {
|
||||
const response = await axios.post<{ response: string }>(
|
||||
`${this.ollamaUrl}/api/generate`,
|
||||
{
|
||||
model: this.mainModel,
|
||||
model: options.model ?? this.mainModel,
|
||||
prompt,
|
||||
stream: false,
|
||||
},
|
||||
@@ -89,6 +97,11 @@ export class OllamaService {
|
||||
return this.mainModel;
|
||||
}
|
||||
|
||||
/** คืนชื่อ OCR model สำหรับ model switching ใน BullMQ processor (ADR-034) */
|
||||
getOcrModelName(): string {
|
||||
return this.ocrModel;
|
||||
}
|
||||
|
||||
/** คืนชื่อ embedding model สำหรับ audit log */
|
||||
getEmbeddingModelName(): string {
|
||||
return this.embedModel;
|
||||
@@ -143,8 +156,13 @@ export class OllamaService {
|
||||
}
|
||||
}
|
||||
|
||||
/** โหลดโมเดลล่วงหน้าแบบ Synchronous และตรวจสอบความพร้อมบน Ollama (T007) */
|
||||
async loadModel(modelName: string): Promise<boolean> {
|
||||
/** โหลดโมเดลเข้า VRAM — ใช้สำหรับ preload และ model switching (ADR-033, ADR-034)
|
||||
* @param keepAlive ค่า keep_alive: -1 = ค้างใน VRAM ตลอด (main), 0 = unload หลังจบ (OCR)
|
||||
*/
|
||||
async loadModel(
|
||||
modelName: string,
|
||||
keepAlive?: number | string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const tagsResponse = await axios.get<{
|
||||
models?: Array<{ name: string; model: string }>;
|
||||
@@ -161,7 +179,7 @@ export class OllamaService {
|
||||
return false;
|
||||
}
|
||||
this.logger.log(
|
||||
`Synchronously pre-loading model ${modelName} into GPU memory...`
|
||||
`Synchronously pre-loading model ${modelName} into GPU memory (keep_alive=${String(keepAlive ?? -1)})...`
|
||||
);
|
||||
await axios.post(
|
||||
`${this.ollamaUrl}/api/generate`,
|
||||
@@ -169,9 +187,9 @@ export class OllamaService {
|
||||
model: modelName,
|
||||
prompt: '',
|
||||
stream: false,
|
||||
keep_alive: -1,
|
||||
keep_alive: keepAlive ?? -1,
|
||||
},
|
||||
{ timeout: 30000 }
|
||||
{ timeout: 60000 }
|
||||
);
|
||||
this.logger.log(`Model ${modelName} pre-loaded successfully`);
|
||||
return true;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// File: src/modules/rag/local-llm.service.ts
|
||||
// Change Log
|
||||
// - 2026-05-15: แทนที่ cloud LLM API ด้วย Ollama local-only ตาม ADR-023A.
|
||||
// - 2026-06-03: ADR-034 — เปลี่ยน default fallback จาก gemma4:e4b เป็น typhoon2.5-np-dms:latest
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
@@ -26,7 +27,10 @@ export class LocalLlmService {
|
||||
);
|
||||
this.ollamaModel = this.configService.get<string>(
|
||||
'OLLAMA_MODEL_MAIN',
|
||||
this.configService.get<string>('OLLAMA_RAG_MODEL', 'gemma4:e4b')
|
||||
this.configService.get<string>(
|
||||
'OLLAMA_RAG_MODEL',
|
||||
'typhoon2.5-np-dms:latest'
|
||||
)
|
||||
);
|
||||
this.timeoutMs = this.configService.get<number>('RAG_TIMEOUT_MS', 30000);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user