690602:0957 ADR-033-233 #01
CI / CD Pipeline / build (push) Successful in 4m52s
CI / CD Pipeline / deploy (push) Successful in 17m39s

This commit is contained in:
2026-06-02 09:57:48 +07:00
parent 7f35c3a585
commit bc754e66fd
32 changed files with 1404 additions and 576 deletions
+19 -6
View File
@@ -8,8 +8,8 @@
// - 2026-05-30: เพิ่ม VRAM insufficiency guard สำหรับ Typhoon OCR engine (T016a, ADR-032)
// - 2026-05-30: ปรับปรุงสำหรับ Dynamic OCR Engine selection, Caching, และ Graceful Fallback (T013, T014, T016, T022, T023, US1)
// - 2026-06-01: ปรับปรุง remapPath ให้รองรับ Windows absolute และ relative path ได้แม่นยำ 100%
// - 2026-06-01: เปลี่ยน processWithTesseract/processWithTyphoon ให้ส่ง file content ผ่าน multipart
// ไปยัง /ocr-upload แทนการส่ง path (แก้ปัญหา Docker WSL2 mount ไม่ได้)
// - 2026-06-01: เปลี่ยน processWithTesseract/processWithTyphoon ให้ส่ง file content ผ่าน multipart ไปยัง /ocr-upload แทนการส่ง path
// - 2026-06-02: ส่งค่า X-API-Key ใน request headers ไปยัง ocr-sidecar เพื่อความมั่นคงปลอดภัยสูงสุด (ADR-033, Suggestion 2)
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@@ -99,7 +99,7 @@ export class OcrService {
private readonly logger = new Logger(OcrService.name);
private readonly threshold: number;
private readonly ocrApiUrl: string;
private readonly ocrSidecarApiKey: string;
constructor(
private readonly configService: ConfigService,
@InjectRepository(SystemSetting)
@@ -115,6 +115,10 @@ export class OcrService {
'OCR_API_URL',
'http://localhost:8765'
);
this.ocrSidecarApiKey = this.configService.get<string>(
'OCR_SIDECAR_API_KEY',
'lcbp3-dms-ocr-sidecar-secure-token-2026'
);
}
/** ดึงรายการ OCR Engines ทั้งหมด พร้อมตรวจสอบตัวที่กำลัง Active */
@@ -195,7 +199,10 @@ export class OcrService {
async checkHealth(): Promise<OcrHealthResult> {
const startTime = Date.now();
try {
await axios.get(`${this.ocrApiUrl}/health`, { timeout: 5000 });
await axios.get(`${this.ocrApiUrl}/health`, {
timeout: 5000,
headers: { 'X-API-Key': this.ocrSidecarApiKey },
});
return {
status: 'HEALTHY',
latencyMs: Date.now() - startTime,
@@ -256,7 +263,10 @@ export class OcrService {
const response = await axios.post<OcrSidecarResponse>(
`${this.ocrApiUrl}/ocr-upload`,
form,
{ timeout: 90000 }
{
timeout: 90000,
headers: { 'X-API-Key': this.ocrSidecarApiKey },
}
);
const text = response.data.text ?? '';
const durationMs = Date.now() - startTime;
@@ -323,7 +333,10 @@ export class OcrService {
const response = await axios.post<OcrSidecarResponse>(
`${this.ocrApiUrl}/ocr-upload`,
form,
{ timeout: 120000 }
{
timeout: 120000,
headers: { 'X-API-Key': this.ocrSidecarApiKey },
}
);
const text = response.data.text ?? '';
@@ -1,178 +1,209 @@
// 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)
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
export interface OllamaGenerateOptions {
timeoutMs?: number;
signal?: AbortSignal;
}
/** บริการเรียก 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 embedModel: string;
private readonly timeoutMs: number;
constructor(private readonly configService: ConfigService) {
this.ollamaUrl = this.configService.get<string>(
'OLLAMA_URL',
this.configService.get<string>('AI_HOST_URL', 'http://localhost:11434')
);
this.mainModel = this.configService.get<string>(
'OLLAMA_MODEL_MAIN',
'gemma4:e4b'
);
this.embedModel = this.configService.get<string>(
'OLLAMA_MODEL_EMBED',
this.configService.get<string>('OLLAMA_EMBED_MODEL', 'nomic-embed-text')
);
this.timeoutMs = this.configService.get<number>('AI_TIMEOUT_MS', 30000);
}
/** สร้างข้อความตอบกลับจาก gemma4:e4b หรือค่า ENV ที่กำหนด */
async generate(
prompt: string,
options: OllamaGenerateOptions = {}
): Promise<string> {
try {
const response = await axios.post<{ response: string }>(
`${this.ollamaUrl}/api/generate`,
{
model: this.mainModel,
prompt,
stream: false,
},
{
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<number[]> {
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;
}
/** คืนชื่อ 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: [this.mainModel, this.embedModel],
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,
};
}
}
/** โหลดโมเดลล่วงหน้าแบบ Synchronous และตรวจสอบความพร้อมบน Ollama (T007) */
async loadModel(modelName: string): Promise<boolean> {
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...`
);
await axios.post(
`${this.ollamaUrl}/api/generate`,
{
model: modelName,
prompt: '',
stream: false,
keep_alive: -1,
},
{ timeout: 30000 }
);
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<boolean> {
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;
}
}
}
@@ -2,6 +2,7 @@
// Change Log
// - 2026-05-30: แยก SandboxOcrEngineService ออกจาก OcrService เพื่อรองรับการเลือก Typhoon OCR เฉพาะ sandbox โดยไม่กระทบ core OCR flow
// - 2026-06-01: เปลี่ยนจาก remapPath + pdfPath ไปเป็น multipart file upload ไปยัง /ocr-upload (แก้ปัญหา Docker WSL2 mount)
// - 2026-06-02: ส่งค่า X-API-Key ใน request headers ไปยัง ocr-sidecar เพื่อความมั่นคงปลอดภัยสูงสุด (ADR-033, Suggestion 2)
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@@ -33,7 +34,7 @@ export interface SandboxOcrResult {
export class SandboxOcrEngineService {
private readonly logger = new Logger(SandboxOcrEngineService.name);
private readonly ocrApiUrl: string;
private readonly ocrSidecarApiKey: string;
constructor(
private readonly configService: ConfigService,
private readonly ocrService: OcrService
@@ -42,6 +43,10 @@ export class SandboxOcrEngineService {
'OCR_API_URL',
'http://localhost:8765'
);
this.ocrSidecarApiKey = this.configService.get<string>(
'OCR_SIDECAR_API_KEY',
'lcbp3-dms-ocr-sidecar-secure-token-2026'
);
}
/** รัน OCR ตาม engine ที่เลือก โดย fallback กลับไป Tesseract baseline เมื่อ Typhoon ล้มเหลว */
@@ -71,7 +76,10 @@ export class SandboxOcrEngineService {
const response = await axios.post<SandboxOcrSidecarResponse>(
`${this.ocrApiUrl}/ocr-upload`,
form,
{ timeout: 120000 }
{
timeout: 120000,
headers: { 'X-API-Key': this.ocrSidecarApiKey },
}
);
return {
@@ -111,15 +111,14 @@ export class VramMonitorService {
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
this.logger.warn(
`VRAM status fetch failed: ${msg} — ใช้ค่า conservative fallback`
`VRAM status fetch failed: ${msg} — ใช้ค่า resilient fallback`
);
// Fallback: สมมติว่า VRAM ไม่พอเมื่อ Ollama ไม่ตอบสนอง
return {
totalVramMb: GPU_TOTAL_VRAM_MB,
usedVramMb: GPU_TOTAL_VRAM_MB,
freeVramMb: 0,
usedVramMb: 0,
freeVramMb: GPU_TOTAL_VRAM_MB,
loadedModels: [],
hasCapacity: false,
hasCapacity: true,
};
}
}