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,98 @@
// File: frontend/lib/services/__tests__/ai.service.test.ts
// Change Log:
// - 2026-06-13: Initial creation - test coverage for aiService
import { describe, it, expect, vi, beforeEach } from 'vitest';
import api from '@/lib/api/client';
import { aiService } from '../ai.service';
describe('aiService', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('extract', () => {
it('ควรส่งคำขอ POST /ai/extract เพื่อสกัดข้อมูลและส่งกลับผลลัพธ์สำเร็จ', async () => {
const mockResult = {
documentNumber: 'DOC-001',
title: 'Document Title',
confidenceScore: 0.95,
};
vi.mocked(api.post).mockResolvedValue({ data: mockResult });
const dto = { filePublicId: 'file-123' };
const result = await aiService.extract(dto);
expect(api.post).toHaveBeenCalledWith('/ai/extract', dto);
expect(result).toEqual(mockResult);
});
it('ควรจัดการการห่อหุ้มข้อมูล (nested data wrapper) ได้อย่างถูกต้อง', async () => {
const mockResult = {
documentNumber: 'DOC-001',
title: 'Document Title',
confidenceScore: 0.95,
};
vi.mocked(api.post).mockResolvedValue({ data: { data: mockResult } });
const dto = { filePublicId: 'file-123' };
const result = await aiService.extract(dto);
expect(result).toEqual(mockResult);
});
});
describe('getMigrationList', () => {
it('ควรดึงประวัติการอพยพข้อมูลพร้อมแบ่งหน้าได้ถูกต้อง', async () => {
const mockResponse = {
items: [
{
publicId: '019505a1-7c3e-7000-8000-log111111111',
status: 'COMPLETED',
},
],
total: 1,
page: 1,
limit: 10,
totalPages: 1,
};
vi.mocked(api.get).mockResolvedValue({ data: mockResponse });
const result = await aiService.getMigrationList({ page: 1, limit: 10 });
expect(api.get).toHaveBeenCalledWith('/ai/migration', { params: { page: 1, limit: 10 } });
expect(result).toEqual(mockResponse);
});
it('ควรคืนค่ารูปแบบแบ่งหน้าเริ่มต้นหากข้อมูลที่ได้รับไม่ถูกต้อง', async () => {
vi.mocked(api.get).mockResolvedValue({ data: null });
const result = await aiService.getMigrationList({});
expect(result).toEqual({ items: [], total: 0, page: 1, limit: 10, totalPages: 0 });
});
});
describe('updateMigration', () => {
it('ควรส่งคำขอ PATCH พร้อมแนบ Idempotency-Key สำเร็จ', async () => {
const mockLog = {
publicId: '019505a1-7c3e-7000-8000-log111111111',
status: 'VERIFIED',
};
vi.mocked(api.patch).mockResolvedValue({ data: mockLog });
const dto = { status: 'VERIFIED' as const };
const result = await aiService.updateMigration(
'019505a1-7c3e-7000-8000-log111111111',
dto,
'idempotency-123'
);
expect(api.patch).toHaveBeenCalledWith(
'/ai/migration/019505a1-7c3e-7000-8000-log111111111',
dto,
{ headers: { 'Idempotency-Key': 'idempotency-123' } }
);
expect(result).toEqual(mockLog);
});
});
describe('submitFeedback', () => {
it('ควรส่งคำขอ POST /ai/feedback พร้อมข้อมูลฟีดแบ็คสำเร็จ', async () => {
vi.mocked(api.post).mockResolvedValue({ data: {} });
const dto = { logPublicId: 'log-1', rating: 5, comments: 'Good extraction' };
await aiService.submitFeedback(dto);
expect(api.post).toHaveBeenCalledWith('/ai/feedback', dto);
});
});
});
@@ -0,0 +1,47 @@
// File: frontend/lib/services/__tests__/audit-log.service.test.ts
// Change Log:
// - 2026-06-13: Initial creation - test coverage for auditLogService
import { describe, it, expect, vi, beforeEach } from 'vitest';
import apiClient from '@/lib/api/client';
import { auditLogService } from '../audit-log.service';
describe('auditLogService', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getLogs', () => {
it('ควรดึงข้อมูล audit logs รูปแบบอาร์เรย์ได้อย่างถูกต้อง', async () => {
const mockLogs = [
{
publicId: '019505a1-7c3e-7000-8000-audit1111111',
auditId: 'AUD-001',
action: 'LOGIN',
severity: 'INFO',
createdAt: '2026-06-13T00:00:00.000Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockLogs });
const result = await auditLogService.getLogs();
expect(apiClient.get).toHaveBeenCalledWith('/audit-logs', { params: undefined });
expect(result).toEqual(mockLogs);
});
it('ควรดึงข้อมูล audit logs รูปแบบ data wrapper ได้อย่างถูกต้อง', async () => {
const mockLogs = [
{
publicId: '019505a1-7c3e-7000-8000-audit1111111',
auditId: 'AUD-001',
action: 'LOGIN',
severity: 'INFO',
createdAt: '2026-06-13T00:00:00.000Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: { data: mockLogs } });
const result = await auditLogService.getLogs({ search: 'login' });
expect(apiClient.get).toHaveBeenCalledWith('/audit-logs', { params: { search: 'login' } });
expect(result).toEqual(mockLogs);
});
});
});
@@ -0,0 +1,97 @@
// File: frontend/lib/services/__tests__/contract.service.test.ts
// Change Log:
// - 2026-06-13: Initial creation - test coverage for contractService
import { describe, it, expect, vi, beforeEach } from 'vitest';
import apiClient from '@/lib/api/client';
import { contractService } from '../contract.service';
describe('contractService', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getAll', () => {
it('ควรดึงข้อมูลสัญญาและประมวลผลรูปแบบอาร์เรย์ได้อย่างถูกต้อง', async () => {
const mockContracts = [
{
publicId: '019505a1-7c3e-7000-8000-contract111',
contractName: 'Contract Alpha',
project: {
publicId: '019505a1-7c3e-7000-8000-project111',
projectName: 'Project Alpha',
},
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockContracts });
const result = await contractService.getAll();
expect(apiClient.get).toHaveBeenCalledWith('/contracts', { params: undefined });
expect(result).toEqual(mockContracts);
});
it('ควรดึงข้อมูลและประมวลผลรูปแบบ nested data ได้อย่างถูกต้อง', async () => {
const mockContracts = [
{
publicId: '019505a1-7c3e-7000-8000-contract111',
contractName: 'Contract Alpha',
project: {
publicId: '019505a1-7c3e-7000-8000-project111',
projectName: 'Project Alpha',
},
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: { data: mockContracts } });
const result = await contractService.getAll({ projectId: 1 });
expect(apiClient.get).toHaveBeenCalledWith('/contracts', { params: { projectId: 1 } });
expect(result).toEqual(mockContracts);
});
it('ควรส่งกลับอาร์เรย์ว่างหากข้อมูลที่ได้รับไม่ใช่รูปแบบอาร์เรย์', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: null });
const result = await contractService.getAll();
expect(result).toEqual([]);
});
});
describe('getByUuid', () => {
it('ควรดึงรายละเอียดสัญญาตาม UUID สำเร็จ', async () => {
const mockContract = {
publicId: '019505a1-7c3e-7000-8000-contract111',
contractName: 'Contract Alpha',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockContract });
const result = await contractService.getByUuid('019505a1-7c3e-7000-8000-contract111');
expect(apiClient.get).toHaveBeenCalledWith('/contracts/019505a1-7c3e-7000-8000-contract111');
expect(result).toEqual(mockContract);
});
});
describe('create', () => {
it('ควรส่งคำขอ POST เพื่อสร้างสัญญาใหม่สำเร็จ', async () => {
const createDto = { contractName: 'New Contract', contractCode: 'C-001', projectId: 1 };
vi.mocked(apiClient.post).mockResolvedValue({ data: { publicId: 'new-uuid', ...createDto } });
const result = await contractService.create(createDto);
expect(apiClient.post).toHaveBeenCalledWith('/contracts', createDto);
expect(result.contractName).toBe('New Contract');
});
});
describe('update', () => {
it('ควรส่งคำขอ PATCH เพื่ออัปเดตสัญญาสำเร็จ', async () => {
const updateDto = { contractName: 'Updated Contract' };
vi.mocked(apiClient.patch).mockResolvedValue({ data: { publicId: 'uuid', ...updateDto } });
const result = await contractService.update('uuid', updateDto);
expect(apiClient.patch).toHaveBeenCalledWith('/contracts/uuid', updateDto);
expect(result.contractName).toBe('Updated Contract');
});
});
describe('delete', () => {
it('ควรส่งคำขอ DELETE เพื่อลบสัญญาสำเร็จ', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({ data: { success: true } });
const result = await contractService.delete('uuid');
expect(apiClient.delete).toHaveBeenCalledWith('/contracts/uuid');
expect(result).toEqual({ success: true });
});
});
});
@@ -0,0 +1,81 @@
// File: frontend/lib/services/__tests__/review-team.service.test.ts
// Change Log:
// - 2026-06-13: Initial creation - test coverage for reviewTeamService
import { describe, it, expect, vi, beforeEach } from 'vitest';
import apiClient from '@/lib/api/client';
import { reviewTeamService } from '../review-team.service';
describe('reviewTeamService', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getAll', () => {
it('ควรดึงข้อมูลทีมทบทวนทั้งหมดสำเร็จ', async () => {
const mockTeams = [{ publicId: '019505a1-7c3e-7000-8000-team11111111', name: 'Review Team Alpha' }];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockTeams });
const result = await reviewTeamService.getAll({ projectPublicId: 'proj-1' });
expect(apiClient.get).toHaveBeenCalledWith('/review-teams', { params: { projectPublicId: 'proj-1' } });
expect(result).toEqual(mockTeams);
});
});
describe('getByPublicId', () => {
it('ควรดึงข้อมูลทีมตาม PublicId สำเร็จ', async () => {
const mockTeam = { publicId: '019505a1-7c3e-7000-8000-team11111111', name: 'Review Team Alpha' };
vi.mocked(apiClient.get).mockResolvedValue({ data: mockTeam });
const result = await reviewTeamService.getByPublicId('019505a1-7c3e-7000-8000-team11111111');
expect(apiClient.get).toHaveBeenCalledWith('/review-teams/019505a1-7c3e-7000-8000-team11111111');
expect(result).toEqual(mockTeam);
});
});
describe('create', () => {
it('ควรส่งคำขอ POST เพื่อสร้างทีมทบทวนใหม่สำเร็จ', async () => {
const createDto = { name: 'New Team', projectPublicId: 'proj-1', defaultForRfaTypes: ['RFA'] };
vi.mocked(apiClient.post).mockResolvedValue({ data: { publicId: 'new-team-uuid', ...createDto } });
const result = await reviewTeamService.create(createDto);
expect(apiClient.post).toHaveBeenCalledWith('/review-teams', createDto);
expect(result.name).toBe('New Team');
});
});
describe('update', () => {
it('ควรส่งคำขอ PATCH เพื่ออัปเดตทีมทบทวนสำเร็จ', async () => {
const updateDto = { name: 'Updated Team' };
vi.mocked(apiClient.patch).mockResolvedValue({ data: { publicId: 'team-uuid', ...updateDto } });
const result = await reviewTeamService.update('team-uuid', updateDto);
expect(apiClient.patch).toHaveBeenCalledWith('/review-teams/team-uuid', updateDto);
expect(result.name).toBe('Updated Team');
});
});
describe('addMember', () => {
it('ควรส่งคำขอ POST เพื่อเพิ่มสมาชิกเข้าทีมทบทวนสำเร็จ', async () => {
const memberDto = { userPublicId: 'user-1', disciplineId: 1, role: 'REVIEWER' as const };
vi.mocked(apiClient.post).mockResolvedValue({ data: { success: true } });
const result = await reviewTeamService.addMember('team-uuid', memberDto);
expect(apiClient.post).toHaveBeenCalledWith('/review-teams/team-uuid/members', memberDto);
expect(result).toEqual({ success: true });
});
});
describe('removeMember', () => {
it('ควรส่งคำขอ DELETE เพื่อลบสมาชิกออกจากทีมทบทวนสำเร็จ', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({ data: { success: true } });
const result = await reviewTeamService.removeMember('team-uuid', 'member-uuid');
expect(apiClient.delete).toHaveBeenCalledWith('/review-teams/team-uuid/members/member-uuid');
expect(result).toEqual({ success: true });
});
});
describe('deactivate', () => {
it('ควรส่งคำขอ DELETE เพื่อหยุดการทำงานของทีมทบทวนสำเร็จ', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({ data: { success: true } });
const result = await reviewTeamService.deactivate('team-uuid');
expect(apiClient.delete).toHaveBeenCalledWith('/review-teams/team-uuid');
expect(result).toEqual({ success: true });
});
});
});
@@ -0,0 +1,50 @@
// File: frontend/lib/services/__tests__/search.service.test.ts
// Change Log:
// - 2026-06-13: Initial creation - test coverage for searchService
import { describe, it, expect, vi, beforeEach } from 'vitest';
import apiClient from '@/lib/api/client';
import { searchService } from '../search.service';
describe('searchService', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('search', () => {
it('ควรส่งคำขอ GET /search พร้อมข้อมูลการค้นหาสำเร็จ', async () => {
const mockResult = { items: [{ publicId: '019505a1-7c3e-7000-8000-doc111111111', title: 'Test doc' }] };
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResult });
const query = { q: 'test', limit: 10, offset: 0 };
const result = await searchService.search(query);
expect(apiClient.get).toHaveBeenCalledWith('/search', { params: query });
expect(result).toEqual(mockResult);
});
});
describe('suggest', () => {
it('ควรดึงข้อมูล suggest และแกะค่า items ออกมาสำเร็จ', async () => {
const mockResult = { items: ['test1', 'test2'] };
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResult });
const result = await searchService.suggest('test');
expect(apiClient.get).toHaveBeenCalledWith('/search', { params: { q: 'test', limit: 5 } });
expect(result).toEqual(['test1', 'test2']);
});
it('ควรคืนค่า raw response ใน suggest หากไม่มีฟิลด์ items', async () => {
const mockResult = ['test1', 'test2'];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResult });
const result = await searchService.suggest('test');
expect(result).toEqual(['test1', 'test2']);
});
});
describe('reindex', () => {
it('ควรส่งคำขอ POST เพื่อสั่ง reindex สำเร็จ', async () => {
vi.mocked(apiClient.post).mockResolvedValue({ data: { success: true } });
const result = await searchService.reindex('correspondence');
expect(apiClient.post).toHaveBeenCalledWith('/search/reindex', { type: 'correspondence' });
expect(result).toEqual({ success: true });
});
});
});
+72 -2
View File
@@ -13,6 +13,10 @@
// - 2026-06-03: ADR-034 — เพิ่ม activeModels field (หลัก+OCR) ใน AiSystemHealth interface
// - 2026-06-02: แก้ endpoint getAvailableModels ให้ตรงกับ backend admin route (/ai/admin/models)
// - 2026-06-02: normalize VRAM response ให้รองรับ field names จาก backend ปัจจุบันและรูปแบบ loadedModels แบบเดิม
// - 2026-06-13: T027-T029 — เพิ่ม getSandboxProfile, saveSandboxProfile, resetSandboxProfile สำหรับ sandbox parameter management
// - 2026-06-13: T042-T043 — เพิ่ม applyProfile และ getProductionDefaults สำหรับปรับใช้และดึงค่า production parameters
// - 2026-06-13: US4 — อัปเดต submitSandboxExtract และ submitSandboxAiExtract ให้รองรับ project/contract publicId
import api from '../api/client';
import { AiJobResponse } from '../../types/ai';
@@ -138,6 +142,17 @@ export interface AiActiveModelResponse {
activeModel: string;
}
/** พารามิเตอร์ sandbox draft สำหรับ profile (ADR-036) */
export interface SandboxProfileParams {
canonicalModel: 'np-dms-ai' | 'np-dms-ocr';
temperature: number;
topP: number;
maxTokens: number | null;
numCtx: number | null;
repeatPenalty: number;
keepAliveSeconds: number;
}
const extractData = <T>(value: unknown): T => {
if (value && typeof value === 'object' && 'data' in value) {
return (value as { data: T }).data;
@@ -215,10 +230,16 @@ export const adminAiService = {
return extractData<AiSandboxJobResult>(data);
},
submitSandboxExtract: async (
file: File
file: File,
projectPublicId: string,
contractPublicId?: string
): Promise<{ requestPublicId: string; jobId: string; status: string }> => {
const formData = new FormData();
formData.append('file', file);
formData.append('projectPublicId', projectPublicId);
if (contractPublicId) {
formData.append('contractPublicId', contractPublicId);
}
const { data } = await api.post('/ai/admin/sandbox/extract', formData, {
headers: {
'Content-Type': 'multipart/form-data',
@@ -258,11 +279,15 @@ export const adminAiService = {
submitSandboxAiExtract: async (
requestPublicId: string,
promptVersion?: number
promptVersion: number | undefined,
projectPublicId: string,
contractPublicId?: string
): Promise<{ requestPublicId: string; jobId: string; status: string }> => {
const { data } = await api.post('/ai/admin/sandbox/ai-extract', {
requestPublicId,
promptVersion,
projectPublicId,
contractPublicId,
});
return extractData<{ requestPublicId: string; jobId: string; status: string }>(data);
},
@@ -317,6 +342,51 @@ export const adminAiService = {
return extractData<{ activeEngineName: string }>(data);
},
// --- Sandbox Parameter Management (ADR-036, T027-T029) ---
getSandboxProfile: async (profileName: string): Promise<SandboxProfileParams> => {
const { data } = await api.get(`/ai/sandbox-profiles/${encodeURIComponent(profileName)}`);
return extractData<SandboxProfileParams>(data);
},
saveSandboxProfile: async (
profileName: string,
updates: Partial<SandboxProfileParams>,
idempotencyKey: string
): Promise<SandboxProfileParams> => {
const { data } = await api.put(
`/ai/sandbox-profiles/${encodeURIComponent(profileName)}`,
updates,
{ headers: { 'Idempotency-Key': idempotencyKey } }
);
return extractData<SandboxProfileParams>(data);
},
resetSandboxProfile: async (profileName: string): Promise<SandboxProfileParams> => {
const { data } = await api.post(
`/ai/sandbox-profiles/${encodeURIComponent(profileName)}/reset`,
{}
);
return extractData<SandboxProfileParams>(data);
},
applyProfile: async (
profileName: string,
idempotencyKey: string
): Promise<SandboxProfileParams> => {
const { data } = await api.post(
`/ai/profiles/${encodeURIComponent(profileName)}/apply`,
{},
{ headers: { 'Idempotency-Key': idempotencyKey } }
);
return extractData<SandboxProfileParams>(data);
},
getProductionDefaults: async (profileName: string): Promise<SandboxProfileParams> => {
const { data } = await api.get(`/ai/profiles/${encodeURIComponent(profileName)}`);
return extractData<SandboxProfileParams>(data);
},
submitAiJob: async (
type: string,
documentPublicId?: string,