// File: src/modules/ai/services/ollama.service.ts // Change Log // - 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() // - 2026-06-06: เพิ่ม system prompt support ใน OllamaGenerateOptions และ generate() method เพื่อรองรับ Typhoon model ที่ต้องการ system prompt แยกต่างหาก // - 2026-06-06: [T036] แก้ไข default URL เป็น http://192.168.10.100:11434 (Desk-5439) แทน localhost; เพิ่ม options และ keepAlive ใน OllamaGenerateOptions เพื่อรองรับ Typhoon model parameters import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import axios from 'axios'; export interface OllamaGenerateOptions { timeoutMs?: number; signal?: AbortSignal; /** ชื่อ model ที่ต้องการใช้ — ถ้าไม่ระบุ จะใช้ mainModel เป็นค่าเริ่มต้น (ADR-034) */ model?: string; /** System prompt สำหรับ Typhoon model ที่ต้องการ system prompt แยกต่างหาก (ใช้ triple quotes) */ system?: string; /** บังคับ structured output จาก Ollama สำหรับงานที่ต้อง parse JSON */ format?: 'json'; /** Ollama generation options (temperature, top_p, etc.) */ options?: { temperature?: number; top_p?: number; repeat_penalty?: number; num_gpu?: number; num_ctx?: number; }; /** keep_alive: -1 = stay loaded, 0 = unload immediately, N = seconds */ keepAlive?: number; } /** บริการเรียก Ollama local-only บน Admin Desktop ตาม ADR-023A */ @Injectable() 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; constructor(private readonly configService: ConfigService) { this.ollamaUrl = this.configService.get( 'OLLAMA_URL', this.configService.get( 'AI_HOST_URL', 'http://192.168.10.100:11434' ) ); this.mainModel = this.configService.get( 'OLLAMA_MODEL_MAIN', 'typhoon2.5-np-dms:latest' ); this.ocrModel = this.configService.get( 'OLLAMA_MODEL_OCR', 'typhoon-np-dms-ocr:latest' ); this.embedModel = this.configService.get( 'OLLAMA_MODEL_EMBED', this.configService.get('OLLAMA_EMBED_MODEL', 'nomic-embed-text') ); this.timeoutMs = this.configService.get('AI_TIMEOUT_MS', 30000); } /** สร้างข้อความตอบกลับด้วย typhoon2.5-np-dms:latest หรือโมเดลที่ระบุใน options.model / ENV */ async generate( prompt: string, options: OllamaGenerateOptions = {} ): Promise { try { const response = await axios.post<{ response: string }>( `${this.ollamaUrl}/api/generate`, { model: options.model ?? this.mainModel, prompt, system: options.system, format: options.format, stream: false, options: options.options, keep_alive: options.keepAlive ?? -1, }, { timeout: options.timeoutMs ?? this.timeoutMs, signal: options.signal, } ); return response.data.response ?? ''; } catch (err) { this.logger.error( 'Ollama generate failed', err instanceof Error ? err.stack : String(err) ); throw err; } } /** สร้าง embedding ด้วย nomic-embed-text หรือค่า ENV ที่กำหนด */ async generateEmbedding(text: string): Promise { try { const response = await axios.post<{ embedding: number[] }>( `${this.ollamaUrl}/api/embeddings`, { model: this.embedModel, prompt: text }, { timeout: this.timeoutMs } ); return response.data.embedding; } catch (err) { this.logger.error( 'Ollama embedding failed', err instanceof Error ? err.stack : String(err) ); throw err; } } /** คืนชื่อ main model สำหรับ audit log */ getMainModelName(): string { 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; } /** ตรวจสอบสุขภาพและความเร็ว (Latency) ของระบบ Ollama */ async checkHealth(): Promise<{ status: 'HEALTHY' | 'DEGRADED' | 'DOWN'; latencyMs: number; models: string[]; error?: string; }> { const startTime = Date.now(); try { await axios.get(`${this.ollamaUrl}/api/tags`, { timeout: 5000 }); const latencyMs = Date.now() - startTime; let loadedModels: string[] = []; try { const psResponse = await axios.get<{ models?: Array<{ name: string }>; }>(`${this.ollamaUrl}/api/ps`, { timeout: 3000 }); if (psResponse.data?.models) { loadedModels = psResponse.data.models.map((m) => m.name); } } catch (psErr) { this.logger.warn( `Failed to fetch loaded models from /api/ps: ${psErr instanceof Error ? psErr.message : String(psErr)}` ); } if (loadedModels.length === 0) { loadedModels = [this.mainModel, this.embedModel]; } return { status: 'HEALTHY', latencyMs, models: loadedModels, }; } catch (err: unknown) { const latencyMs = Date.now() - startTime; const error = err instanceof Error ? err.message : String(err); const isTimeout = err instanceof Error && (err.message.includes('timeout') || err.message.includes('504') || err.message.includes('code ECONNABORTED')); return { status: isTimeout ? 'DEGRADED' : 'DOWN', latencyMs, models: [this.mainModel, this.embedModel], error, }; } } /** โหลดโมเดลเข้า 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 { try { const tagsResponse = await axios.get<{ models?: Array<{ name: string; model: string }>; }>(`${this.ollamaUrl}/api/tags`, { timeout: 5000 }); const installedModels = tagsResponse.data?.models ?? []; const exists = installedModels.some( (m) => m.name === modelName || m.model === modelName || m.name.startsWith(modelName) ); if (!exists) { this.logger.warn(`Model ${modelName} is not installed in Ollama`); return false; } this.logger.log( `Synchronously pre-loading model ${modelName} into GPU memory (keep_alive=${String(keepAlive ?? -1)})...` ); await axios.post( `${this.ollamaUrl}/api/generate`, { model: modelName, prompt: '', stream: false, keep_alive: keepAlive ?? -1, }, { timeout: 60000 } ); this.logger.log(`Model ${modelName} pre-loaded successfully`); return true; } catch (err: unknown) { this.logger.error( `Failed to pre-load model ${modelName}`, err instanceof Error ? err.stack : String(err) ); return false; } } /** ล้างโมเดลออกจากหน่วยความจำ GPU ของ Ollama เพื่อคืนค่า VRAM (ADR-033 Suggestion 1) */ async unloadModel(modelName: string): Promise { try { this.logger.log(`Unloading model ${modelName} from GPU memory...`); await axios.post( `${this.ollamaUrl}/api/generate`, { model: modelName, prompt: '', stream: false, keep_alive: 0, }, { timeout: 10000 } ); this.logger.log(`Model ${modelName} unloaded successfully`); return true; } catch (err: unknown) { this.logger.warn( `Failed to unload model ${modelName}: ${err instanceof Error ? err.message : String(err)}` ); return false; } } }