feat(ai): ADR-032 Typhoon OCR integration - models, processors, cache, VRAM monitor, sandbox UI
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
// File: src/modules/ai/services/ocr-cache.service.ts
|
||||
// Change Log
|
||||
// - 2026-05-30: Initial implementation สำหรับ Typhoon OCR 24-hour result caching (T007, ADR-032)
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRedis } from '@nestjs-modules/ioredis';
|
||||
import Redis from 'ioredis';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
/** ผลลัพธ์ที่ cache ไว้ใน Redis */
|
||||
export interface CachedOcrResult {
|
||||
text: string;
|
||||
engineUsed: string;
|
||||
charCount: number;
|
||||
cachedAt: string; // ISO string
|
||||
}
|
||||
|
||||
// TTL 24 ชั่วโมง (ตามที่กำหนดใน ADR-032)
|
||||
const OCR_CACHE_TTL_SECONDS = 24 * 60 * 60;
|
||||
// Prefix key ใน Redis
|
||||
const OCR_CACHE_PREFIX = 'ai:ocr:result:';
|
||||
|
||||
/**
|
||||
* บริการ cache ผลลัพธ์ OCR ใน Redis สำหรับ Typhoon OCR
|
||||
* Key: SHA-256(pdfPath + engineType) เพื่อป้องกัน key collision ระหว่าง engine ต่างๆ
|
||||
* TTL: 24 ชั่วโมง ตาม ADR-032
|
||||
*/
|
||||
@Injectable()
|
||||
export class OcrCacheService {
|
||||
private readonly logger = new Logger(OcrCacheService.name);
|
||||
|
||||
constructor(@InjectRedis() private readonly redis: Redis) {}
|
||||
|
||||
/**
|
||||
* สร้าง Redis cache key จาก pdfPath และ engineType
|
||||
* ใช้ SHA-256 เพื่อหลีกเลี่ยง key ยาวเกินไปและ cache collision
|
||||
*/
|
||||
private buildKey(pdfPath: string, engineType: string): string {
|
||||
const hash = createHash('sha256')
|
||||
.update(`${pdfPath}::${engineType}`)
|
||||
.digest('hex');
|
||||
return `${OCR_CACHE_PREFIX}${hash}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* ดึงผลลัพธ์ OCR จาก Redis cache
|
||||
* คืน null ถ้าไม่มี cache หรือ cache หมดอายุ
|
||||
*/
|
||||
async get(
|
||||
pdfPath: string,
|
||||
engineType: string
|
||||
): Promise<CachedOcrResult | null> {
|
||||
const key = this.buildKey(pdfPath, engineType);
|
||||
try {
|
||||
const raw = await this.redis.get(key);
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw) as CachedOcrResult;
|
||||
} catch (err: unknown) {
|
||||
// Cache miss ที่เกิดจาก parse error — ไม่ throw, คืน null เพื่อ fallback OCR จริง
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
this.logger.warn(`OCR cache get failed for ${engineType}: ${msg}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* บันทึกผลลัพธ์ OCR ลง Redis cache พร้อม TTL 24 ชั่วโมง
|
||||
*/
|
||||
async set(
|
||||
pdfPath: string,
|
||||
engineType: string,
|
||||
result: Omit<CachedOcrResult, 'cachedAt'>
|
||||
): Promise<void> {
|
||||
const key = this.buildKey(pdfPath, engineType);
|
||||
const value: CachedOcrResult = {
|
||||
...result,
|
||||
cachedAt: new Date().toISOString(),
|
||||
};
|
||||
try {
|
||||
await this.redis.setex(key, OCR_CACHE_TTL_SECONDS, JSON.stringify(value));
|
||||
this.logger.debug(
|
||||
`OCR cache set: ${engineType} for ${pdfPath} (TTL 24h)`
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
// Cache write failure ไม่ควร block OCR flow
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
this.logger.warn(`OCR cache set failed: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ลบ cache entry สำหรับไฟล์ที่ระบุ (เช่น หลังจากไฟล์ถูกแก้ไข)
|
||||
*/
|
||||
async invalidate(pdfPath: string, engineType: string): Promise<void> {
|
||||
const key = this.buildKey(pdfPath, engineType);
|
||||
try {
|
||||
await this.redis.del(key);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
this.logger.warn(`OCR cache invalidate failed: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** ตรวจสอบว่ามี cache อยู่หรือไม่ (ไม่ดึงข้อมูล) */
|
||||
async exists(pdfPath: string, engineType: string): Promise<boolean> {
|
||||
const key = this.buildKey(pdfPath, engineType);
|
||||
const count = await this.redis.exists(key);
|
||||
return count > 0;
|
||||
}
|
||||
}
|
||||
@@ -5,15 +5,31 @@
|
||||
// - 2026-05-25: เพิ่ม path remapping (OCR_UPLOAD_BASE_PATH) เพื่อแปลง local upload path เป็น path ที่ sidecar เห็นผ่าน CIFS.
|
||||
// - 2026-05-29: เพิ่ม checkHealth() เพื่อตรวจสอบสุขภาพของ OCR sidecar สำหรับ getSystemHealth() (ADR-027)
|
||||
// - 2026-05-30: เปลี่ยนจาก PaddleOCR เป็น Tesseract OCR เพื่อความเข้ากันได้กับ CPU เก่า
|
||||
// - 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)
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRedis } from '@nestjs-modules/ioredis';
|
||||
import Redis from 'ioredis';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, EntityManager } from 'typeorm';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
OcrEngineConfiguration,
|
||||
OcrEngineType,
|
||||
} from '../entities/ocr-engine-configuration.entity';
|
||||
import { OcrEngineResponseDto } from '../dto/ocr-engine-response.dto';
|
||||
import { SystemSetting } from '../entities/system-setting.entity';
|
||||
import { AiAuditLog, AiAuditStatus } from '../entities/ai-audit-log.entity';
|
||||
import { OcrCacheService } from './ocr-cache.service';
|
||||
import { VramMonitorService } from './vram-monitor.service';
|
||||
|
||||
export interface OcrDetectionInput {
|
||||
extractedText?: string;
|
||||
extractedChars?: number;
|
||||
pdfPath?: string;
|
||||
documentPublicId?: string; // เพิ่มเพื่อการทำ audit logs
|
||||
}
|
||||
|
||||
export interface OcrDetectionResult {
|
||||
@@ -32,7 +48,48 @@ export interface OcrHealthResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** บริการเลือก fast path หรือ OCR sidecar (Tesseract) ตามจำนวนตัวอักษรที่ extract ได้ */
|
||||
const OCR_ACTIVE_ENGINE_KEY = 'OCR_ACTIVE_ENGINE';
|
||||
const OCR_ACTIVE_ENGINE_CACHE_KEY = 'system_settings:OCR_ACTIVE_ENGINE';
|
||||
const OCR_ACTIVE_ENGINE_TTL_SECONDS = 30;
|
||||
|
||||
const TESSERACT_ENGINE_ID = '019505a1-7c3e-7000-8000-abc123def001';
|
||||
const TYPHOON_ENGINE_ID = '019505a1-7c3e-7000-8000-abc123def002';
|
||||
|
||||
// VRAM ที่ Typhoon OCR-3B ต้องการ (MB)
|
||||
const TYPHOON_OCR_REQUIRED_VRAM_MB = 4000;
|
||||
|
||||
const TESSERACT_ENGINE: OcrEngineConfiguration = {
|
||||
engineId: TESSERACT_ENGINE_ID,
|
||||
engineName: 'Tesseract OCR',
|
||||
engineType: OcrEngineType.TESSERACT,
|
||||
isActive: true,
|
||||
vramRequirementMB: 0,
|
||||
processingTimeLimitSeconds: 30,
|
||||
concurrentLimit: 10,
|
||||
fallbackEngineId: null,
|
||||
createdAt: new Date('2026-05-30T00:00:00Z'),
|
||||
updatedAt: new Date('2026-05-30T00:00:00Z'),
|
||||
};
|
||||
|
||||
const TYPHOON_ENGINE: OcrEngineConfiguration = {
|
||||
engineId: TYPHOON_ENGINE_ID,
|
||||
engineName: 'Typhoon OCR-3B',
|
||||
engineType: OcrEngineType.TYPHOON_OCR,
|
||||
isActive: true,
|
||||
vramRequirementMB: TYPHOON_OCR_REQUIRED_VRAM_MB,
|
||||
processingTimeLimitSeconds: 60,
|
||||
concurrentLimit: 1,
|
||||
fallbackEngineId: TESSERACT_ENGINE_ID,
|
||||
createdAt: new Date('2026-05-30T00:00:00Z'),
|
||||
updatedAt: new Date('2026-05-30T00:00:00Z'),
|
||||
};
|
||||
|
||||
const ENGINES_MAP = new Map<string, OcrEngineConfiguration>([
|
||||
[TESSERACT_ENGINE_ID, TESSERACT_ENGINE],
|
||||
[TYPHOON_ENGINE_ID, TYPHOON_ENGINE],
|
||||
]);
|
||||
|
||||
/** บริการเลือก fast path หรือ OCR sidecar (Tesseract/Typhoon) พร้อมความสามารถในสลับ Engine และ Caching */
|
||||
@Injectable()
|
||||
export class OcrService {
|
||||
private readonly logger = new Logger(OcrService.name);
|
||||
@@ -41,13 +98,21 @@ export class OcrService {
|
||||
private readonly localUploadBase: string;
|
||||
private readonly sidecarUploadBase: string;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
@InjectRepository(SystemSetting)
|
||||
private readonly settingRepo: Repository<SystemSetting>,
|
||||
@InjectRepository(AiAuditLog)
|
||||
private readonly auditLogRepo: Repository<AiAuditLog>,
|
||||
private readonly ocrCacheService: OcrCacheService,
|
||||
private readonly vramMonitorService: VramMonitorService,
|
||||
@InjectRedis() private readonly redis: Redis
|
||||
) {
|
||||
this.threshold = this.configService.get<number>('OCR_CHAR_THRESHOLD', 100);
|
||||
this.ocrApiUrl = this.configService.get<string>(
|
||||
'OCR_API_URL',
|
||||
'http://localhost:8765'
|
||||
);
|
||||
// path ที่ backend เห็น → path ที่ sidecar เห็น (ผ่าน CIFS mount)
|
||||
this.localUploadBase = this.configService
|
||||
.get<string>('UPLOAD_PERMANENT_DIR', '/app/uploads/permanent')
|
||||
.replace(/\/permanent$/, '');
|
||||
@@ -57,6 +122,81 @@ export class OcrService {
|
||||
);
|
||||
}
|
||||
|
||||
/** ดึงรายการ OCR Engines ทั้งหมด พร้อมตรวจสอบตัวที่กำลัง Active */
|
||||
async getOcrEngines(): Promise<OcrEngineResponseDto[]> {
|
||||
const activeEngineId = await this.getActiveEngineId();
|
||||
return Array.from(ENGINES_MAP.values()).map((engine) => ({
|
||||
...engine,
|
||||
isCurrentActive: engine.engineId === activeEngineId,
|
||||
}));
|
||||
}
|
||||
|
||||
/** บันทึกการเลือก OCR Engine หลัก */
|
||||
async selectOcrEngine(
|
||||
engineId: string,
|
||||
userId: number
|
||||
): Promise<OcrEngineConfiguration> {
|
||||
const selectedEngine = ENGINES_MAP.get(engineId);
|
||||
if (!selectedEngine) {
|
||||
throw new NotFoundException(`OCR Engine with ID ${engineId} not found`);
|
||||
}
|
||||
|
||||
await this.settingRepo.manager.transaction(
|
||||
async (manager: EntityManager): Promise<void> => {
|
||||
const repo = manager.getRepository(SystemSetting);
|
||||
const existing = await repo.findOne({
|
||||
where: { settingKey: OCR_ACTIVE_ENGINE_KEY },
|
||||
});
|
||||
|
||||
const setting =
|
||||
existing ??
|
||||
repo.create({
|
||||
settingKey: OCR_ACTIVE_ENGINE_KEY,
|
||||
dataType: 'string',
|
||||
category: 'ai',
|
||||
description: 'เอนจิน OCR หลักที่ใช้งานในระบบ (global)',
|
||||
isPublic: true,
|
||||
});
|
||||
|
||||
setting.settingValue = engineId;
|
||||
setting.updatedBy = userId;
|
||||
await repo.save(setting);
|
||||
}
|
||||
);
|
||||
|
||||
await this.redis.del(OCR_ACTIVE_ENGINE_CACHE_KEY);
|
||||
this.logger.log(
|
||||
`Active OCR Engine changed to ${selectedEngine.engineName} (ID: ${engineId}) by user ${userId}`
|
||||
);
|
||||
return selectedEngine;
|
||||
}
|
||||
|
||||
/** ดึง ID ของ OCR Engine ที่ใช้งานอยู่ปัจจุบัน */
|
||||
async getActiveEngineId(): Promise<string> {
|
||||
try {
|
||||
const cachedValue = await this.redis.get(OCR_ACTIVE_ENGINE_CACHE_KEY);
|
||||
if (cachedValue) return cachedValue;
|
||||
|
||||
const setting = await this.settingRepo.findOne({
|
||||
where: { settingKey: OCR_ACTIVE_ENGINE_KEY },
|
||||
});
|
||||
|
||||
const activeEngine = setting?.settingValue ?? TESSERACT_ENGINE_ID;
|
||||
await this.redis.set(
|
||||
OCR_ACTIVE_ENGINE_CACHE_KEY,
|
||||
activeEngine,
|
||||
'EX',
|
||||
OCR_ACTIVE_ENGINE_TTL_SECONDS
|
||||
);
|
||||
return activeEngine;
|
||||
} catch (error: unknown) {
|
||||
this.logger.error(
|
||||
`Failed to get active OCR engine: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
return TESSERACT_ENGINE_ID;
|
||||
}
|
||||
}
|
||||
|
||||
/** แปลง local upload path เป็น path ที่ sidecar เห็นผ่าน CIFS mount */
|
||||
private remapPath(localPath: string): string {
|
||||
if (this.localUploadBase && localPath.startsWith(this.localUploadBase)) {
|
||||
@@ -103,19 +243,51 @@ export class OcrService {
|
||||
return { text: extractedText, ocrUsed: false };
|
||||
}
|
||||
|
||||
const activeEngineId = await this.getActiveEngineId();
|
||||
|
||||
if (activeEngineId === TYPHOON_ENGINE_ID) {
|
||||
return this.processWithTyphoon(input);
|
||||
} else {
|
||||
return this.processWithTesseract(input);
|
||||
}
|
||||
}
|
||||
|
||||
/** ประมวลผลผ่าน Tesseract OCR */
|
||||
private async processWithTesseract(
|
||||
input: OcrDetectionInput
|
||||
): Promise<OcrDetectionResult> {
|
||||
const startTime = Date.now();
|
||||
const sidecarPath = this.remapPath(input.pdfPath!);
|
||||
|
||||
try {
|
||||
const sidecarPath = this.remapPath(input.pdfPath);
|
||||
this.logger.debug(`OCR path remap: ${input.pdfPath} → ${sidecarPath}`);
|
||||
this.logger.debug(
|
||||
`Tesseract OCR processing: ${input.pdfPath} → ${sidecarPath}`
|
||||
);
|
||||
const response = await axios.post<OcrSidecarResponse>(
|
||||
`${this.ocrApiUrl}/ocr`,
|
||||
{ pdfPath: sidecarPath },
|
||||
{ timeout: 90000 }
|
||||
);
|
||||
|
||||
const text = response.data.text ?? '';
|
||||
const durationMs = Date.now() - startTime;
|
||||
|
||||
await this.writeAuditLog({
|
||||
documentPublicId: input.documentPublicId,
|
||||
aiModel: 'tesseract',
|
||||
modelName: 'tesseract-ocr',
|
||||
modelType: 'tesseract',
|
||||
status: AiAuditStatus.SUCCESS,
|
||||
processingTimeMs: durationMs,
|
||||
cacheHit: false,
|
||||
});
|
||||
|
||||
return {
|
||||
text: response.data.text ?? '',
|
||||
text,
|
||||
ocrUsed: true,
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
const durationMs = Date.now() - startTime;
|
||||
const cause =
|
||||
err instanceof AggregateError && err.errors?.length
|
||||
? err.errors
|
||||
@@ -124,9 +296,214 @@ export class OcrService {
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err);
|
||||
|
||||
await this.writeAuditLog({
|
||||
documentPublicId: input.documentPublicId,
|
||||
aiModel: 'tesseract',
|
||||
modelName: 'tesseract-ocr',
|
||||
modelType: 'tesseract',
|
||||
status: AiAuditStatus.FAILED,
|
||||
processingTimeMs: durationMs,
|
||||
cacheHit: false,
|
||||
errorMessage: cause,
|
||||
});
|
||||
|
||||
throw new Error(
|
||||
`OCR sidecar (Tesseract) unreachable at ${this.ocrApiUrl} — ${cause}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** ประมวลผลผ่าน Typhoon OCR พร้อม Caching และ Fallback */
|
||||
private async processWithTyphoon(
|
||||
input: OcrDetectionInput
|
||||
): Promise<OcrDetectionResult> {
|
||||
const startTime = Date.now();
|
||||
const pdfPath = input.pdfPath!;
|
||||
const engineType = 'typhoon-ocr-3b';
|
||||
|
||||
// 1. ตรวจสอบ Redis cache (T022)
|
||||
try {
|
||||
const cached = await this.ocrCacheService.get(pdfPath, engineType);
|
||||
if (cached) {
|
||||
this.logger.log(`OCR Cache Hit for Typhoon OCR: ${pdfPath}`);
|
||||
const durationMs = Date.now() - startTime;
|
||||
|
||||
await this.writeAuditLog({
|
||||
documentPublicId: input.documentPublicId,
|
||||
aiModel: 'typhoon-ocr',
|
||||
modelName: 'scb10x/typhoon-ocr-3b',
|
||||
modelType: engineType,
|
||||
status: AiAuditStatus.SUCCESS,
|
||||
processingTimeMs: durationMs,
|
||||
cacheHit: true,
|
||||
});
|
||||
|
||||
return {
|
||||
text: cached.text,
|
||||
ocrUsed: true,
|
||||
};
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
this.logger.warn(
|
||||
`Cache checking failed: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
|
||||
// 2. ตรวจสอบปริมาณ VRAM ก่อนประมวลผล (T016a)
|
||||
const hasCapacity = await this.vramMonitorService.hasVramCapacity(
|
||||
TYPHOON_OCR_REQUIRED_VRAM_MB
|
||||
);
|
||||
if (!hasCapacity) {
|
||||
const errorMsg = `VRAM capacity (< 4GB) insufficient for Typhoon OCR-3B. Fallback to Tesseract.`;
|
||||
return this.fallbackToTesseract(
|
||||
pdfPath,
|
||||
errorMsg,
|
||||
input.documentPublicId
|
||||
);
|
||||
}
|
||||
|
||||
// 3. เรียกประมวลผล Typhoon OCR
|
||||
const sidecarPath = this.remapPath(pdfPath);
|
||||
try {
|
||||
this.logger.log(`Calling Typhoon OCR-3B for: ${sidecarPath}`);
|
||||
const response = await axios.post<OcrSidecarResponse>(
|
||||
`${this.ocrApiUrl}/ocr`,
|
||||
{ pdfPath: sidecarPath, engine: engineType },
|
||||
{ timeout: 60000 } // 60s timeout per ADR-032
|
||||
);
|
||||
|
||||
const text = response.data.text ?? '';
|
||||
const durationMs = Date.now() - startTime;
|
||||
|
||||
// เซ็ต Cache ลง Redis 24 ชั่วโมง (T022)
|
||||
await this.ocrCacheService.set(pdfPath, engineType, {
|
||||
text,
|
||||
engineUsed: engineType,
|
||||
charCount: text.length,
|
||||
});
|
||||
|
||||
// Invalidate VRAM monitor cache เนื่องจากใช้ keep_alive = 0 โมเดลจะถูก unload ทันที
|
||||
await this.vramMonitorService.invalidateCache();
|
||||
|
||||
// บันทึก Audit Log (T023)
|
||||
await this.writeAuditLog({
|
||||
documentPublicId: input.documentPublicId,
|
||||
aiModel: 'typhoon-ocr',
|
||||
modelName: 'scb10x/typhoon-ocr-3b',
|
||||
modelType: engineType,
|
||||
status: AiAuditStatus.SUCCESS,
|
||||
processingTimeMs: durationMs,
|
||||
cacheHit: false,
|
||||
vramUsageMb: TYPHOON_OCR_REQUIRED_VRAM_MB,
|
||||
});
|
||||
|
||||
return {
|
||||
text,
|
||||
ocrUsed: true,
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
const cause = err instanceof Error ? err.message : String(err);
|
||||
const errorMsg = `Typhoon OCR API call failed: ${cause}`;
|
||||
|
||||
// 4. สลับเอนจินสำรองอัตโนมัติ (Graceful Fallback to Tesseract - T016)
|
||||
return this.fallbackToTesseract(
|
||||
pdfPath,
|
||||
errorMsg,
|
||||
input.documentPublicId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** สลับไปใช้งาน Tesseract OCR อัตโนมัติในฐานะระบบสำรอง (Graceful Fallback - T016) */
|
||||
private async fallbackToTesseract(
|
||||
pdfPath: string,
|
||||
originalError: string,
|
||||
documentPublicId?: string
|
||||
): Promise<OcrDetectionResult> {
|
||||
this.logger.warn(
|
||||
`Typhoon OCR processing failed, initiating graceful fallback to Tesseract: ${originalError}`
|
||||
);
|
||||
const startTime = Date.now();
|
||||
const sidecarPath = this.remapPath(pdfPath);
|
||||
|
||||
try {
|
||||
const response = await axios.post<OcrSidecarResponse>(
|
||||
`${this.ocrApiUrl}/ocr`,
|
||||
{ pdfPath: sidecarPath }, // ส่งโดยไม่มี engine parameter เพื่อให้เป็น Tesseract
|
||||
{ timeout: 30000 } // 30s timeout สำหรับ fallback
|
||||
);
|
||||
|
||||
const text = response.data.text ?? '';
|
||||
const durationMs = Date.now() - startTime;
|
||||
|
||||
// บันทึก Audit Log ด้วยสถานะ SUCCESS สำหรับ Tesseract แต่ระบุ Error ของ Typhoon ไว้
|
||||
await this.writeAuditLog({
|
||||
documentPublicId,
|
||||
aiModel: 'tesseract',
|
||||
modelName: 'tesseract-ocr',
|
||||
modelType: 'tesseract',
|
||||
status: AiAuditStatus.SUCCESS,
|
||||
processingTimeMs: durationMs,
|
||||
cacheHit: false,
|
||||
errorMessage: `Graceful fallback from Typhoon OCR. Original error: ${originalError}`,
|
||||
});
|
||||
|
||||
return {
|
||||
text,
|
||||
ocrUsed: true,
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
const durationMs = Date.now() - startTime;
|
||||
const cause = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(`OCR fallback to Tesseract failed: ${cause}`);
|
||||
|
||||
await this.writeAuditLog({
|
||||
documentPublicId,
|
||||
aiModel: 'tesseract',
|
||||
modelName: 'tesseract-ocr',
|
||||
modelType: 'tesseract',
|
||||
status: AiAuditStatus.FAILED,
|
||||
processingTimeMs: durationMs,
|
||||
cacheHit: false,
|
||||
errorMessage: `Fallback failed: ${cause}. Original Typhoon error: ${originalError}`,
|
||||
});
|
||||
|
||||
throw new Error(
|
||||
`OCR processing failed entirely. Typhoon error: ${originalError}. Fallback error: ${cause}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** เขียนบันทึก AI Audit Log (T023) */
|
||||
private async writeAuditLog(params: {
|
||||
documentPublicId?: string;
|
||||
aiModel: string;
|
||||
modelName: string;
|
||||
modelType: string;
|
||||
status: AiAuditStatus;
|
||||
processingTimeMs: number;
|
||||
cacheHit: boolean;
|
||||
vramUsageMb?: number;
|
||||
errorMessage?: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const log = this.auditLogRepo.create({
|
||||
documentPublicId: params.documentPublicId,
|
||||
aiModel: params.aiModel,
|
||||
modelName: params.modelName,
|
||||
modelType: params.modelType,
|
||||
status: params.status,
|
||||
processingTimeMs: params.processingTimeMs,
|
||||
cacheHit: params.cacheHit,
|
||||
vramUsageMb: params.vramUsageMb,
|
||||
errorMessage: params.errorMessage,
|
||||
});
|
||||
await this.auditLogRepo.save(log);
|
||||
} catch (err: unknown) {
|
||||
this.logger.warn(
|
||||
`Failed to write AI audit log: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// File: src/modules/ai/services/sandbox-ocr-engine.service.ts
|
||||
// Change Log
|
||||
// - 2026-05-30: แยก SandboxOcrEngineService ออกจาก OcrService เพื่อรองรับการเลือก Typhoon OCR เฉพาะ sandbox โดยไม่กระทบ core OCR flow
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios from 'axios';
|
||||
import { OcrService } from './ocr.service';
|
||||
|
||||
export type SandboxOcrEngineType = 'auto' | 'tesseract' | 'typhoon-ocr-3b';
|
||||
|
||||
interface SandboxOcrSidecarResponse {
|
||||
text?: string;
|
||||
ocrUsed?: boolean;
|
||||
engineUsed?: string;
|
||||
}
|
||||
|
||||
export interface SandboxOcrResult {
|
||||
text: string;
|
||||
ocrUsed: boolean;
|
||||
engineUsed: string;
|
||||
fallbackUsed: boolean;
|
||||
}
|
||||
|
||||
/** บริการ OCR สำหรับ sandbox เท่านั้น เพื่อแยก blast radius ออกจาก OcrService หลัก */
|
||||
@Injectable()
|
||||
export class SandboxOcrEngineService {
|
||||
private readonly logger = new Logger(SandboxOcrEngineService.name);
|
||||
private readonly ocrApiUrl: string;
|
||||
private readonly localUploadBase: string;
|
||||
private readonly sidecarUploadBase: string;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly ocrService: OcrService
|
||||
) {
|
||||
this.ocrApiUrl = this.configService.get<string>(
|
||||
'OCR_API_URL',
|
||||
'http://localhost:8765'
|
||||
);
|
||||
this.localUploadBase = this.configService
|
||||
.get<string>('UPLOAD_PERMANENT_DIR', '/app/uploads/permanent')
|
||||
.replace(/\/permanent$/, '');
|
||||
this.sidecarUploadBase = this.configService.get<string>(
|
||||
'OCR_SIDECAR_UPLOAD_BASE',
|
||||
'/mnt/uploads'
|
||||
);
|
||||
}
|
||||
|
||||
/** แปลง local upload path เป็น path ที่ sidecar เห็นผ่าน CIFS mount */
|
||||
private remapPath(localPath: string): string {
|
||||
if (this.localUploadBase && localPath.startsWith(this.localUploadBase)) {
|
||||
return localPath.replace(this.localUploadBase, this.sidecarUploadBase);
|
||||
}
|
||||
return localPath;
|
||||
}
|
||||
|
||||
/** รัน OCR ตาม engine ที่เลือก โดย fallback กลับไป Tesseract baseline เมื่อ Typhoon ล้มเหลว */
|
||||
async detectAndExtract(
|
||||
pdfPath: string,
|
||||
engineType: SandboxOcrEngineType = 'auto'
|
||||
): Promise<SandboxOcrResult> {
|
||||
if (engineType === 'auto' || engineType === 'tesseract') {
|
||||
const result = await this.ocrService.detectAndExtract({ pdfPath });
|
||||
return {
|
||||
text: result.text,
|
||||
ocrUsed: result.ocrUsed,
|
||||
engineUsed: result.ocrUsed ? 'tesseract' : 'fast-path',
|
||||
fallbackUsed: false,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.post<SandboxOcrSidecarResponse>(
|
||||
`${this.ocrApiUrl}/ocr`,
|
||||
{
|
||||
pdfPath: this.remapPath(pdfPath),
|
||||
engine: engineType,
|
||||
},
|
||||
{ timeout: 120000 }
|
||||
);
|
||||
|
||||
return {
|
||||
text: response.data.text ?? '',
|
||||
ocrUsed: response.data.ocrUsed ?? true,
|
||||
engineUsed: response.data.engineUsed ?? engineType,
|
||||
fallbackUsed: false,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const cause = error instanceof Error ? error.message : String(error);
|
||||
this.logger.warn(
|
||||
`Typhoon OCR failed in sandbox, falling back to Tesseract: ${cause}`
|
||||
);
|
||||
|
||||
const fallbackResult = await this.ocrService.detectAndExtract({
|
||||
pdfPath,
|
||||
});
|
||||
return {
|
||||
text: fallbackResult.text,
|
||||
ocrUsed: fallbackResult.ocrUsed,
|
||||
engineUsed: fallbackResult.ocrUsed ? 'tesseract' : 'fast-path',
|
||||
fallbackUsed: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// File: src/modules/ai/services/vram-monitor.service.ts
|
||||
// Change Log
|
||||
// - 2026-05-30: Initial implementation สำหรับ Typhoon OCR VRAM monitoring (T006, ADR-032)
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios from 'axios';
|
||||
import { InjectRedis } from '@nestjs-modules/ioredis';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
/** ข้อมูล VRAM จาก Ollama PS API */
|
||||
export interface OllamaModelInfo {
|
||||
name: string;
|
||||
size_vram: number; // bytes
|
||||
}
|
||||
|
||||
/** ผลลัพธ์ VRAM status */
|
||||
export interface VramStatus {
|
||||
totalVramMb: number;
|
||||
usedVramMb: number;
|
||||
freeVramMb: number;
|
||||
loadedModels: string[];
|
||||
hasCapacity: boolean; // true ถ้า free VRAM >= minRequiredMb
|
||||
}
|
||||
|
||||
/** ผลลัพธ์ภายในจาก Ollama /api/ps */
|
||||
interface OllamaProcessStatus {
|
||||
models?: OllamaModelInfo[];
|
||||
}
|
||||
|
||||
// Redis key สำหรับ cache VRAM status
|
||||
const VRAM_STATUS_CACHE_KEY = 'ai:vram:status';
|
||||
// TTL 10 วินาที — refresh บ่อยพอสำหรับ real-time monitoring
|
||||
const VRAM_STATUS_TTL_SECONDS = 10;
|
||||
// VRAM limit สำหรับ RTX 2060 Super (8192 MB)
|
||||
const GPU_TOTAL_VRAM_MB = 8192;
|
||||
// Threshold: ไม่โหลด model ถ้า usage > 90%
|
||||
const VRAM_USAGE_LIMIT_PERCENT = 0.9;
|
||||
|
||||
/** บริการตรวจสอบ VRAM GPU ผ่าน Ollama API ตาม ADR-032 */
|
||||
@Injectable()
|
||||
export class VramMonitorService {
|
||||
private readonly logger = new Logger(VramMonitorService.name);
|
||||
private readonly ollamaUrl: string;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
@InjectRedis() private readonly redis: Redis
|
||||
) {
|
||||
this.ollamaUrl = this.configService.get<string>(
|
||||
'OLLAMA_URL',
|
||||
this.configService.get<string>('AI_HOST_URL', 'http://localhost:11434')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ดึงสถานะ VRAM ปัจจุบันจาก Ollama /api/ps
|
||||
* ใช้ Redis cache TTL 10 วินาทีเพื่อลด overhead
|
||||
*/
|
||||
async getVramStatus(minRequiredMb = 4000): Promise<VramStatus> {
|
||||
const cached = await this.redis.get(VRAM_STATUS_CACHE_KEY);
|
||||
if (cached) {
|
||||
const parsed = JSON.parse(cached) as VramStatus;
|
||||
parsed.hasCapacity = parsed.freeVramMb >= minRequiredMb;
|
||||
return parsed;
|
||||
}
|
||||
return this.fetchAndCacheVramStatus(minRequiredMb);
|
||||
}
|
||||
|
||||
/** ตรวจสอบว่า VRAM เพียงพอสำหรับโหลด model ที่ต้องการ */
|
||||
async hasVramCapacity(requiredMb: number): Promise<boolean> {
|
||||
const status = await this.getVramStatus(requiredMb);
|
||||
return status.hasCapacity;
|
||||
}
|
||||
|
||||
/** ดึงข้อมูล VRAM จาก Ollama และ cache ใน Redis */
|
||||
private async fetchAndCacheVramStatus(
|
||||
minRequiredMb: number
|
||||
): Promise<VramStatus> {
|
||||
try {
|
||||
const response = await axios.get<OllamaProcessStatus>(
|
||||
`${this.ollamaUrl}/api/ps`,
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
const models = response.data.models ?? [];
|
||||
const loadedModels = models.map((m) => m.name);
|
||||
// คำนวณ VRAM ที่ใช้จาก models ที่โหลดอยู่
|
||||
const usedVramBytes = models.reduce(
|
||||
(sum, m) => sum + (m.size_vram ?? 0),
|
||||
0
|
||||
);
|
||||
const usedVramMb = Math.round(usedVramBytes / 1024 / 1024);
|
||||
// จำกัด VRAM ไม่เกิน limit 90% ของ GPU ทั้งหมด
|
||||
const maxAllowedMb = Math.floor(
|
||||
GPU_TOTAL_VRAM_MB * VRAM_USAGE_LIMIT_PERCENT
|
||||
);
|
||||
const freeVramMb = Math.max(0, maxAllowedMb - usedVramMb);
|
||||
const status: VramStatus = {
|
||||
totalVramMb: GPU_TOTAL_VRAM_MB,
|
||||
usedVramMb,
|
||||
freeVramMb,
|
||||
loadedModels,
|
||||
hasCapacity: freeVramMb >= minRequiredMb,
|
||||
};
|
||||
await this.redis.setex(
|
||||
VRAM_STATUS_CACHE_KEY,
|
||||
VRAM_STATUS_TTL_SECONDS,
|
||||
JSON.stringify(status)
|
||||
);
|
||||
return status;
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
this.logger.warn(
|
||||
`VRAM status fetch failed: ${msg} — ใช้ค่า conservative fallback`
|
||||
);
|
||||
// Fallback: สมมติว่า VRAM ไม่พอเมื่อ Ollama ไม่ตอบสนอง
|
||||
return {
|
||||
totalVramMb: GPU_TOTAL_VRAM_MB,
|
||||
usedVramMb: GPU_TOTAL_VRAM_MB,
|
||||
freeVramMb: 0,
|
||||
loadedModels: [],
|
||||
hasCapacity: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ล้าง VRAM cache (เรียกหลังจาก model unload ด้วย keep_alive=0)
|
||||
* เพื่อให้ status check ครั้งต่อไปดึงข้อมูลใหม่จาก Ollama
|
||||
*/
|
||||
async invalidateCache(): Promise<void> {
|
||||
await this.redis.del(VRAM_STATUS_CACHE_KEY);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user