feat(ai): add ADR-036 unified OCR architecture and frontend test coverage
- Add ADR-036 unified OCR architecture (typhoon-ocr via Ollama) - Extend AI execution profiles for OCR sandbox configuration - Add comprehensive frontend test coverage (components, hooks, services) - Add backend test coverage for document-numbering services - Update OCR sidecar with typhoon-ocr integration - Add AI policy service and execution profile management - Update AGENTS.md and architecture documentation
This commit is contained in:
@@ -2,16 +2,28 @@
|
||||
// Change Log:
|
||||
// - 2026-06-11: Initial creation of AiPolicyService for managing execution profiles and policies
|
||||
// - 2026-06-11: แก้ไขข้อผิดพลาด TS2367 (เทียบ profile กับ ocr-extract) และลบบรรทัดว่างในฟังก์ชัน getProfileParameters
|
||||
// - 2026-06-13: ADR-036 — เพิ่ม canonical model defaults และ OCR snapshot params
|
||||
// - 2026-06-13: T022 — เพิ่ม saveSandboxDraft (UPSERT sandbox draft)
|
||||
// - 2026-06-13: T023 — เพิ่ม resetSandboxToProduction (overwrite draft ด้วยค่า production)
|
||||
// - 2026-06-13: T035, T038 — เพิ่ม applyProfile และ validatePolicyParams สำหรับการปรับใช้ sandbox draft ไปยัง production
|
||||
// - 2026-06-13: T067, T068 — ปรับปรุง createJobPayload ให้ดึงพารามิเตอร์สำหรับ ocr-extract จาก model defaults
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
BadRequestException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRedis } from '@nestjs-modules/ioredis';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import type Redis from 'ioredis';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AiExecutionProfile } from '../entities/ai-execution-profile.entity';
|
||||
import { AiSandboxProfile } from '../entities/ai-sandbox-profile.entity';
|
||||
import {
|
||||
ExecutionProfile,
|
||||
InternalJobType,
|
||||
OcrSnapshotParams,
|
||||
RuntimePolicy,
|
||||
AiJobPayload,
|
||||
} from '../interfaces/execution-policy.interface';
|
||||
@@ -20,6 +32,7 @@ import {
|
||||
export class AiPolicyService {
|
||||
private readonly logger = new Logger(AiPolicyService.name);
|
||||
private readonly cachePrefix = 'ai_execution_profiles:';
|
||||
private readonly modelDefaultsCachePrefix = 'ai_execution_profiles:model:';
|
||||
private readonly cacheTtlSeconds = 60;
|
||||
|
||||
private readonly defaultProfiles: Record<ExecutionProfile, RuntimePolicy> = {
|
||||
@@ -61,9 +74,21 @@ export class AiPolicyService {
|
||||
},
|
||||
};
|
||||
|
||||
private readonly defaultOcrPolicy: RuntimePolicy = {
|
||||
canonicalModel: 'np-dms-ocr',
|
||||
temperature: 0.1,
|
||||
topP: 0.1,
|
||||
maxTokens: null,
|
||||
numCtx: null,
|
||||
repeatPenalty: 1.1,
|
||||
keepAliveSeconds: 0,
|
||||
};
|
||||
|
||||
constructor(
|
||||
@InjectRepository(AiExecutionProfile)
|
||||
private readonly profileRepo: Repository<AiExecutionProfile>,
|
||||
@InjectRepository(AiSandboxProfile)
|
||||
private readonly sandboxProfileRepo: Repository<AiSandboxProfile>,
|
||||
@InjectRedis() private readonly redis: Redis
|
||||
) {}
|
||||
|
||||
@@ -121,15 +146,7 @@ export class AiPolicyService {
|
||||
where: { profileName: profile, isActive: true },
|
||||
});
|
||||
if (dbProfile) {
|
||||
const policy: RuntimePolicy = {
|
||||
canonicalModel: 'np-dms-ai',
|
||||
temperature: Number(dbProfile.temperature),
|
||||
topP: Number(dbProfile.topP),
|
||||
maxTokens: dbProfile.maxTokens,
|
||||
numCtx: dbProfile.numCtx,
|
||||
repeatPenalty: Number(dbProfile.repeatPenalty),
|
||||
keepAliveSeconds: dbProfile.keepAliveSeconds,
|
||||
};
|
||||
const policy = this.toRuntimePolicy(dbProfile);
|
||||
try {
|
||||
await this.redis.set(
|
||||
cacheKey,
|
||||
@@ -152,6 +169,135 @@ export class AiPolicyService {
|
||||
return this.defaultProfiles[profile];
|
||||
}
|
||||
|
||||
/**
|
||||
* ดึงค่า default แยกตาม canonical model สำหรับ model-defaults rows เช่น ocr-extract
|
||||
*/
|
||||
async getModelDefaults(
|
||||
canonicalModel: 'np-dms-ai' | 'np-dms-ocr'
|
||||
): Promise<RuntimePolicy> {
|
||||
const cacheKey = `${this.modelDefaultsCachePrefix}${canonicalModel}`;
|
||||
try {
|
||||
const cached = await this.redis.get(cacheKey);
|
||||
if (cached) return JSON.parse(cached) as RuntimePolicy;
|
||||
} catch (cacheErr) {
|
||||
this.logger.warn(
|
||||
`Failed to read model defaults cache: ${cacheErr instanceof Error ? cacheErr.message : String(cacheErr)}`
|
||||
);
|
||||
}
|
||||
try {
|
||||
const dbProfile = await this.profileRepo.findOne({
|
||||
where: { canonicalModel, isActive: true },
|
||||
order: { updatedAt: 'DESC' },
|
||||
});
|
||||
if (dbProfile) {
|
||||
const policy = this.toRuntimePolicy(dbProfile);
|
||||
await this.cachePolicy(cacheKey, policy);
|
||||
return policy;
|
||||
}
|
||||
} catch (dbErr) {
|
||||
this.logger.error(
|
||||
`Failed to read model defaults from DB: ${dbErr instanceof Error ? dbErr.message : String(dbErr)}`
|
||||
);
|
||||
}
|
||||
return canonicalModel === 'np-dms-ocr'
|
||||
? this.defaultOcrPolicy
|
||||
: this.defaultProfiles.standard;
|
||||
}
|
||||
|
||||
/**
|
||||
* ดึง sandbox draft profile; ถ้ายังไม่มีจะ seed จาก production profile ปัจจุบัน
|
||||
*/
|
||||
async getSandboxParameters(profileName: string): Promise<RuntimePolicy> {
|
||||
const existing = await this.sandboxProfileRepo.findOne({
|
||||
where: { profileName },
|
||||
});
|
||||
if (existing) return this.toRuntimePolicy(existing);
|
||||
const productionPolicy = await this.getProductionPolicy(profileName);
|
||||
const draft = this.sandboxProfileRepo.create({
|
||||
profileName,
|
||||
canonicalModel: productionPolicy.canonicalModel,
|
||||
temperature: productionPolicy.temperature,
|
||||
topP: productionPolicy.topP,
|
||||
maxTokens: productionPolicy.maxTokens,
|
||||
numCtx: productionPolicy.numCtx,
|
||||
repeatPenalty: productionPolicy.repeatPenalty,
|
||||
keepAliveSeconds: productionPolicy.keepAliveSeconds,
|
||||
});
|
||||
return this.toRuntimePolicy(await this.sandboxProfileRepo.save(draft));
|
||||
}
|
||||
|
||||
/**
|
||||
* บันทึก sandbox draft parameters (UPSERT) — เปลี่ยนเฉพาะ fields ที่ระบุ
|
||||
*/
|
||||
async saveSandboxDraft(
|
||||
profileName: string,
|
||||
updates: Partial<{
|
||||
temperature: number;
|
||||
topP: number;
|
||||
maxTokens: number | null;
|
||||
numCtx: number | null;
|
||||
repeatPenalty: number;
|
||||
keepAliveSeconds: number;
|
||||
canonicalModel: 'np-dms-ai' | 'np-dms-ocr';
|
||||
}>,
|
||||
updatedBy?: number
|
||||
): Promise<RuntimePolicy> {
|
||||
let draft = await this.sandboxProfileRepo.findOne({
|
||||
where: { profileName },
|
||||
});
|
||||
if (!draft) {
|
||||
const productionPolicy = await this.getProductionPolicy(profileName);
|
||||
draft = this.sandboxProfileRepo.create({
|
||||
profileName,
|
||||
canonicalModel: productionPolicy.canonicalModel,
|
||||
temperature: productionPolicy.temperature,
|
||||
topP: productionPolicy.topP,
|
||||
maxTokens: productionPolicy.maxTokens,
|
||||
numCtx: productionPolicy.numCtx,
|
||||
repeatPenalty: productionPolicy.repeatPenalty,
|
||||
keepAliveSeconds: productionPolicy.keepAliveSeconds,
|
||||
});
|
||||
}
|
||||
if (updates.temperature !== undefined)
|
||||
draft.temperature = updates.temperature;
|
||||
if (updates.topP !== undefined) draft.topP = updates.topP;
|
||||
if (updates.maxTokens !== undefined) draft.maxTokens = updates.maxTokens;
|
||||
if (updates.numCtx !== undefined) draft.numCtx = updates.numCtx;
|
||||
if (updates.repeatPenalty !== undefined)
|
||||
draft.repeatPenalty = updates.repeatPenalty;
|
||||
if (updates.keepAliveSeconds !== undefined)
|
||||
draft.keepAliveSeconds = updates.keepAliveSeconds;
|
||||
if (updates.canonicalModel !== undefined)
|
||||
draft.canonicalModel = updates.canonicalModel;
|
||||
if (updatedBy !== undefined) draft.updatedBy = updatedBy;
|
||||
return this.toRuntimePolicy(await this.sandboxProfileRepo.save(draft));
|
||||
}
|
||||
|
||||
/**
|
||||
* รีเซ็ต sandbox draft ให้ตรงกับ production profile ปัจจุบัน
|
||||
*/
|
||||
async resetSandboxToProduction(
|
||||
profileName: string,
|
||||
updatedBy?: number
|
||||
): Promise<RuntimePolicy> {
|
||||
const productionPolicy = await this.getProductionPolicy(profileName);
|
||||
let draft = await this.sandboxProfileRepo.findOne({
|
||||
where: { profileName },
|
||||
});
|
||||
if (!draft) {
|
||||
draft = this.sandboxProfileRepo.create({ profileName });
|
||||
}
|
||||
draft.canonicalModel = productionPolicy.canonicalModel;
|
||||
draft.temperature = productionPolicy.temperature;
|
||||
draft.topP = productionPolicy.topP;
|
||||
draft.maxTokens = productionPolicy.maxTokens;
|
||||
draft.numCtx = productionPolicy.numCtx;
|
||||
draft.repeatPenalty = productionPolicy.repeatPenalty;
|
||||
draft.keepAliveSeconds = productionPolicy.keepAliveSeconds;
|
||||
if (updatedBy !== undefined) draft.updatedBy = updatedBy;
|
||||
return this.toRuntimePolicy(await this.sandboxProfileRepo.save(draft));
|
||||
}
|
||||
|
||||
/**
|
||||
* สร้าง payload ของ BullMQ job ที่มี snapshot parameters ณ เวลา dispatch
|
||||
*/
|
||||
@@ -163,7 +309,11 @@ export class AiPolicyService {
|
||||
const effectiveProfile = this.getProfileForJobType(jobType);
|
||||
const canonicalModel =
|
||||
jobType === 'ocr-extract' ? 'np-dms-ocr' : 'np-dms-ai';
|
||||
const policy = await this.getProfileParameters(effectiveProfile);
|
||||
const policy =
|
||||
jobType === 'ocr-extract'
|
||||
? await this.getModelDefaults('np-dms-ocr')
|
||||
: await this.getProfileParameters(effectiveProfile);
|
||||
const ocrSnapshotParams = await this.createOcrSnapshotParams(jobType);
|
||||
return {
|
||||
jobType,
|
||||
documentPublicId,
|
||||
@@ -178,6 +328,156 @@ export class AiPolicyService {
|
||||
repeatPenalty: policy.repeatPenalty,
|
||||
keepAliveSeconds: policy.keepAliveSeconds,
|
||||
},
|
||||
...(ocrSnapshotParams ? { ocrSnapshotParams } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
private toRuntimePolicy(
|
||||
profile: AiExecutionProfile | AiSandboxProfile
|
||||
): RuntimePolicy {
|
||||
return {
|
||||
canonicalModel: profile.canonicalModel ?? 'np-dms-ai',
|
||||
temperature: Number(profile.temperature),
|
||||
topP: Number(profile.topP),
|
||||
maxTokens: profile.maxTokens,
|
||||
numCtx: profile.numCtx,
|
||||
repeatPenalty: Number(profile.repeatPenalty),
|
||||
keepAliveSeconds: profile.keepAliveSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
private async getProductionPolicy(
|
||||
profileName: string
|
||||
): Promise<RuntimePolicy> {
|
||||
if (this.isExecutionProfile(profileName)) {
|
||||
return this.getProfileParameters(profileName);
|
||||
}
|
||||
if (profileName === 'ocr-extract') {
|
||||
return this.getModelDefaults('np-dms-ocr');
|
||||
}
|
||||
return this.defaultProfiles.standard;
|
||||
}
|
||||
|
||||
private isExecutionProfile(
|
||||
profileName: string
|
||||
): profileName is ExecutionProfile {
|
||||
return (
|
||||
profileName === 'interactive' ||
|
||||
profileName === 'standard' ||
|
||||
profileName === 'quality' ||
|
||||
profileName === 'deep-analysis'
|
||||
);
|
||||
}
|
||||
|
||||
private async cachePolicy(
|
||||
cacheKey: string,
|
||||
policy: RuntimePolicy
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.redis.set(
|
||||
cacheKey,
|
||||
JSON.stringify(policy),
|
||||
'EX',
|
||||
this.cacheTtlSeconds
|
||||
);
|
||||
} catch (cacheSetErr) {
|
||||
this.logger.warn(
|
||||
`Failed to write execution policy cache: ${cacheSetErr instanceof Error ? cacheSetErr.message : String(cacheSetErr)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async createOcrSnapshotParams(
|
||||
jobType: InternalJobType
|
||||
): Promise<OcrSnapshotParams | undefined> {
|
||||
if (
|
||||
jobType !== 'migrate-document' &&
|
||||
jobType !== 'auto-fill-document' &&
|
||||
jobType !== 'ocr-extract'
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const ocrPolicy = await this.getModelDefaults('np-dms-ocr');
|
||||
return {
|
||||
temperature: ocrPolicy.temperature,
|
||||
topP: ocrPolicy.topP,
|
||||
repeatPenalty: ocrPolicy.repeatPenalty,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply sandbox draft to production (copy sandbox profile -> execution profile)
|
||||
* And invalidate Redis cache key.
|
||||
*/
|
||||
async applyProfile(
|
||||
profileName: string,
|
||||
updatedBy?: number
|
||||
): Promise<RuntimePolicy> {
|
||||
const draft = await this.sandboxProfileRepo.findOne({
|
||||
where: { profileName },
|
||||
});
|
||||
if (!draft) {
|
||||
throw new NotFoundException(
|
||||
`Sandbox draft for profile ${profileName} not found`
|
||||
);
|
||||
}
|
||||
this.validatePolicyParams(draft);
|
||||
let production = await this.profileRepo.findOne({
|
||||
where: { profileName },
|
||||
});
|
||||
if (!production) {
|
||||
production = this.profileRepo.create({
|
||||
profileName,
|
||||
isActive: true,
|
||||
});
|
||||
}
|
||||
production.canonicalModel = draft.canonicalModel;
|
||||
production.temperature = draft.temperature;
|
||||
production.topP = draft.topP;
|
||||
production.maxTokens = draft.maxTokens;
|
||||
production.numCtx = draft.numCtx;
|
||||
production.repeatPenalty = draft.repeatPenalty;
|
||||
production.keepAliveSeconds = draft.keepAliveSeconds;
|
||||
if (updatedBy !== undefined) {
|
||||
production.updatedBy = updatedBy;
|
||||
}
|
||||
const saved = await this.profileRepo.save(production);
|
||||
const cacheKey = `${this.cachePrefix}${profileName}`;
|
||||
const modelDefaultsCacheKey = `${this.modelDefaultsCachePrefix}${draft.canonicalModel}`;
|
||||
try {
|
||||
await this.redis.del(cacheKey);
|
||||
await this.redis.del(modelDefaultsCacheKey);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Failed to invalidate cache: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
return this.toRuntimePolicy(saved);
|
||||
}
|
||||
|
||||
private validatePolicyParams(params: {
|
||||
temperature: number | string;
|
||||
topP: number | string;
|
||||
repeatPenalty: number | string;
|
||||
keepAliveSeconds: number;
|
||||
}): void {
|
||||
const temp = Number(params.temperature);
|
||||
const topP = Number(params.topP);
|
||||
const repeat = Number(params.repeatPenalty);
|
||||
const keepAlive = params.keepAliveSeconds;
|
||||
if (isNaN(temp) || temp < 0 || temp > 1) {
|
||||
throw new BadRequestException('Temperature must be between 0 and 1');
|
||||
}
|
||||
if (isNaN(topP) || topP < 0 || topP > 1) {
|
||||
throw new BadRequestException('Top-P must be between 0 and 1');
|
||||
}
|
||||
if (isNaN(repeat) || repeat < 1 || repeat > 2) {
|
||||
throw new BadRequestException('Repeat penalty must be between 1 and 2');
|
||||
}
|
||||
if (keepAlive < 0) {
|
||||
throw new BadRequestException(
|
||||
'Keep-alive seconds must be greater than or equal to 0'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
// - 2026-06-02: ส่งค่า X-API-Key ใน request headers ไปยัง ocr-sidecar เพื่อความมั่นคงปลอดภัยสูงสุด (ADR-033, Suggestion 2)
|
||||
// - 2026-06-04: ADR-034 — เปลี่ยน TYPHOON_ENGINE.engineName เป็น typhoon-np-dms-ocr:latest ตรงกับชื่อโมเดลใน Ollama
|
||||
// - 2026-06-11: US2 - คำนวณ OCR residency keep_alive แบบ dynamic ตาม VRAM headroom และ active profile
|
||||
// - 2026-06-13: US5 - เพิ่มการส่ง temperature, topP และ repeatPenalty ไปยัง OCR sidecar ผ่าน multipart form (T070)
|
||||
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
@@ -40,6 +41,11 @@ export interface OcrDetectionInput {
|
||||
pdfPath?: string;
|
||||
documentPublicId?: string; // เพิ่มเพื่อการทำ audit logs
|
||||
activeProfile?: ExecutionProfile;
|
||||
typhoonOptions?: {
|
||||
temperature?: number;
|
||||
topP?: number;
|
||||
repeatPenalty?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OcrDetectionResult {
|
||||
@@ -417,6 +423,18 @@ export class OcrService {
|
||||
);
|
||||
form.append('engine', 'typhoon-np-dms-ocr');
|
||||
form.append('keep_alive', String(keepAlive));
|
||||
if (input.typhoonOptions?.temperature !== undefined) {
|
||||
form.append('temperature', String(input.typhoonOptions.temperature));
|
||||
}
|
||||
if (input.typhoonOptions?.topP !== undefined) {
|
||||
form.append('topP', String(input.typhoonOptions.topP));
|
||||
}
|
||||
if (input.typhoonOptions?.repeatPenalty !== undefined) {
|
||||
form.append(
|
||||
'repeatPenalty',
|
||||
String(input.typhoonOptions.repeatPenalty)
|
||||
);
|
||||
}
|
||||
const response = await axios.post<OcrSidecarResponse>(
|
||||
`${this.ocrApiUrl}/ocr-upload`,
|
||||
form,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Change Log:
|
||||
// - 2026-06-03: สร้าง unit test สำหรับ OllamaService ครอบคลุม generate() model option,
|
||||
// getOcrModelName(), และ loadModel() keepAlive param ตาม ADR-034
|
||||
// - 2026-06-13: ADR-036 — อัปเดต expected model tags เป็น np-dms-ai/np-dms-ocr
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
@@ -15,8 +16,8 @@ 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_MAIN: 'np-dms-ai:latest',
|
||||
OLLAMA_MODEL_OCR: 'np-dms-ocr:latest',
|
||||
OLLAMA_MODEL_EMBED: 'nomic-embed-text',
|
||||
AI_TIMEOUT_MS: 30000,
|
||||
};
|
||||
@@ -36,13 +37,13 @@ describe('OllamaService (ADR-034)', () => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
describe('getMainModelName()', () => {
|
||||
it('ควรคืน typhoon2.5-np-dms:latest เป็น main model (ADR-034)', () => {
|
||||
expect(service.getMainModelName()).toBe('typhoon2.5-np-dms:latest');
|
||||
it('ควรคืน np-dms-ai:latest เป็น main model (ADR-036)', () => {
|
||||
expect(service.getMainModelName()).toBe('np-dms-ai:latest');
|
||||
});
|
||||
});
|
||||
describe('getOcrModelName()', () => {
|
||||
it('ควรคืน typhoon-np-dms-ocr:latest เป็น OCR model (ADR-034)', () => {
|
||||
expect(service.getOcrModelName()).toBe('typhoon-np-dms-ocr:latest');
|
||||
it('ควรคืน np-dms-ocr:latest เป็น OCR model (ADR-036)', () => {
|
||||
expect(service.getOcrModelName()).toBe('np-dms-ocr:latest');
|
||||
});
|
||||
});
|
||||
describe('generate()', () => {
|
||||
@@ -53,7 +54,7 @@ describe('OllamaService (ADR-034)', () => {
|
||||
await service.generate('test prompt');
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/generate'),
|
||||
expect.objectContaining({ model: 'typhoon2.5-np-dms:latest' }),
|
||||
expect.objectContaining({ model: 'np-dms-ai:latest' }),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
@@ -75,11 +76,11 @@ describe('OllamaService (ADR-034)', () => {
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ data: { response: 'ocr result' } });
|
||||
await service.generate('ocr prompt', {
|
||||
model: 'typhoon-np-dms-ocr:latest',
|
||||
model: 'np-dms-ocr:latest',
|
||||
});
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/generate'),
|
||||
expect.objectContaining({ model: 'typhoon-np-dms-ocr:latest' }),
|
||||
expect.objectContaining({ model: 'np-dms-ocr:latest' }),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
@@ -90,14 +91,14 @@ describe('OllamaService (ADR-034)', () => {
|
||||
data: {
|
||||
models: [
|
||||
{
|
||||
name: 'typhoon2.5-np-dms:latest',
|
||||
model: 'typhoon2.5-np-dms:latest',
|
||||
name: 'np-dms-ai:latest',
|
||||
model: 'np-dms-ai:latest',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
mockedAxios.post = jest.fn().mockResolvedValueOnce({ data: {} });
|
||||
await service.loadModel('typhoon2.5-np-dms:latest');
|
||||
await service.loadModel('np-dms-ai:latest');
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/generate'),
|
||||
expect.objectContaining({ keep_alive: -1 }),
|
||||
@@ -109,14 +110,14 @@ describe('OllamaService (ADR-034)', () => {
|
||||
data: {
|
||||
models: [
|
||||
{
|
||||
name: 'typhoon-np-dms-ocr:latest',
|
||||
model: 'typhoon-np-dms-ocr:latest',
|
||||
name: 'np-dms-ocr:latest',
|
||||
model: 'np-dms-ocr:latest',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
mockedAxios.post = jest.fn().mockResolvedValueOnce({ data: {} });
|
||||
await service.loadModel('typhoon-np-dms-ocr:latest', 0);
|
||||
await service.loadModel('np-dms-ocr:latest', 0);
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/generate'),
|
||||
expect.objectContaining({ keep_alive: 0 }),
|
||||
@@ -127,7 +128,7 @@ describe('OllamaService (ADR-034)', () => {
|
||||
mockedAxios.get = jest.fn().mockResolvedValueOnce({
|
||||
data: { models: [{ name: 'other-model', model: 'other-model' }] },
|
||||
});
|
||||
const result = await service.loadModel('typhoon-np-dms-ocr:latest', 0);
|
||||
const result = await service.loadModel('np-dms-ocr:latest', 0);
|
||||
expect(result).toBe(false);
|
||||
expect(mockedAxios.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
// - 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
|
||||
// - 2026-06-08: เพิ่ม num_predict ใน OllamaGenerateOptions.options — ป้องกัน JSON truncation เมื่อ LLM สร้าง structured output
|
||||
// - 2026-06-13: ADR-036 — เปลี่ยน default model tags เป็น np-dms-ai/np-dms-ocr
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
@@ -55,11 +56,11 @@ export class OllamaService {
|
||||
);
|
||||
this.mainModel = this.configService.get<string>(
|
||||
'OLLAMA_MODEL_MAIN',
|
||||
'typhoon2.5-np-dms:latest'
|
||||
'np-dms-ai:latest'
|
||||
);
|
||||
this.ocrModel = this.configService.get<string>(
|
||||
'OLLAMA_MODEL_OCR',
|
||||
'typhoon-np-dms-ocr:latest'
|
||||
'np-dms-ocr:latest'
|
||||
);
|
||||
this.embedModel = this.configService.get<string>(
|
||||
'OLLAMA_MODEL_EMBED',
|
||||
@@ -68,7 +69,7 @@ export class OllamaService {
|
||||
this.timeoutMs = this.configService.get<number>('AI_TIMEOUT_MS', 30000);
|
||||
}
|
||||
|
||||
/** สร้างข้อความตอบกลับด้วย typhoon2.5-np-dms:latest หรือโมเดลที่ระบุใน options.model / ENV */
|
||||
/** สร้างข้อความตอบกลับด้วย np-dms-ai:latest หรือโมเดลที่ระบุใน options.model / ENV */
|
||||
async generate(
|
||||
prompt: string,
|
||||
options: OllamaGenerateOptions = {}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// - 2026-06-02: ส่งค่า X-API-Key ใน request headers ไปยัง ocr-sidecar เพื่อความมั่นคงปลอดภัยสูงสุด (ADR-033, Suggestion 2)
|
||||
// - 2026-06-04: ADR-034 — เพิ่ม 'typhoon-np-dms-ocr' เป็น canonical SandboxOcrEngineType; legacy aliases ยังรองรับ
|
||||
// - 2026-06-04: เพิ่ม OcrTyphoonOptions interface; รับ temperature/topP/repeatPenalty จาก frontend sandbox เพื่อ override Modelfile defaults
|
||||
// - 2026-06-13: ADR-036 — เปลี่ยน canonical SandboxOcrEngineType เป็น np-dms-ocr และคง legacy alias
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
@@ -12,7 +13,11 @@ import axios from 'axios';
|
||||
import * as fs from 'fs';
|
||||
import { OcrService } from './ocr.service';
|
||||
|
||||
export type SandboxOcrEngineType = 'auto' | 'tesseract' | 'typhoon-np-dms-ocr';
|
||||
export type SandboxOcrEngineType =
|
||||
| 'auto'
|
||||
| 'tesseract'
|
||||
| 'np-dms-ocr'
|
||||
| 'typhoon-np-dms-ocr';
|
||||
|
||||
/** ค่า parameter สำหรับ Typhoon OCR ที่ override Modelfile defaults ได้จาก sandbox UI */
|
||||
export interface OcrTyphoonOptions {
|
||||
@@ -60,12 +65,14 @@ export class SandboxOcrEngineService {
|
||||
engineType: SandboxOcrEngineType = 'auto',
|
||||
typhoonOptions?: OcrTyphoonOptions
|
||||
): Promise<SandboxOcrResult> {
|
||||
const resolvedEngineType =
|
||||
engineType === 'typhoon-np-dms-ocr' ? 'np-dms-ocr' : engineType;
|
||||
this.logger.log(
|
||||
`detectAndExtract called — engine="${engineType}" pdfPath="${pdfPath}" typhoonOptions=${JSON.stringify(typhoonOptions ?? null)}`
|
||||
`detectAndExtract called — engine="${resolvedEngineType}" pdfPath="${pdfPath}" typhoonOptions=${JSON.stringify(typhoonOptions ?? null)}`
|
||||
);
|
||||
if (engineType === 'auto' || engineType === 'tesseract') {
|
||||
if (resolvedEngineType === 'auto' || resolvedEngineType === 'tesseract') {
|
||||
this.logger.log(
|
||||
`engine="${engineType}" → routing to Tesseract/fast-path`
|
||||
`engine="${resolvedEngineType}" → routing to Tesseract/fast-path`
|
||||
);
|
||||
const result = await this.ocrService.detectAndExtract({ pdfPath });
|
||||
return {
|
||||
@@ -77,7 +84,7 @@ export class SandboxOcrEngineService {
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`engine="typhoon-np-dms-ocr" → calling sidecar at ${this.ocrApiUrl}/ocr-upload`
|
||||
`engine="np-dms-ocr" → calling sidecar at ${this.ocrApiUrl}/ocr-upload`
|
||||
);
|
||||
try {
|
||||
let fileBuffer: Buffer;
|
||||
@@ -99,7 +106,7 @@ export class SandboxOcrEngineService {
|
||||
new Blob([new Uint8Array(fileBuffer)], { type: 'application/pdf' }),
|
||||
'upload.pdf'
|
||||
);
|
||||
form.append('engine', engineType);
|
||||
form.append('engine', resolvedEngineType);
|
||||
if (typhoonOptions?.temperature !== undefined) {
|
||||
form.append('temperature', String(typhoonOptions.temperature));
|
||||
}
|
||||
@@ -127,7 +134,7 @@ export class SandboxOcrEngineService {
|
||||
return {
|
||||
text: response.data.text ?? '',
|
||||
ocrUsed: response.data.ocrUsed ?? true,
|
||||
engineUsed: response.data.engineUsed ?? engineType,
|
||||
engineUsed: response.data.engineUsed ?? resolvedEngineType,
|
||||
fallbackUsed: false,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
|
||||
Reference in New Issue
Block a user