feat(ai): implement unified prompt management UX/UI (ADR-037)
- Add context config endpoints (GET/PUT /api/ai/prompts/:type/:version/context-config) - Add execution profile endpoints (CRUD /api/ai/execution-profiles) - Add sandbox RAG Prep endpoint (POST /api/ai/admin/sandbox/rag-prep) - Create Prompt Management UI with multi-type support - Add ContextConfigEditor, PromptEditor, RuntimeParametersPanel components - Add SandboxTabs for 3-step workflow (OCR, Extract, RAG Prep) - Add database deltas for ai_execution_profiles and additional prompt types - Update quickstart.md with production backend URLs - Add comprehensive test coverage for new features
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
// - 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
|
||||
// - 2026-06-14: เพิ่ม tests สำหรับ generateEmbedding, checkHealth, unloadModel เพื่อเพิ่ม branch coverage
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
@@ -132,5 +133,125 @@ describe('OllamaService (ADR-034)', () => {
|
||||
expect(result).toBe(false);
|
||||
expect(mockedAxios.post).not.toHaveBeenCalled();
|
||||
});
|
||||
it('ควรคืน false และ log error เมื่อ axios throw ระหว่าง loadModel', async () => {
|
||||
mockedAxios.get = jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('ECONNREFUSED'));
|
||||
const result = await service.loadModel('np-dms-ai:latest');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
describe('getEmbeddingModelName()', () => {
|
||||
it('ควรคืน nomic-embed-text เป็น embedding model', () => {
|
||||
expect(service.getEmbeddingModelName()).toBe('nomic-embed-text');
|
||||
});
|
||||
});
|
||||
describe('generateEmbedding()', () => {
|
||||
it('ควรคืน embedding vector เมื่อ Ollama ตอบกลับสำเร็จ', async () => {
|
||||
const mockVector = [0.1, 0.2, 0.3];
|
||||
mockedAxios.post = jest.fn().mockResolvedValueOnce({
|
||||
data: { embedding: mockVector },
|
||||
});
|
||||
const result = await service.generateEmbedding('test text');
|
||||
expect(result).toEqual(mockVector);
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/embeddings'),
|
||||
expect.objectContaining({
|
||||
model: 'nomic-embed-text',
|
||||
prompt: 'test text',
|
||||
}),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
it('ควร throw error เมื่อ Ollama embedding ล้มเหลว', async () => {
|
||||
mockedAxios.post = jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('Embedding failed'));
|
||||
await expect(service.generateEmbedding('test')).rejects.toThrow(
|
||||
'Embedding failed'
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('checkHealth()', () => {
|
||||
it('ควรคืน HEALTHY พร้อมโมเดลที่โหลดอยู่จาก /api/ps เมื่อ Ollama ตอบกลับสำเร็จ', async () => {
|
||||
mockedAxios.get = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ data: {} }) // /api/tags
|
||||
.mockResolvedValueOnce({
|
||||
data: { models: [{ name: 'np-dms-ai:latest' }] },
|
||||
}); // /api/ps
|
||||
const result = await service.checkHealth();
|
||||
expect(result.status).toBe('HEALTHY');
|
||||
expect(result.models).toContain('np-dms-ai:latest');
|
||||
expect(result.latencyMs).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
it('ควรคืน HEALTHY พร้อม fallback models เมื่อ /api/ps ไม่มีข้อมูล', async () => {
|
||||
mockedAxios.get = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ data: {} }) // /api/tags OK
|
||||
.mockResolvedValueOnce({ data: { models: [] } }); // /api/ps empty
|
||||
const result = await service.checkHealth();
|
||||
expect(result.status).toBe('HEALTHY');
|
||||
expect(result.models).toContain('np-dms-ai:latest'); // fallback
|
||||
});
|
||||
it('ควรคืน HEALTHY แม้ /api/ps throw error (graceful degradation)', async () => {
|
||||
mockedAxios.get = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ data: {} }) // /api/tags OK
|
||||
.mockRejectedValueOnce(new Error('ps endpoint error')); // /api/ps fails
|
||||
const result = await service.checkHealth();
|
||||
expect(result.status).toBe('HEALTHY');
|
||||
});
|
||||
it('ควรคืน DEGRADED เมื่อ /api/tags timeout', async () => {
|
||||
mockedAxios.get = jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('timeout error'));
|
||||
const result = await service.checkHealth();
|
||||
expect(result.status).toBe('DEGRADED');
|
||||
expect(result.error).toContain('timeout');
|
||||
});
|
||||
it('ควรคืน DEGRADED เมื่อ error message มี code ECONNABORTED', async () => {
|
||||
mockedAxios.get = jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('code ECONNABORTED'));
|
||||
const result = await service.checkHealth();
|
||||
expect(result.status).toBe('DEGRADED');
|
||||
});
|
||||
it('ควรคืน DOWN เมื่อ connection ถูกปฏิเสธ (ไม่ใช่ timeout)', async () => {
|
||||
mockedAxios.get = jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('ECONNREFUSED'));
|
||||
const result = await service.checkHealth();
|
||||
expect(result.status).toBe('DOWN');
|
||||
});
|
||||
});
|
||||
describe('unloadModel()', () => {
|
||||
it('ควรคืน true เมื่อ unload สำเร็จ', async () => {
|
||||
mockedAxios.post = jest.fn().mockResolvedValueOnce({ data: {} });
|
||||
const result = await service.unloadModel('np-dms-ocr:latest');
|
||||
expect(result).toBe(true);
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/generate'),
|
||||
expect.objectContaining({ model: 'np-dms-ocr:latest', keep_alive: 0 }),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
it('ควรคืน false เมื่อ unload ล้มเหลว', async () => {
|
||||
mockedAxios.post = jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('Unload failed'));
|
||||
const result = await service.unloadModel('np-dms-ocr:latest');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
describe('generate() error path', () => {
|
||||
it('ควร throw error เมื่อ Ollama generate ล้มเหลว', async () => {
|
||||
mockedAxios.post = jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('LLM timeout'));
|
||||
await expect(service.generate('test prompt')).rejects.toThrow(
|
||||
'LLM timeout'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
// File: src/modules/ai/services/sandbox-ocr-engine.service.spec.ts
|
||||
// Change Log:
|
||||
// - 2026-06-14: สร้าง unit tests สำหรับ SandboxOcrEngineService ครอบคลุม detectAndExtract ทุก engine
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios from 'axios';
|
||||
import * as fs from 'fs';
|
||||
import { SandboxOcrEngineService } from './sandbox-ocr-engine.service';
|
||||
import { OcrService } from './ocr.service';
|
||||
|
||||
jest.mock('axios');
|
||||
jest.mock('fs');
|
||||
|
||||
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
||||
const mockedFs = fs as jest.Mocked<typeof fs>;
|
||||
|
||||
/** OcrService mock สำหรับ tesseract/fast-path */
|
||||
const mockOcrService = {
|
||||
detectAndExtract: jest.fn(),
|
||||
};
|
||||
|
||||
/** ConfigService mock */
|
||||
const mockConfigService = {
|
||||
get: jest.fn(<T>(key: string, defaultValue?: T): T | undefined => {
|
||||
const cfg: Record<string, unknown> = {
|
||||
OCR_API_URL: 'http://localhost:8765',
|
||||
OCR_SIDECAR_API_KEY: 'test-api-key-2026',
|
||||
};
|
||||
return (cfg[key] as T | undefined) ?? defaultValue;
|
||||
}),
|
||||
};
|
||||
|
||||
describe('SandboxOcrEngineService', () => {
|
||||
let service: SandboxOcrEngineService;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
SandboxOcrEngineService,
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
{ provide: OcrService, useValue: mockOcrService },
|
||||
],
|
||||
}).compile();
|
||||
service = module.get<SandboxOcrEngineService>(SandboxOcrEngineService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('detectAndExtract() — engine=auto', () => {
|
||||
it('ควร route ไปยัง OcrService เมื่อ engine=auto', async () => {
|
||||
mockOcrService.detectAndExtract.mockResolvedValueOnce({
|
||||
text: 'auto extracted text',
|
||||
ocrUsed: true,
|
||||
});
|
||||
const result = await service.detectAndExtract('/tmp/file.pdf', 'auto');
|
||||
expect(result.text).toBe('auto extracted text');
|
||||
expect(result.engineUsed).toBe('tesseract');
|
||||
expect(result.fallbackUsed).toBe(false);
|
||||
expect(mockOcrService.detectAndExtract).toHaveBeenCalledWith({
|
||||
pdfPath: '/tmp/file.pdf',
|
||||
});
|
||||
});
|
||||
|
||||
it('ควรใช้ fast-path engineUsed เมื่อ OcrService คืน ocrUsed=false', async () => {
|
||||
mockOcrService.detectAndExtract.mockResolvedValueOnce({
|
||||
text: 'embedded text',
|
||||
ocrUsed: false,
|
||||
});
|
||||
const result = await service.detectAndExtract('/tmp/file.pdf', 'auto');
|
||||
expect(result.engineUsed).toBe('fast-path');
|
||||
expect(result.fallbackUsed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectAndExtract() — engine=tesseract', () => {
|
||||
it('ควร route ไปยัง OcrService เมื่อ engine=tesseract', async () => {
|
||||
mockOcrService.detectAndExtract.mockResolvedValueOnce({
|
||||
text: 'tesseract text',
|
||||
ocrUsed: true,
|
||||
});
|
||||
const result = await service.detectAndExtract(
|
||||
'/tmp/file.pdf',
|
||||
'tesseract'
|
||||
);
|
||||
expect(result.engineUsed).toBe('tesseract');
|
||||
expect(result.fallbackUsed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectAndExtract() — engine=typhoon-np-dms-ocr (legacy alias)', () => {
|
||||
it('ควรแปลง typhoon-np-dms-ocr เป็น np-dms-ocr และส่งไปยัง sidecar', async () => {
|
||||
const mockBuffer = Buffer.from('pdf content');
|
||||
(mockedFs.readFileSync as jest.Mock).mockReturnValueOnce(mockBuffer);
|
||||
mockedAxios.post = jest.fn().mockResolvedValueOnce({
|
||||
data: {
|
||||
text: 'ocr text via alias',
|
||||
ocrUsed: true,
|
||||
engineUsed: 'np-dms-ocr',
|
||||
},
|
||||
});
|
||||
const result = await service.detectAndExtract(
|
||||
'/tmp/file.pdf',
|
||||
'typhoon-np-dms-ocr'
|
||||
);
|
||||
expect(result.text).toBe('ocr text via alias');
|
||||
expect(result.engineUsed).toBe('np-dms-ocr');
|
||||
expect(result.fallbackUsed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectAndExtract() — engine=np-dms-ocr (sidecar path)', () => {
|
||||
it('ควรส่ง file ไปยัง sidecar /ocr-upload สำเร็จ', async () => {
|
||||
const mockBuffer = Buffer.from('pdf binary data');
|
||||
(mockedFs.readFileSync as jest.Mock).mockReturnValueOnce(mockBuffer);
|
||||
mockedAxios.post = jest.fn().mockResolvedValueOnce({
|
||||
data: {
|
||||
text: 'extracted from typhoon',
|
||||
ocrUsed: true,
|
||||
engineUsed: 'np-dms-ocr',
|
||||
},
|
||||
});
|
||||
const result = await service.detectAndExtract(
|
||||
'/tmp/doc.pdf',
|
||||
'np-dms-ocr'
|
||||
);
|
||||
expect(result.text).toBe('extracted from typhoon');
|
||||
expect(result.ocrUsed).toBe(true);
|
||||
expect(result.engineUsed).toBe('np-dms-ocr');
|
||||
expect(result.fallbackUsed).toBe(false);
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/ocr-upload'),
|
||||
expect.any(FormData),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'X-API-Key': 'test-api-key-2026',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('ควรส่ง typhoonOptions (temperature, topP, repeatPenalty) ไปใน form data', async () => {
|
||||
const mockBuffer = Buffer.from('pdf data');
|
||||
(mockedFs.readFileSync as jest.Mock).mockReturnValueOnce(mockBuffer);
|
||||
mockedAxios.post = jest.fn().mockResolvedValueOnce({
|
||||
data: { text: 'result', ocrUsed: true, engineUsed: 'np-dms-ocr' },
|
||||
});
|
||||
await service.detectAndExtract('/tmp/doc.pdf', 'np-dms-ocr', {
|
||||
temperature: 0.5,
|
||||
topP: 0.8,
|
||||
repeatPenalty: 1.2,
|
||||
});
|
||||
expect(mockedAxios.post).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ควรใช้ fallback values เมื่อ sidecar response ไม่มี text/ocrUsed/engineUsed', async () => {
|
||||
const mockBuffer = Buffer.from('pdf data');
|
||||
(mockedFs.readFileSync as jest.Mock).mockReturnValueOnce(mockBuffer);
|
||||
mockedAxios.post = jest.fn().mockResolvedValueOnce({
|
||||
data: {},
|
||||
});
|
||||
const result = await service.detectAndExtract(
|
||||
'/tmp/doc.pdf',
|
||||
'np-dms-ocr'
|
||||
);
|
||||
expect(result.text).toBe('');
|
||||
expect(result.ocrUsed).toBe(true);
|
||||
expect(result.engineUsed).toBe('np-dms-ocr'); // resolvedEngineType fallback
|
||||
});
|
||||
|
||||
it('ควร fallback ไปยัง Tesseract เมื่อ fs.readFileSync ล้มเหลว (outer catch fallback)', async () => {
|
||||
(mockedFs.readFileSync as jest.Mock).mockImplementationOnce(() => {
|
||||
throw new Error('ENOENT: file not found');
|
||||
});
|
||||
// service จะ catch error และ fallback ไปยัง Tesseract
|
||||
mockOcrService.detectAndExtract.mockResolvedValueOnce({
|
||||
text: 'tesseract fallback text',
|
||||
ocrUsed: true,
|
||||
});
|
||||
const result = await service.detectAndExtract(
|
||||
'/tmp/missing.pdf',
|
||||
'np-dms-ocr'
|
||||
);
|
||||
expect(result.fallbackUsed).toBe(true);
|
||||
expect(result.engineUsed).toBe('tesseract');
|
||||
});
|
||||
|
||||
it('ควร fallback ไปยัง Tesseract เมื่อ sidecar HTTP error เกิดขึ้น', async () => {
|
||||
const mockBuffer = Buffer.from('pdf data');
|
||||
(mockedFs.readFileSync as jest.Mock).mockReturnValueOnce(mockBuffer);
|
||||
mockedAxios.post = jest.fn().mockRejectedValueOnce(
|
||||
Object.assign(new Error('Request failed'), {
|
||||
response: { status: 500, data: { detail: 'Internal Server Error' } },
|
||||
})
|
||||
);
|
||||
mockOcrService.detectAndExtract.mockResolvedValueOnce({
|
||||
text: 'tesseract fallback result',
|
||||
ocrUsed: true,
|
||||
});
|
||||
const result = await service.detectAndExtract(
|
||||
'/tmp/doc.pdf',
|
||||
'np-dms-ocr'
|
||||
);
|
||||
expect(result.text).toBe('tesseract fallback result');
|
||||
expect(result.fallbackUsed).toBe(true);
|
||||
expect(result.engineUsed).toBe('tesseract');
|
||||
});
|
||||
|
||||
it('ควร fallback ไปยัง fast-path เมื่อ sidecar error และ OcrService ส่ง ocrUsed=false', async () => {
|
||||
const mockBuffer = Buffer.from('pdf data');
|
||||
(mockedFs.readFileSync as jest.Mock).mockReturnValueOnce(mockBuffer);
|
||||
mockedAxios.post = jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('Connection refused'));
|
||||
mockOcrService.detectAndExtract.mockResolvedValueOnce({
|
||||
text: 'embedded text',
|
||||
ocrUsed: false,
|
||||
});
|
||||
const result = await service.detectAndExtract(
|
||||
'/tmp/doc.pdf',
|
||||
'np-dms-ocr'
|
||||
);
|
||||
expect(result.engineUsed).toBe('fast-path');
|
||||
expect(result.fallbackUsed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectAndExtract() — default engine (no arg)', () => {
|
||||
it('ควรใช้ auto เป็น default engine เมื่อไม่ระบุ engineType', async () => {
|
||||
mockOcrService.detectAndExtract.mockResolvedValueOnce({
|
||||
text: 'default text',
|
||||
ocrUsed: false,
|
||||
});
|
||||
const result = await service.detectAndExtract('/tmp/file.pdf');
|
||||
expect(result.fallbackUsed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectAndExtract() — edge cases', () => {
|
||||
it('ควร handle axios error ที่ไม่มี response.status gracefully', async () => {
|
||||
const mockBuffer = Buffer.from('pdf data');
|
||||
(mockedFs.readFileSync as jest.Mock).mockReturnValueOnce(mockBuffer);
|
||||
mockedAxios.post = jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('Network unreachable'));
|
||||
mockOcrService.detectAndExtract.mockResolvedValueOnce({
|
||||
text: 'fallback text',
|
||||
ocrUsed: true,
|
||||
});
|
||||
const result = await service.detectAndExtract(
|
||||
'/tmp/doc.pdf',
|
||||
'np-dms-ocr'
|
||||
);
|
||||
expect(result.fallbackUsed).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user