feat(ai): add ADR-036 unified OCR architecture and frontend test coverage
CI / CD Pipeline / build (push) Failing after 6m24s
CI / CD Pipeline / deploy (push) Has been skipped

- 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:
2026-06-14 06:34:07 +07:00
parent e3503b6a77
commit 7e8f4859cd
108 changed files with 33914 additions and 339 deletions
@@ -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) {