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
@@ -0,0 +1,290 @@
// File: backend/tests/integration/modules/ai/ai-policy.service.integration.spec.ts
// Change Log:
// - 2026-06-13: T034 — Integration test สำหรับ apply flow (sandbox draft → validate → production + cache DEL)
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { NotFoundException, BadRequestException } from '@nestjs/common';
import { AiPolicyService } from '../../../../src/modules/ai/services/ai-policy.service';
import { AiExecutionProfile } from '../../../../src/modules/ai/entities/ai-execution-profile.entity';
import { AiSandboxProfile } from '../../../../src/modules/ai/entities/ai-sandbox-profile.entity';
const DEFAULT_REDIS_TOKEN = 'default_IORedisModuleConnectionToken';
/**
* Integration test สำหรับ Apply Profile Flow (T034 — ADR-036)
*
* ครอบคลุม cross-service interactions:
* 1. Full apply flow: sandbox draft → validation → copy to production → Redis cache DEL
* 2. Idempotency logic: duplicate key ใน Redis ต้องไม่ apply ซ้ำ
* 3. Parameter range validation propagation
* 4. Cache miss → DB fallback → cache set → subsequent cache hit
*/
describe('AiPolicyService — Apply Flow Integration (T034)', () => {
let service: AiPolicyService;
const productionRow = {
profileName: 'standard',
canonicalModel: 'np-dms-ai' as const,
isActive: true,
temperature: 0.4,
topP: 0.85,
maxTokens: 3000,
numCtx: 6000,
repeatPenalty: 1.2,
keepAliveSeconds: 300,
updatedBy: undefined as number | undefined,
};
const sandboxDraft = {
profileName: 'standard',
canonicalModel: 'np-dms-ai' as const,
temperature: 0.65,
topP: 0.9,
maxTokens: 4096,
numCtx: 8192,
repeatPenalty: 1.15,
keepAliveSeconds: 600,
};
let mockProfileRepo: {
findOne: jest.Mock;
create: jest.Mock;
save: jest.Mock;
};
let mockSandboxProfileRepo: {
findOne: jest.Mock;
create: jest.Mock;
save: jest.Mock;
};
let mockRedis: {
get: jest.Mock;
set: jest.Mock;
del: jest.Mock;
};
beforeEach(async () => {
const savedProductionRow = { ...productionRow };
mockProfileRepo = {
findOne: jest.fn().mockResolvedValue({ ...savedProductionRow }),
create: jest.fn((input: unknown) => ({ ...(input as object) })),
save: jest.fn((input: unknown) => {
Object.assign(savedProductionRow, input as object);
return Promise.resolve({ ...savedProductionRow });
}),
};
mockSandboxProfileRepo = {
findOne: jest.fn().mockResolvedValue({ ...sandboxDraft }),
create: jest.fn((input: unknown) => ({ ...(input as object) })),
save: jest.fn((input: unknown) =>
Promise.resolve({ ...(input as object) })
),
};
mockRedis = {
get: jest.fn().mockResolvedValue(null),
set: jest.fn().mockResolvedValue('OK'),
del: jest.fn().mockResolvedValue(1),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
AiPolicyService,
{
provide: getRepositoryToken(AiExecutionProfile),
useValue: mockProfileRepo,
},
{
provide: getRepositoryToken(AiSandboxProfile),
useValue: mockSandboxProfileRepo,
},
{ provide: DEFAULT_REDIS_TOKEN, useValue: mockRedis },
],
}).compile();
service = module.get<AiPolicyService>(AiPolicyService);
});
describe('Full apply flow: draft → validate → production → cache DEL', () => {
it('ควรคัดลอกค่าจาก sandbox draft ไปยัง production row และลบ Redis cache ทั้งสองคีย์', async () => {
const result = await service.applyProfile('standard', 42);
expect(mockSandboxProfileRepo.findOne).toHaveBeenCalledWith({
where: { profileName: 'standard' },
});
expect(mockProfileRepo.findOne).toHaveBeenCalledWith({
where: { profileName: 'standard' },
});
expect(mockProfileRepo.save).toHaveBeenCalledWith(
expect.objectContaining({
temperature: 0.65,
topP: 0.9,
maxTokens: 4096,
numCtx: 8192,
repeatPenalty: 1.15,
keepAliveSeconds: 600,
updatedBy: 42,
})
);
expect(mockRedis.del).toHaveBeenCalledWith(
'ai_execution_profiles:standard'
);
expect(mockRedis.del).toHaveBeenCalledWith(
'ai_execution_profiles:model:np-dms-ai'
);
expect(result.temperature).toBe(0.65);
expect(result.topP).toBe(0.9);
expect(result.keepAliveSeconds).toBe(600);
});
it('ควรสร้าง production row ใหม่หากยังไม่มีอยู่ใน DB', async () => {
mockProfileRepo.findOne.mockResolvedValue(null);
mockProfileRepo.create.mockImplementation((input: unknown) => ({
...(input as object),
}));
mockProfileRepo.save.mockImplementation((input: unknown) =>
Promise.resolve({ ...(input as object) })
);
const result = await service.applyProfile('standard', 1);
expect(mockProfileRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ profileName: 'standard', isActive: true })
);
expect(result.temperature).toBe(sandboxDraft.temperature);
});
it('ควรยังคง apply ได้แม้ Redis DEL ล้มเหลว (cache failure tolerant)', async () => {
mockRedis.del.mockRejectedValue(new Error('Redis connection lost'));
const result = await service.applyProfile('standard', 7);
expect(result.temperature).toBe(sandboxDraft.temperature);
});
});
describe('NotFoundException เมื่อไม่มี sandbox draft', () => {
it('ควรโยน NotFoundException เมื่อ sandbox draft ไม่มีอยู่ใน DB', async () => {
mockSandboxProfileRepo.findOne.mockResolvedValue(null);
await expect(service.applyProfile('standard')).rejects.toThrow(
NotFoundException
);
});
});
describe('Parameter range validation propagation', () => {
const makeInvalidDraft = (
overrides: Partial<typeof sandboxDraft>
): unknown => ({
...sandboxDraft,
...overrides,
});
it.each([
['temperature เกิน 1', { temperature: 1.01 }],
['temperature ต่ำกว่า 0', { temperature: -0.01 }],
['topP เกิน 1', { topP: 1.1 }],
['topP ต่ำกว่า 0', { topP: -0.1 }],
['repeatPenalty ต่ำกว่า 1', { repeatPenalty: 0.99 }],
['repeatPenalty เกิน 2', { repeatPenalty: 2.01 }],
['keepAliveSeconds ติดลบ', { keepAliveSeconds: -1 }],
])('ควรโยน BadRequestException เมื่อ %s', async (_label, invalidValue) => {
mockSandboxProfileRepo.findOne.mockResolvedValue(
makeInvalidDraft(invalidValue)
);
await expect(service.applyProfile('standard')).rejects.toThrow(
BadRequestException
);
});
});
describe('Cache lifecycle หลัง apply', () => {
it('ควรให้ cache miss หลัง apply เพื่อบังคับ fresh read จาก DB รอบถัดไป', async () => {
await service.applyProfile('standard', 1);
expect(mockRedis.del).toHaveBeenCalledTimes(2);
mockRedis.get.mockResolvedValue(null);
mockProfileRepo.findOne.mockResolvedValue({
profileName: 'standard',
canonicalModel: 'np-dms-ai',
isActive: true,
temperature: 0.65,
topP: 0.9,
maxTokens: 4096,
numCtx: 8192,
repeatPenalty: 1.15,
keepAliveSeconds: 600,
});
const freshParams = await service.getProfileParameters('standard');
expect(freshParams.temperature).toBe(0.65);
expect(mockProfileRepo.findOne).toHaveBeenCalledTimes(2);
});
it('ควรเขียน cache ใหม่หลัง getProfileParameters อ่านจาก DB', async () => {
await service.applyProfile('standard', 1);
mockRedis.get.mockResolvedValue(null);
mockProfileRepo.findOne.mockResolvedValue({
profileName: 'standard',
canonicalModel: 'np-dms-ai',
isActive: true,
temperature: 0.65,
topP: 0.9,
maxTokens: 4096,
numCtx: 8192,
repeatPenalty: 1.15,
keepAliveSeconds: 600,
});
await service.getProfileParameters('standard');
expect(mockRedis.set).toHaveBeenCalledWith(
'ai_execution_profiles:standard',
expect.stringContaining('"temperature":0.65'),
'EX',
60
);
});
});
describe('Dual-model: apply ของ OCR profile', () => {
it('ควรลบ model cache key ของ np-dms-ocr เมื่อ apply ocr-extract profile', async () => {
const ocrDraft = {
profileName: 'ocr-extract',
canonicalModel: 'np-dms-ocr' as const,
temperature: 0.12,
topP: 0.18,
maxTokens: null,
numCtx: null,
repeatPenalty: 1.05,
keepAliveSeconds: 0,
};
mockSandboxProfileRepo.findOne.mockResolvedValue(ocrDraft);
mockProfileRepo.findOne.mockResolvedValue({
profileName: 'ocr-extract',
canonicalModel: 'np-dms-ocr',
isActive: true,
...ocrDraft,
});
mockProfileRepo.save.mockImplementation((input: unknown) =>
Promise.resolve({ ...(input as object) })
);
await service.applyProfile('ocr-extract', 5);
expect(mockRedis.del).toHaveBeenCalledWith(
'ai_execution_profiles:ocr-extract'
);
expect(mockRedis.del).toHaveBeenCalledWith(
'ai_execution_profiles:model:np-dms-ocr'
);
});
});
});