690528:1524 ADR-030-230 context aware #02
This commit is contained in:
@@ -58,6 +58,7 @@ describe('AiService', () => {
|
||||
|
||||
const mockQueue = {
|
||||
add: jest.fn(),
|
||||
getJob: jest.fn(),
|
||||
isPaused: jest.fn().mockResolvedValue(false),
|
||||
getActiveCount: jest.fn().mockResolvedValue(1),
|
||||
getWaitingCount: jest.fn().mockResolvedValue(2),
|
||||
@@ -88,6 +89,15 @@ describe('AiService', () => {
|
||||
set: jest.fn(),
|
||||
};
|
||||
|
||||
const mockImportTransactionRepo = {
|
||||
findOne: jest.fn(),
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
manager: {
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
// Mock ConfigService — คืนค่า Config ตาม Key
|
||||
const mockConfigService = {
|
||||
get: jest.fn((key: string) => {
|
||||
@@ -144,7 +154,7 @@ describe('AiService', () => {
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(ImportTransaction),
|
||||
useValue: { findOne: jest.fn(), create: jest.fn(), save: jest.fn() },
|
||||
useValue: mockImportTransactionRepo,
|
||||
},
|
||||
{ provide: getQueueToken(QUEUE_AI_REALTIME), useValue: mockQueue },
|
||||
{ provide: getQueueToken(QUEUE_AI_BATCH), useValue: mockQueue },
|
||||
@@ -163,6 +173,46 @@ describe('AiService', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('submitMigrationJob', () => {
|
||||
it('ควรส่ง projectPublicId และ contextOverride จาก n8n เข้า BullMQ โดยไม่ใช้ default project', async () => {
|
||||
mockImportTransactionRepo.findOne.mockResolvedValue(null);
|
||||
mockQueue.getJob.mockResolvedValue(null);
|
||||
mockQueue.add.mockResolvedValue({ id: 'job-001' });
|
||||
const result = await service.submitMigrationJob(
|
||||
{
|
||||
type: 'migrate-document',
|
||||
payload: {
|
||||
tempAttachmentId: '019505a1-7c3e-7000-8000-abc123def456',
|
||||
documentNumber: 'LEGACY-001',
|
||||
title: 'Legacy Title',
|
||||
batchId: 'C22024-MIGRATION',
|
||||
contextOverride: {
|
||||
projectPublicId: '019505a1-7c3e-7000-8000-abc123def777',
|
||||
contractPublicId: '019505a1-7c3e-7000-8000-abc123def888',
|
||||
},
|
||||
},
|
||||
},
|
||||
'C22024-MIGRATION:LEGACY-001'
|
||||
);
|
||||
expect(result).toEqual({ success: true, jobId: 'job-001' });
|
||||
expect(mockImportTransactionRepo.manager.findOne).not.toHaveBeenCalled();
|
||||
expect(mockQueue.add).toHaveBeenCalledWith(
|
||||
'migrate-document',
|
||||
expect.objectContaining({
|
||||
projectPublicId: '019505a1-7c3e-7000-8000-abc123def777',
|
||||
batchId: 'C22024-MIGRATION',
|
||||
payload: expect.objectContaining({
|
||||
contextOverride: {
|
||||
projectPublicId: '019505a1-7c3e-7000-8000-abc123def777',
|
||||
contractPublicId: '019505a1-7c3e-7000-8000-abc123def888',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
{ jobId: 'C22024-MIGRATION:LEGACY-001' }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --- handleWebhookCallback ---
|
||||
|
||||
describe('handleWebhookCallback', () => {
|
||||
|
||||
@@ -290,12 +290,15 @@ export class AiService {
|
||||
if (activeJob) {
|
||||
return { success: true, jobId: String(activeJob.id) };
|
||||
}
|
||||
const defaultProject = await this.importTransactionRepo.manager.findOne(
|
||||
Project,
|
||||
{ where: {} }
|
||||
);
|
||||
const projectPublicId =
|
||||
defaultProject?.publicId ?? '00000000-0000-0000-0000-000000000000';
|
||||
let projectPublicId = dto.payload.contextOverride?.projectPublicId;
|
||||
if (!projectPublicId) {
|
||||
const defaultProject = await this.importTransactionRepo.manager.findOne(
|
||||
Project,
|
||||
{ where: {} }
|
||||
);
|
||||
projectPublicId =
|
||||
defaultProject?.publicId ?? '00000000-0000-0000-0000-000000000000';
|
||||
}
|
||||
try {
|
||||
const job = await this.aiBatchQueue.add(
|
||||
'migrate-document',
|
||||
@@ -309,7 +312,9 @@ export class AiService {
|
||||
batchId: dto.payload.batchId,
|
||||
existingTags: dto.payload.existingTags,
|
||||
systemCategories: dto.payload.systemCategories,
|
||||
contextOverride: dto.payload.contextOverride,
|
||||
},
|
||||
batchId: dto.payload.batchId,
|
||||
idempotencyKey,
|
||||
},
|
||||
{ jobId: idempotencyKey }
|
||||
|
||||
@@ -35,6 +35,21 @@ export class TagOptionDto {
|
||||
colorCode?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ตัวกรองบริบท Master Data สำหรับ Migration AI โดยใช้ public UUID เท่านั้น
|
||||
*/
|
||||
export class MigrationContextOverrideDto {
|
||||
@ApiPropertyOptional({ description: 'UUID สาธารณะของโครงการ' })
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
projectPublicId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'UUID สาธารณะของสัญญา' })
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
contractPublicId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload ข้อมูลเอกสารเก่าสำหรับการทำ Migration
|
||||
*/
|
||||
@@ -73,6 +88,16 @@ export class MigrateDocumentPayloadDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
batchId!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: MigrationContextOverrideDto,
|
||||
description: 'ตัวกรอง Master Data Context ตาม ADR-030',
|
||||
})
|
||||
@IsObject()
|
||||
@ValidateNested()
|
||||
@Type(() => MigrationContextOverrideDto)
|
||||
@IsOptional()
|
||||
contextOverride?: MigrationContextOverrideDto;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
// - 2026-05-21: เพิ่มการทดสอบ sandbox-extract พร้อม mock OcrService, OllamaService และ Redis (T039).
|
||||
// - 2026-05-21: แก้ไข ESLint unexpected any และ unsafe member access โดยกำหนด type ให้ redis เป็น Record<string, jest.Mock>
|
||||
// - 2026-05-22: เพิ่ม Mock dependencies (ProjectRepository, AiAuditLogRepository, TagsService, MigrationService) เพื่อแก้ปัญหา Nest resolve dependency ใน unit test และปรับโครงสร้างฟังก์ชันไม่มีบรรทัดว่าง (Zero Blank Lines) ตามกฎเหล็ก
|
||||
// - 2026-05-27: เพิ่ม Mock สำหรับ getActive และ resolveContext ของ AiPromptsService เพื่อรองรับ Context-Aware Prompt (T017)
|
||||
// - 2026-05-28: เพิ่ม test สำหรับ EC-001 (NEW_TAG_SUGGESTED) และ EC-002 (UNRESOLVED_SENDER/RECIPIENT_UUID)
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
@@ -86,12 +88,34 @@ describe('AiBatchProcessor', () => {
|
||||
.mockResolvedValue([
|
||||
{ id: 5, publicId: 'tag-uuid-999', tagName: 'foundation' },
|
||||
]),
|
||||
findOrSuggestTags: jest.fn().mockResolvedValue([
|
||||
{
|
||||
tag: { id: 5, publicId: 'tag-uuid-999', tagName: 'foundation' },
|
||||
isNew: false,
|
||||
},
|
||||
]),
|
||||
};
|
||||
const mockMigrationService = {
|
||||
createError: jest.fn().mockResolvedValue(undefined),
|
||||
enqueueRecord: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const mockAiPromptsService = {
|
||||
getActive: jest.fn().mockResolvedValue({
|
||||
id: 1,
|
||||
promptType: 'ocr_extraction',
|
||||
versionNumber: 2,
|
||||
template:
|
||||
'Resolved test prompt with OCR text {{ocr_text}} and context {{master_data_context}}',
|
||||
isActive: true,
|
||||
contextConfig: { filter: {} },
|
||||
}),
|
||||
resolveContext: jest.fn().mockResolvedValue({
|
||||
availableProjects: [],
|
||||
availableOrganizations: [],
|
||||
availableDisciplines: [],
|
||||
availableCorrespondenceTypes: [],
|
||||
availableTags: [],
|
||||
}),
|
||||
resolveActive: jest.fn().mockResolvedValue({
|
||||
resolvedPrompt: 'Resolved test prompt with OCR text',
|
||||
versionNumber: 2,
|
||||
@@ -203,6 +227,114 @@ describe('AiBatchProcessor', () => {
|
||||
expect.stringContaining('completed')
|
||||
);
|
||||
});
|
||||
it('EC-001: ควรบันทึก aiIssues เมื่อ AI สกัด Tag ใหม่ที่ไม่มีในระบบ', async () => {
|
||||
mockTagsService.findOrSuggestTags.mockResolvedValueOnce([
|
||||
{
|
||||
tag: { id: 5, publicId: 'tag-uuid-999', tagName: 'foundation' },
|
||||
isNew: false,
|
||||
},
|
||||
{
|
||||
tag: { id: 99, publicId: 'tag-uuid-new', tagName: 'newlytag' },
|
||||
isNew: true,
|
||||
},
|
||||
]);
|
||||
const mockManager = {
|
||||
createQueryBuilder: jest.fn().mockReturnThis(),
|
||||
select: jest.fn().mockReturnThis(),
|
||||
from: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
getRawOne: jest.fn().mockResolvedValue({ id: 10 }),
|
||||
};
|
||||
jest
|
||||
.spyOn(
|
||||
mockAttachmentRepo as unknown as { manager: unknown },
|
||||
'manager',
|
||||
'get'
|
||||
)
|
||||
.mockReturnValue(mockManager);
|
||||
mockProjectRepo.findOne.mockResolvedValue({
|
||||
id: 2,
|
||||
publicId: 'proj-uuid-456',
|
||||
});
|
||||
const job = {
|
||||
id: 'job-ec001',
|
||||
data: {
|
||||
jobType: 'migrate-document',
|
||||
documentPublicId: 'doc-uuid-123',
|
||||
projectPublicId: 'proj-uuid-456',
|
||||
payload: { documentNumber: 'LEGACY-EC001', title: 'EC001 Title' },
|
||||
idempotencyKey: 'idem-ec001',
|
||||
batchId: 'batch-ec001',
|
||||
},
|
||||
} as unknown as Job<AiBatchJobData>;
|
||||
await processor.process(job);
|
||||
expect(mockMigrationService.enqueueRecord).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
aiIssues: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: 'NEW_TAG_SUGGESTED',
|
||||
tagName: 'newlytag',
|
||||
}),
|
||||
]),
|
||||
})
|
||||
);
|
||||
});
|
||||
it('EC-002: ควรตั้ง isValid=false และบันทึก aiIssues เมื่อ UUID ผู้ส่งไม่พบใน Master Data', async () => {
|
||||
mockTagsService.findOrSuggestTags.mockResolvedValueOnce([]);
|
||||
mockOllamaService.generate.mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
documentNumber: 'LEGACY-EC002',
|
||||
subject: 'EC002 Subject',
|
||||
discipline: 'Civil',
|
||||
category: 'Correspondence',
|
||||
originatorOrganizationPublicId: 'unknown-org-uuid',
|
||||
confidence: 0.95,
|
||||
tags: [],
|
||||
summary: 'summary',
|
||||
})
|
||||
);
|
||||
const mockManager = {
|
||||
createQueryBuilder: jest.fn().mockReturnThis(),
|
||||
select: jest.fn().mockReturnThis(),
|
||||
from: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
getRawOne: jest.fn().mockResolvedValue(null),
|
||||
};
|
||||
jest
|
||||
.spyOn(
|
||||
mockAttachmentRepo as unknown as { manager: unknown },
|
||||
'manager',
|
||||
'get'
|
||||
)
|
||||
.mockReturnValue(mockManager);
|
||||
mockProjectRepo.findOne.mockResolvedValue({
|
||||
id: 2,
|
||||
publicId: 'proj-uuid-456',
|
||||
});
|
||||
const job = {
|
||||
id: 'job-ec002',
|
||||
data: {
|
||||
jobType: 'migrate-document',
|
||||
documentPublicId: 'doc-uuid-123',
|
||||
projectPublicId: 'proj-uuid-456',
|
||||
payload: { documentNumber: 'LEGACY-EC002', title: 'EC002 Title' },
|
||||
idempotencyKey: 'idem-ec002',
|
||||
batchId: 'batch-ec002',
|
||||
},
|
||||
} as unknown as Job<AiBatchJobData>;
|
||||
await processor.process(job);
|
||||
expect(mockMigrationService.enqueueRecord).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
isValid: false,
|
||||
aiIssues: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: 'UNRESOLVED_SENDER_UUID',
|
||||
uuid: 'unknown-org-uuid',
|
||||
}),
|
||||
]),
|
||||
})
|
||||
);
|
||||
});
|
||||
it('ควรประมวลผล migrate-document โดยจำลอง OCR, AI และเรียก migrationService.enqueueRecord', async () => {
|
||||
const job = {
|
||||
id: 'job-migrate',
|
||||
@@ -215,6 +347,9 @@ describe('AiBatchProcessor', () => {
|
||||
title: 'Legacy Title',
|
||||
senderOrgId: 1,
|
||||
receiverOrgId: 2,
|
||||
contextOverride: {
|
||||
contractPublicId: 'contract-uuid-789',
|
||||
},
|
||||
},
|
||||
idempotencyKey: 'idem-migrate-123',
|
||||
batchId: 'batch-999',
|
||||
@@ -228,16 +363,21 @@ describe('AiBatchProcessor', () => {
|
||||
pdfPath: '/files/test.pdf',
|
||||
});
|
||||
expect(ollamaService.generate).toHaveBeenCalledTimes(1);
|
||||
expect(mockTagsService.findOrCreateTags).toHaveBeenCalledTimes(1);
|
||||
expect(mockTagsService.findOrSuggestTags).toHaveBeenCalledTimes(1);
|
||||
expect(mockMigrationService.enqueueRecord).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
documentNumber: 'LCBP3-CIV-001',
|
||||
documentNumber: 'LEGACY-001',
|
||||
subject: 'Foundation Inspection Report',
|
||||
category: 'Correspondence',
|
||||
isValid: true,
|
||||
confidence: 0.95,
|
||||
})
|
||||
);
|
||||
expect(mockAiPromptsService.resolveContext).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'proj-uuid-456',
|
||||
'contract-uuid-789'
|
||||
);
|
||||
expect(mockAiAuditLogRepo.create).toHaveBeenCalledTimes(1);
|
||||
expect(mockAiAuditLogRepo.save).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
// - 2026-05-22: แก้ไข type compilation error ใน processMigrateDocument และนำช่องว่างภายในฟังก์ชันออก
|
||||
// - 2026-05-25: เพิ่ม AiPromptsService เพื่อดึง Dynamic Prompt สำหรับ OCR extraction ใน sandbox และ migration pipeline
|
||||
// - 2026-05-26: แก้ไข bug lockDuration=30000ms ทำให้ sandbox-extract job stall เมื่อ Ollama ใช้เวลา >30s — เพิ่ม lockDuration: 150000
|
||||
// - 2026-05-28: EC-001 ใช้ findOrSuggestTags เพื่อตรวจจับ Tag ใหม่และบันทึก aiIssues; EC-002 ตรวจสอบ UUID ของผู้ส่ง/ผู้รับ และ Flag เมื่อหาไม่พบ
|
||||
|
||||
import { Processor, WorkerHost } from '@nestjs/bullmq';
|
||||
import { Logger } from '@nestjs/common';
|
||||
@@ -30,14 +31,16 @@ import { MigrationErrorType } from '../../migration/entities/migration-error.ent
|
||||
import { AiPromptsService } from '../prompts/ai-prompts.service';
|
||||
|
||||
interface MigrateDocumentMetadata extends Record<string, unknown> {
|
||||
documentNumber?: string;
|
||||
projectPublicId?: string;
|
||||
correspondenceTypeCode?: string;
|
||||
disciplineCode?: string;
|
||||
originatorOrganizationPublicId?: string;
|
||||
recipients?: Array<{ organizationPublicId: string; recipientType: string }>;
|
||||
subject?: string;
|
||||
category?: string;
|
||||
discipline?: string;
|
||||
date?: string;
|
||||
confidence?: number;
|
||||
documentDate?: string;
|
||||
tags?: string[];
|
||||
summary?: string;
|
||||
confidence?: number;
|
||||
}
|
||||
|
||||
export type AiBatchJobType =
|
||||
@@ -72,6 +75,32 @@ const toStringList = (value: unknown): string[] =>
|
||||
? value.filter((item): item is string => typeof item === 'string')
|
||||
: [];
|
||||
|
||||
const toRecipientsList = (
|
||||
value: unknown
|
||||
): Array<{ organizationPublicId: string; recipientType: string }> => {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
const result: Array<{ organizationPublicId: string; recipientType: string }> =
|
||||
[];
|
||||
for (const item of value) {
|
||||
if (item && typeof item === 'object') {
|
||||
const obj = item as Record<string, unknown>;
|
||||
const orgId = readString(obj.organizationPublicId);
|
||||
const type = readString(obj.recipientType);
|
||||
if (orgId && type) {
|
||||
// Normalize 'CC ' whitespace typo to 'CC'
|
||||
const normalizedType = type.trim() === 'CC' ? 'CC' : type.trim();
|
||||
result.push({
|
||||
organizationPublicId: orgId,
|
||||
recipientType: normalizedType,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const parseMigrateDocumentMetadata = (
|
||||
cleanedResponse: string
|
||||
): MigrateDocumentMetadata => {
|
||||
@@ -81,11 +110,15 @@ const parseMigrateDocumentMetadata = (
|
||||
}
|
||||
const source = parsed as Record<string, unknown>;
|
||||
return {
|
||||
documentNumber: readString(source.documentNumber),
|
||||
projectPublicId: readString(source.projectPublicId),
|
||||
correspondenceTypeCode: readString(source.correspondenceTypeCode),
|
||||
disciplineCode: readString(source.disciplineCode),
|
||||
originatorOrganizationPublicId: readString(
|
||||
source.originatorOrganizationPublicId
|
||||
),
|
||||
recipients: toRecipientsList(source.recipients),
|
||||
subject: readString(source.subject),
|
||||
category: readString(source.category),
|
||||
discipline: readString(source.discipline),
|
||||
date: readString(source.date),
|
||||
documentDate: readString(source.documentDate),
|
||||
confidence:
|
||||
typeof source.confidence === 'number' ? source.confidence : undefined,
|
||||
tags: toStringList(source.tags),
|
||||
@@ -246,8 +279,10 @@ export class AiBatchProcessor extends WorkerHost {
|
||||
|
||||
/** ประมวลผล sandbox OCR + Metadata extraction โดยไม่บันทึกลง database */
|
||||
private async processSandboxExtract(data: AiBatchJobData): Promise<void> {
|
||||
const { idempotencyKey, payload } = data;
|
||||
const { idempotencyKey, payload, projectPublicId } = data;
|
||||
const pdfPath = payload.pdfPath as string;
|
||||
const overrideProjPublicId =
|
||||
(payload.projectPublicId as string) || projectPublicId;
|
||||
if (!pdfPath) {
|
||||
throw new Error('pdfPath is required for sandbox-extract job');
|
||||
}
|
||||
@@ -261,11 +296,26 @@ export class AiBatchProcessor extends WorkerHost {
|
||||
);
|
||||
try {
|
||||
const ocrResult = await this.ocrService.detectAndExtract({ pdfPath });
|
||||
const { resolvedPrompt, versionNumber } =
|
||||
await this.aiPromptsService.resolveActive(
|
||||
'ocr_extraction',
|
||||
ocrResult.text
|
||||
|
||||
const activePrompt =
|
||||
await this.aiPromptsService.getActive('ocr_extraction');
|
||||
if (!activePrompt) {
|
||||
throw new Error('No active ocr_extraction prompt version found');
|
||||
}
|
||||
|
||||
// ดึงบริบท Master data
|
||||
const masterDataContext = await this.aiPromptsService.resolveContext(
|
||||
activePrompt,
|
||||
overrideProjPublicId
|
||||
);
|
||||
|
||||
const resolvedPrompt = activePrompt.template
|
||||
.replace('{{ocr_text}}', ocrResult.text)
|
||||
.replace(
|
||||
'{{master_data_context}}',
|
||||
JSON.stringify(masterDataContext, null, 2)
|
||||
);
|
||||
|
||||
const response = await this.ollamaService.generate(resolvedPrompt, {
|
||||
timeoutMs: 120000,
|
||||
});
|
||||
@@ -286,7 +336,7 @@ export class AiBatchProcessor extends WorkerHost {
|
||||
}
|
||||
await this.aiPromptsService.saveTestResult(
|
||||
'ocr_extraction',
|
||||
versionNumber,
|
||||
activePrompt.versionNumber,
|
||||
extractedMetadata
|
||||
);
|
||||
await this.redis.setex(
|
||||
@@ -296,7 +346,7 @@ export class AiBatchProcessor extends WorkerHost {
|
||||
requestPublicId: idempotencyKey,
|
||||
status: 'completed',
|
||||
answer: JSON.stringify(extractedMetadata, null, 2),
|
||||
promptVersionUsed: versionNumber,
|
||||
promptVersionUsed: activePrompt.versionNumber,
|
||||
completedAt: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
@@ -323,6 +373,13 @@ export class AiBatchProcessor extends WorkerHost {
|
||||
const startTime = Date.now();
|
||||
const { documentPublicId, projectPublicId, payload, batchId } = job.data;
|
||||
const docNumber = payload.documentNumber as string;
|
||||
const contextOverride =
|
||||
payload.contextOverride &&
|
||||
typeof payload.contextOverride === 'object' &&
|
||||
!Array.isArray(payload.contextOverride)
|
||||
? (payload.contextOverride as Record<string, unknown>)
|
||||
: {};
|
||||
const contractPublicId = readString(contextOverride.contractPublicId);
|
||||
const attachment = await this.attachmentRepo.findOne({
|
||||
where: { publicId: documentPublicId },
|
||||
});
|
||||
@@ -358,10 +415,27 @@ export class AiBatchProcessor extends WorkerHost {
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
const { resolvedPrompt } = await this.aiPromptsService.resolveActive(
|
||||
'ocr_extraction',
|
||||
ocrResult.text
|
||||
|
||||
const activePrompt =
|
||||
await this.aiPromptsService.getActive('ocr_extraction');
|
||||
if (!activePrompt) {
|
||||
throw new Error('No active prompt found for ocr_extraction');
|
||||
}
|
||||
|
||||
// ดึงบริบทอ้างอิงโครงการที่กรองแล้ว (Data Isolation)
|
||||
const masterDataContext = await this.aiPromptsService.resolveContext(
|
||||
activePrompt,
|
||||
projectPublicId,
|
||||
contractPublicId
|
||||
);
|
||||
|
||||
const resolvedPrompt = activePrompt.template
|
||||
.replace('{{ocr_text}}', ocrResult.text)
|
||||
.replace(
|
||||
'{{master_data_context}}',
|
||||
JSON.stringify(masterDataContext, null, 2)
|
||||
);
|
||||
|
||||
let aiResponse: string;
|
||||
try {
|
||||
aiResponse = await this.ollamaService.generate(resolvedPrompt, {
|
||||
@@ -411,50 +485,162 @@ export class AiBatchProcessor extends WorkerHost {
|
||||
});
|
||||
throw new Error(errMsg);
|
||||
}
|
||||
|
||||
// 3. ตรวจสอบและค้นหา Tags Suggestion ร่วมกับ Auto-Diff (EC-001)
|
||||
const aiIssues: Record<string, unknown>[] = [];
|
||||
let mappedTags: Record<string, string>[] = [];
|
||||
if (extractedMetadata.tags && extractedMetadata.tags.length > 0) {
|
||||
const tags = await this.tagsService.findOrCreateTags(
|
||||
const tagResults = await this.tagsService.findOrSuggestTags(
|
||||
project.id,
|
||||
extractedMetadata.tags,
|
||||
attachment.uploadedByUserId
|
||||
);
|
||||
mappedTags = tags.map((t) => ({
|
||||
publicId: t.publicId,
|
||||
tagName: t.tagName,
|
||||
mappedTags = tagResults.map(({ tag }) => ({
|
||||
publicId: tag.publicId,
|
||||
tagName: tag.tagName,
|
||||
}));
|
||||
// บันทึก Tag ใหม่ที่ไม่มีในระบบเป็น aiIssues เพื่อให้มนุษย์ตรวจสอบ
|
||||
for (const { tag, isNew } of tagResults) {
|
||||
if (isNew) {
|
||||
aiIssues.push({
|
||||
type: 'NEW_TAG_SUGGESTED',
|
||||
tagPublicId: tag.publicId,
|
||||
tagName: tag.tagName,
|
||||
message: `Tag '${tag.tagName}' ถูกสร้างใหม่โดย AI — ต้องการการตรวจสอบจากมนุษย์`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const confidence =
|
||||
typeof extractedMetadata.confidence === 'number'
|
||||
? extractedMetadata.confidence
|
||||
: 0.5;
|
||||
const isValid = confidence >= 0.6 && !!extractedMetadata.documentNumber;
|
||||
|
||||
// 4. Resolve UUIDs of Sender/Recipient Organizations to Database IDs (ADR-019)
|
||||
// EC-002: UUID ที่หาไม่พบใน Master Data จะถูก flag ใน aiIssues และ isValid = false
|
||||
let senderOrgId: number | undefined = undefined;
|
||||
if (extractedMetadata.originatorOrganizationPublicId) {
|
||||
const foundOrg = await this.attachmentRepo.manager
|
||||
.createQueryBuilder()
|
||||
.select('org.id', 'id')
|
||||
.from('organizations', 'org')
|
||||
.where('org.uuid = :uuid', {
|
||||
uuid: extractedMetadata.originatorOrganizationPublicId,
|
||||
})
|
||||
.getRawOne<{ id: number }>();
|
||||
if (foundOrg) {
|
||||
senderOrgId = Number(foundOrg.id);
|
||||
} else {
|
||||
// EC-002: UUID ของผู้ส่งไม่มีใน Master Data — flag เพื่อ human review
|
||||
aiIssues.push({
|
||||
type: 'UNRESOLVED_SENDER_UUID',
|
||||
uuid: extractedMetadata.originatorOrganizationPublicId,
|
||||
message: `UUID ผู้ส่ง '${extractedMetadata.originatorOrganizationPublicId}' ไม่พบใน Master Data — ต้องการการตรวจสอบจากมนุษย์`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let primaryReceiverOrgId: number | undefined = undefined;
|
||||
if (
|
||||
extractedMetadata.recipients &&
|
||||
extractedMetadata.recipients.length > 0
|
||||
) {
|
||||
// ดึงผู้รับที่เป็นประเภท TO รายแรกเป็นผู้รับหลัก (Primary Receiver)
|
||||
const primaryReceiverObj =
|
||||
extractedMetadata.recipients.find((r) => r.recipientType === 'TO') ||
|
||||
extractedMetadata.recipients[0];
|
||||
const foundOrg = await this.attachmentRepo.manager
|
||||
.createQueryBuilder()
|
||||
.select('org.id', 'id')
|
||||
.from('organizations', 'org')
|
||||
.where('org.uuid = :uuid', {
|
||||
uuid: primaryReceiverObj.organizationPublicId,
|
||||
})
|
||||
.getRawOne<{ id: number }>();
|
||||
if (foundOrg) {
|
||||
primaryReceiverOrgId = Number(foundOrg.id);
|
||||
} else {
|
||||
// EC-002: UUID ของผู้รับไม่มีใน Master Data — flag เพื่อ human review
|
||||
aiIssues.push({
|
||||
type: 'UNRESOLVED_RECIPIENT_UUID',
|
||||
uuid: primaryReceiverObj.organizationPublicId,
|
||||
message: `UUID ผู้รับ '${primaryReceiverObj.organizationPublicId}' ไม่พบใน Master Data — ต้องการการตรวจสอบจากมนุษย์`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 5. ดึงประเภทเอกสารโต้ตอบ (Category Type) และสาขางาน (Discipline)
|
||||
let matchedCategory = 'Correspondence';
|
||||
if (extractedMetadata.correspondenceTypeCode) {
|
||||
const foundType = await this.attachmentRepo.manager
|
||||
.createQueryBuilder()
|
||||
.select('t.type_name', 'name')
|
||||
.from('correspondence_types', 't')
|
||||
.where('t.type_code = :code', {
|
||||
code: extractedMetadata.correspondenceTypeCode,
|
||||
})
|
||||
.getRawOne<{ name: string }>();
|
||||
if (foundType) {
|
||||
matchedCategory = foundType.name;
|
||||
}
|
||||
}
|
||||
|
||||
let matchedDisciplineId: number | undefined = undefined;
|
||||
if (extractedMetadata.disciplineCode) {
|
||||
const foundDisp = await this.attachmentRepo.manager
|
||||
.createQueryBuilder()
|
||||
.select('d.id', 'id')
|
||||
.from('disciplines', 'd')
|
||||
.where('d.discipline_code = :code', {
|
||||
code: extractedMetadata.disciplineCode,
|
||||
})
|
||||
.getRawOne<{ id: number }>();
|
||||
if (foundDisp) {
|
||||
matchedDisciplineId = Number(foundDisp.id);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. ส่งบันทึกเข้าสู่ Review Queue พร้อมคืนค่าผู้รับ Object Array ใน JSON metadata details
|
||||
// EC-002: หากมี UUID ที่ไม่สามารถ resolve ได้ ให้ isValid = false เพื่อส่งเข้า review เสมอ
|
||||
const hasUnresolvedUuids = aiIssues.some(
|
||||
(issue) =>
|
||||
issue.type === 'UNRESOLVED_SENDER_UUID' ||
|
||||
issue.type === 'UNRESOLVED_RECIPIENT_UUID'
|
||||
);
|
||||
const isValid = confidence >= 0.6 && !!docNumber && !hasUnresolvedUuids;
|
||||
const payloadTitle = readString(payload.title);
|
||||
|
||||
await this.migrationService.enqueueRecord({
|
||||
documentNumber: extractedMetadata.documentNumber || docNumber,
|
||||
documentNumber: docNumber,
|
||||
subject: extractedMetadata.subject || payloadTitle,
|
||||
originalSubject: payloadTitle,
|
||||
body: extractedMetadata.summary || '',
|
||||
category: extractedMetadata.category || 'Correspondence',
|
||||
category: matchedCategory,
|
||||
aiSummary: extractedMetadata.summary || '',
|
||||
projectId: project.id,
|
||||
senderOrgId: readNumberId(payload.senderOrgId),
|
||||
receiverOrgId: readNumberId(payload.receiverOrgId),
|
||||
issuedDate: extractedMetadata.date || undefined,
|
||||
receivedDate: extractedMetadata.date || undefined,
|
||||
senderOrgId: senderOrgId || readNumberId(payload.senderOrgId),
|
||||
receiverOrgId:
|
||||
primaryReceiverOrgId || readNumberId(payload.receiverOrgId),
|
||||
issuedDate: extractedMetadata.documentDate || undefined,
|
||||
receivedDate: extractedMetadata.documentDate || undefined,
|
||||
extractedTags: mappedTags,
|
||||
tempAttachmentId: attachment.id,
|
||||
isValid,
|
||||
confidence,
|
||||
aiJobId: String(job.id),
|
||||
aiIssues: aiIssues.length > 0 ? aiIssues : undefined,
|
||||
details: {
|
||||
discipline: extractedMetadata.discipline,
|
||||
disciplineCode: extractedMetadata.disciplineCode,
|
||||
disciplineId: matchedDisciplineId,
|
||||
recipientsList: extractedMetadata.recipients, // บันทึก Object Array สกัดใหม่
|
||||
},
|
||||
});
|
||||
|
||||
await this.saveAiAuditLog({
|
||||
documentPublicId,
|
||||
aiModel: this.ollamaService.getMainModelName(),
|
||||
status: AiAuditStatus.SUCCESS,
|
||||
aiSuggestionJson: extractedMetadata,
|
||||
aiSuggestionJson: extractedMetadata as unknown as Record<string, unknown>,
|
||||
confidenceScore: confidence,
|
||||
processingTimeMs: Date.now() - startTime,
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Change Log
|
||||
// - 2026-05-25: Created TypeORM entity for dynamic prompt management (ADR-029)
|
||||
// - 2026-05-25: Added definite assignment assertion operator (!) to satisfy strictPropertyInitialization
|
||||
// - 2026-05-27: Added publicId column for ADR-019 compliance
|
||||
|
||||
import {
|
||||
Entity,
|
||||
@@ -21,6 +22,9 @@ export class AiPrompt {
|
||||
@Exclude() // ADR-019: INT PK ไม่ expose ใน API
|
||||
id!: number;
|
||||
|
||||
@Column({ type: 'uuid', unique: true })
|
||||
publicId!: string;
|
||||
|
||||
@Column({ name: 'prompt_type', length: 50 })
|
||||
promptType!: string;
|
||||
|
||||
@@ -33,6 +37,9 @@ export class AiPrompt {
|
||||
@Column({ name: 'field_schema', type: 'json', nullable: true })
|
||||
fieldSchema!: Record<string, unknown> | null;
|
||||
|
||||
@Column({ name: 'context_config', type: 'json', nullable: true })
|
||||
contextConfig!: Record<string, unknown> | null;
|
||||
|
||||
@Column({ name: 'is_active', type: 'tinyint', width: 1, default: 0 })
|
||||
isActive!: boolean;
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
// File: backend/src/modules/ai/prompts/ai-prompts.service.spec.ts
|
||||
// Change Log
|
||||
// - 2026-05-25: Created unit tests for AiPromptsService (T028)
|
||||
// - 2026-05-27: Added resolveContext and project isolation security tests (T013)
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { AiPromptsService } from './ai-prompts.service';
|
||||
import { AiPrompt } from './ai-prompts.entity';
|
||||
import { AuditLog } from '../../../common/entities/audit-log.entity';
|
||||
@@ -34,9 +36,13 @@ describe('AiPromptsService', () => {
|
||||
};
|
||||
const mockQueryBuilder = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
from: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
innerJoin: jest.fn().mockReturnThis(),
|
||||
setLock: jest.fn().mockReturnThis(),
|
||||
getRawOne: jest.fn(),
|
||||
getRawMany: jest.fn(),
|
||||
};
|
||||
const mockQueryRunner = {
|
||||
connect: jest.fn(),
|
||||
@@ -54,6 +60,9 @@ describe('AiPromptsService', () => {
|
||||
};
|
||||
const mockDataSource = {
|
||||
createQueryRunner: jest.fn().mockReturnValue(mockQueryRunner),
|
||||
manager: {
|
||||
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder),
|
||||
},
|
||||
};
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
@@ -80,6 +89,139 @@ describe('AiPromptsService', () => {
|
||||
}).compile();
|
||||
service = module.get<AiPromptsService>(AiPromptsService);
|
||||
});
|
||||
describe('resolveContext', () => {
|
||||
it('ควรดึงข้อมูล Master Data ได้ครบถ้วนเมื่อไม่มี project filter และ override', async () => {
|
||||
const activePrompt = {
|
||||
id: 1,
|
||||
publicId: 'prompt-uuid-123',
|
||||
promptType: 'ocr_extraction',
|
||||
versionNumber: 1,
|
||||
template: 'Test template',
|
||||
fieldSchema: null,
|
||||
isActive: true,
|
||||
contextConfig: { filter: {} },
|
||||
testResultJson: null,
|
||||
manualNote: null,
|
||||
lastTestedAt: null,
|
||||
activatedAt: null,
|
||||
createdBy: 1,
|
||||
createdAt: new Date(),
|
||||
} as AiPrompt;
|
||||
mockQueryBuilder.getRawMany
|
||||
.mockResolvedValueOnce([
|
||||
{ projectCode: 'LCB3', uuid: 'proj-123', projectName: 'LCP Phase 3' },
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
organizationCode: 'OWN',
|
||||
uuid: 'org-456',
|
||||
organizationName: 'Owner',
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ disciplineCode: 'GEN', codeNameTh: 'General' },
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ typeCode: 'RFA', typeName: 'Request for Approval' },
|
||||
])
|
||||
.mockResolvedValueOnce([{ tagName: 'TagA', colorCode: '#FFF' }]);
|
||||
const result = await service.resolveContext(activePrompt);
|
||||
expect(result.availableProjects).toEqual([
|
||||
{ code: 'LCB3', uuid: 'proj-123', name: 'LCP Phase 3' },
|
||||
]);
|
||||
expect(result.availableOrganizations).toEqual([
|
||||
{ code: 'OWN', uuid: 'org-456', name: 'Owner' },
|
||||
]);
|
||||
expect(result.availableDisciplines).toEqual([
|
||||
{ code: 'GEN', name: 'General' },
|
||||
]);
|
||||
expect(result.availableCorrespondenceTypes).toEqual([
|
||||
{ code: 'RFA', name: 'Request for Approval' },
|
||||
]);
|
||||
expect(result.availableTags).toEqual([{ name: 'TagA', color: '#FFF' }]);
|
||||
});
|
||||
it('ควร throw NotFoundException เมื่อส่ง override project UUID ที่ไม่มีอยู่ใน DB', async () => {
|
||||
const activePrompt = {
|
||||
id: 1,
|
||||
publicId: 'prompt-uuid-456',
|
||||
promptType: 'ocr_extraction',
|
||||
versionNumber: 1,
|
||||
template: 'Test template',
|
||||
fieldSchema: null,
|
||||
isActive: true,
|
||||
contextConfig: {},
|
||||
testResultJson: null,
|
||||
manualNote: null,
|
||||
lastTestedAt: null,
|
||||
activatedAt: null,
|
||||
createdBy: 1,
|
||||
createdAt: new Date(),
|
||||
} as AiPrompt;
|
||||
mockQueryBuilder.getRawOne.mockResolvedValue(null);
|
||||
await expect(
|
||||
service.resolveContext(activePrompt, 'non-existent-uuid')
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
it('ควร throw ForbiddenException เมื่อพยายาม override ข้ามโครงการที่ถูกล็อคไว้ใน template', async () => {
|
||||
const activePrompt = {
|
||||
id: 1,
|
||||
publicId: 'prompt-uuid-789',
|
||||
promptType: 'ocr_extraction',
|
||||
versionNumber: 1,
|
||||
template: 'Test template',
|
||||
fieldSchema: null,
|
||||
isActive: true,
|
||||
contextConfig: {
|
||||
filter: {
|
||||
projectId: 1,
|
||||
},
|
||||
},
|
||||
testResultJson: null,
|
||||
manualNote: null,
|
||||
lastTestedAt: null,
|
||||
activatedAt: null,
|
||||
createdBy: 1,
|
||||
createdAt: new Date(),
|
||||
} as AiPrompt;
|
||||
mockQueryBuilder.getRawOne.mockResolvedValue({ id: 2 });
|
||||
await expect(
|
||||
service.resolveContext(activePrompt, 'another-project-uuid')
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
it('ควรผ่านเมื่อ override project UUID ตรงกับ projectId ที่ล็อคไว้ใน template', async () => {
|
||||
const activePrompt = {
|
||||
id: 1,
|
||||
publicId: 'prompt-uuid-abc',
|
||||
promptType: 'ocr_extraction',
|
||||
versionNumber: 1,
|
||||
template: 'Test template',
|
||||
fieldSchema: null,
|
||||
isActive: true,
|
||||
contextConfig: {
|
||||
filter: {
|
||||
projectId: 1,
|
||||
},
|
||||
},
|
||||
testResultJson: null,
|
||||
manualNote: null,
|
||||
lastTestedAt: null,
|
||||
activatedAt: null,
|
||||
createdBy: 1,
|
||||
createdAt: new Date(),
|
||||
} as AiPrompt;
|
||||
mockQueryBuilder.getRawOne.mockResolvedValue({ id: 1 });
|
||||
mockQueryBuilder.getRawMany
|
||||
.mockResolvedValueOnce([
|
||||
{ projectCode: 'LCB3', uuid: 'proj-123', projectName: 'LCP Phase 3' },
|
||||
])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([]);
|
||||
const result = await service.resolveContext(activePrompt, 'matched-uuid');
|
||||
expect(result.availableProjects).toBeDefined();
|
||||
});
|
||||
});
|
||||
describe('create', () => {
|
||||
it('ควรปฏิเสธ template ที่ไม่มี {{ocr_text}} placeholder', async () => {
|
||||
await expect(
|
||||
@@ -100,6 +242,7 @@ describe('AiPromptsService', () => {
|
||||
mockQueryBuilder.getRawOne.mockResolvedValue({ max: 5 });
|
||||
mockAiPromptRepo.create.mockReturnValue({
|
||||
id: 12,
|
||||
publicId: 'prompt-uuid-new',
|
||||
promptType: 'ocr_extraction',
|
||||
versionNumber: 6,
|
||||
template: 'Test {{ocr_text}}',
|
||||
@@ -107,6 +250,7 @@ describe('AiPromptsService', () => {
|
||||
});
|
||||
mockQueryRunner.manager.save.mockResolvedValue({
|
||||
id: 12,
|
||||
publicId: 'prompt-uuid-new',
|
||||
promptType: 'ocr_extraction',
|
||||
versionNumber: 6,
|
||||
template: 'Test {{ocr_text}}',
|
||||
@@ -126,12 +270,14 @@ describe('AiPromptsService', () => {
|
||||
it('ควร activate สำเร็จ ยกเลิกตัวอื่น และลบ cache', async () => {
|
||||
const activePrompt = {
|
||||
id: 1,
|
||||
publicId: 'prompt-uuid-active',
|
||||
promptType: 'ocr_extraction',
|
||||
versionNumber: 1,
|
||||
isActive: true,
|
||||
};
|
||||
const targetPrompt = {
|
||||
id: 2,
|
||||
publicId: 'prompt-uuid-target',
|
||||
promptType: 'ocr_extraction',
|
||||
versionNumber: 2,
|
||||
isActive: false,
|
||||
@@ -165,6 +311,7 @@ describe('AiPromptsService', () => {
|
||||
it('ควร throw error เมื่อลบ active version', async () => {
|
||||
mockAiPromptRepo.findOne.mockResolvedValue({
|
||||
id: 1,
|
||||
publicId: 'prompt-uuid-del',
|
||||
promptType: 'ocr_extraction',
|
||||
versionNumber: 1,
|
||||
isActive: true,
|
||||
@@ -176,6 +323,7 @@ describe('AiPromptsService', () => {
|
||||
it('ควรลบ inactive version สำเร็จและบันทึก audit log', async () => {
|
||||
const inactivePrompt = {
|
||||
id: 2,
|
||||
publicId: 'prompt-uuid-inactive',
|
||||
promptType: 'ocr_extraction',
|
||||
versionNumber: 2,
|
||||
isActive: false,
|
||||
@@ -190,6 +338,7 @@ describe('AiPromptsService', () => {
|
||||
it('ควรดึงจาก Redis cache เมื่อมี cache hit', async () => {
|
||||
const cachedPrompt = {
|
||||
id: 1,
|
||||
publicId: 'prompt-uuid-cache',
|
||||
promptType: 'ocr_extraction',
|
||||
versionNumber: 1,
|
||||
isActive: true,
|
||||
@@ -202,6 +351,7 @@ describe('AiPromptsService', () => {
|
||||
it('ควร fallback ไปหา DB เมื่อ Redis มีปัญหา', async () => {
|
||||
const dbPrompt = {
|
||||
id: 1,
|
||||
publicId: 'prompt-uuid-db',
|
||||
promptType: 'ocr_extraction',
|
||||
versionNumber: 1,
|
||||
isActive: true,
|
||||
|
||||
@@ -4,11 +4,12 @@
|
||||
// - 2026-05-25: Fixed BusinessException and NotFoundException constructor signatures
|
||||
// - 2026-05-25: Cast getRawOne() to resolve TypeScript type assertion error in ESLint
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Injectable, Logger, ForbiddenException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
import { InjectRedis } from '@nestjs-modules/ioredis';
|
||||
import Redis from 'ioredis';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { AiPrompt } from './ai-prompts.entity';
|
||||
import { AuditLog } from '../../../common/entities/audit-log.entity';
|
||||
import { CreateAiPromptDto } from './dto/create-ai-prompt.dto';
|
||||
@@ -36,8 +37,221 @@ export class AiPromptsService {
|
||||
private readonly dataSource: DataSource
|
||||
) {}
|
||||
|
||||
/**
|
||||
* ค้นหาและเตรียมข้อมูล Master Data อ้างอิงโครงการ (Context Resolution)
|
||||
* ดึงโครงการ, องค์กร, สาขางาน, ประเภทเอกสาร, และแท็ก
|
||||
* พร้อมทั้งทำหน้าที่เป็น Gatekeeper ป้องกันความสอดคล้องความปลอดภัย (ADR-030)
|
||||
* @param activePrompt Prompt version ที่ใช้งานอยู่
|
||||
* @param overrideProjectPublicId UUID โครงการสำหรับ override (optional)
|
||||
* @param overrideContractPublicId UUID สัญญาสำหรับ override (optional)
|
||||
* @returns Master data context ที่กรองแล้ว
|
||||
*/
|
||||
async resolveContext(
|
||||
activePrompt: AiPrompt,
|
||||
overrideProjectPublicId?: string,
|
||||
overrideContractPublicId?: string
|
||||
): Promise<Record<string, unknown>> {
|
||||
const config = activePrompt.contextConfig || {};
|
||||
const filter =
|
||||
(config.filter as Record<string, number | string | null | undefined>) ||
|
||||
{};
|
||||
let targetProjectId: number | null = filter.projectId
|
||||
? Number(filter.projectId)
|
||||
: null;
|
||||
const targetContractId: number | null = filter.contractId
|
||||
? Number(filter.contractId)
|
||||
: null;
|
||||
|
||||
// 1. Logic ตรวจสอบ Override และทำหน้าที่ Gatekeeper ป้องกัน Cross-project data leak
|
||||
if (overrideProjectPublicId) {
|
||||
// ค้นหาโครงการเป้าหมายตาม UUID สาธารณะ
|
||||
const foundProject = await this.dataSource.manager
|
||||
.createQueryBuilder()
|
||||
.select('p.id', 'id')
|
||||
.from('projects', 'p')
|
||||
.where('p.uuid = :uuid', { uuid: overrideProjectPublicId })
|
||||
.andWhere('p.deleted_at IS NULL')
|
||||
.getRawOne<{ id: number }>();
|
||||
|
||||
if (!foundProject) {
|
||||
throw new NotFoundException('Project', overrideProjectPublicId);
|
||||
}
|
||||
|
||||
const overrideProjectId = Number(foundProject.id);
|
||||
|
||||
// ตรวจสอบความสอดคล้องระดับโครงการ (Gatekeeper Rule)
|
||||
if (targetProjectId !== null && targetProjectId !== overrideProjectId) {
|
||||
throw new ForbiddenException(
|
||||
`Cross-project boundary violation: Template is restricted to project ID ${targetProjectId} but requested override is ${overrideProjectId}`
|
||||
);
|
||||
}
|
||||
|
||||
// หากผ่านการคัดกรอง หรือเป็น Global template ให้ใช้ค่า override project ID นี้
|
||||
targetProjectId = overrideProjectId;
|
||||
}
|
||||
|
||||
let overrideContractProjectId: number | null = null;
|
||||
let overrideContractId: number | null = null;
|
||||
if (overrideContractPublicId) {
|
||||
const foundContract = await this.dataSource.manager
|
||||
.createQueryBuilder()
|
||||
.select(['c.id as id', 'c.project_id as projectId'])
|
||||
.from('contracts', 'c')
|
||||
.where('c.uuid = :uuid', { uuid: overrideContractPublicId })
|
||||
.getRawOne<{ id: number; projectId: number }>();
|
||||
|
||||
if (!foundContract) {
|
||||
throw new NotFoundException('Contract', overrideContractPublicId);
|
||||
}
|
||||
|
||||
overrideContractId = Number(foundContract.id);
|
||||
overrideContractProjectId = Number(foundContract.projectId);
|
||||
|
||||
if (
|
||||
targetContractId !== null &&
|
||||
targetContractId !== overrideContractId
|
||||
) {
|
||||
throw new ForbiddenException(
|
||||
`Cross-contract boundary violation: Template is restricted to contract ID ${targetContractId} but requested override is ${overrideContractId}`
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
targetProjectId !== null &&
|
||||
overrideContractProjectId !== targetProjectId
|
||||
) {
|
||||
throw new ForbiddenException(
|
||||
`Cross-project boundary violation: Contract belongs to project ID ${overrideContractProjectId} but requested project is ${targetProjectId}`
|
||||
);
|
||||
}
|
||||
}
|
||||
const targetContractIdResolved =
|
||||
overrideContractId !== null ? overrideContractId : targetContractId;
|
||||
if (targetProjectId === null && overrideContractProjectId !== null) {
|
||||
targetProjectId = overrideContractProjectId;
|
||||
}
|
||||
|
||||
// 2. ดึง Master Data ภายใต้ Project/Contract scope ที่จำกัด
|
||||
const projectsQuery = this.dataSource.manager
|
||||
.createQueryBuilder()
|
||||
.select([
|
||||
'p.project_code as projectCode',
|
||||
'p.uuid as uuid',
|
||||
'p.project_name as projectName',
|
||||
])
|
||||
.from('projects', 'p')
|
||||
.where('p.deleted_at IS NULL');
|
||||
if (targetProjectId) {
|
||||
projectsQuery.andWhere('p.id = :projectId', {
|
||||
projectId: targetProjectId,
|
||||
});
|
||||
}
|
||||
const projects = await projectsQuery.getRawMany<{
|
||||
projectCode: string;
|
||||
uuid: string;
|
||||
projectName: string;
|
||||
}>();
|
||||
|
||||
const orgsQuery = this.dataSource.manager
|
||||
.createQueryBuilder()
|
||||
.select([
|
||||
'o.organization_code as organizationCode',
|
||||
'o.uuid as uuid',
|
||||
'o.organization_name as organizationName',
|
||||
])
|
||||
.from('organizations', 'o')
|
||||
.where('o.deleted_at IS NULL');
|
||||
if (targetProjectId) {
|
||||
// ค้นหาองค์กรที่ผูกอยู่ในโครงการนั้นๆ
|
||||
orgsQuery
|
||||
.innerJoin('project_organizations', 'po', 'po.organization_id = o.id')
|
||||
.andWhere('po.project_id = :projectId', { projectId: targetProjectId });
|
||||
}
|
||||
const organizations = await orgsQuery.getRawMany<{
|
||||
organizationCode: string;
|
||||
uuid: string;
|
||||
organizationName: string;
|
||||
}>();
|
||||
|
||||
const disciplinesQuery = this.dataSource.manager
|
||||
.createQueryBuilder()
|
||||
.select([
|
||||
'd.discipline_code as disciplineCode',
|
||||
'd.code_name_th as codeNameTh',
|
||||
])
|
||||
.from('disciplines', 'd')
|
||||
.where('d.is_active = 1');
|
||||
if (targetContractIdResolved) {
|
||||
disciplinesQuery.andWhere('d.contract_id = :contractId', {
|
||||
contractId: targetContractIdResolved,
|
||||
});
|
||||
} else if (targetProjectId) {
|
||||
// ดึงจากสัญญาทั้งหมดที่อยู่ภายใต้โครงการเป้าหมาย
|
||||
disciplinesQuery
|
||||
.innerJoin('contracts', 'c', 'c.id = d.contract_id')
|
||||
.andWhere('c.project_id = :projectId', { projectId: targetProjectId });
|
||||
}
|
||||
const disciplines = await disciplinesQuery.getRawMany<{
|
||||
disciplineCode: string;
|
||||
codeNameTh: string;
|
||||
}>();
|
||||
|
||||
const correspondenceTypes = await this.dataSource.manager
|
||||
.createQueryBuilder()
|
||||
.select(['t.type_code as typeCode', 't.type_name as typeName'])
|
||||
.from('correspondence_types', 't')
|
||||
.where('t.is_active = 1')
|
||||
.andWhere('t.deleted_at IS NULL')
|
||||
.getRawMany<{ typeCode: string; typeName: string }>();
|
||||
|
||||
const tagsQuery = this.dataSource.manager
|
||||
.createQueryBuilder()
|
||||
.select(['tg.tag_name as tagName', 'tg.color_code as colorCode'])
|
||||
.from('tags', 'tg')
|
||||
.where('tg.deleted_at IS NULL');
|
||||
if (targetProjectId) {
|
||||
tagsQuery.andWhere(
|
||||
'(tg.project_id = :projectId OR tg.project_id IS NULL)',
|
||||
{ projectId: targetProjectId }
|
||||
);
|
||||
} else {
|
||||
tagsQuery.andWhere('tg.project_id IS NULL');
|
||||
}
|
||||
const tags = await tagsQuery.getRawMany<{
|
||||
tagName: string;
|
||||
colorCode: string;
|
||||
}>();
|
||||
|
||||
return {
|
||||
availableProjects: projects.map((p) => ({
|
||||
code: p.projectCode,
|
||||
uuid: p.uuid,
|
||||
name: p.projectName,
|
||||
})),
|
||||
availableOrganizations: organizations.map((o) => ({
|
||||
code: o.organizationCode,
|
||||
uuid: o.uuid,
|
||||
name: o.organizationName,
|
||||
})),
|
||||
availableDisciplines: disciplines.map((d) => ({
|
||||
code: d.disciplineCode,
|
||||
name: d.codeNameTh,
|
||||
})),
|
||||
availableCorrespondenceTypes: correspondenceTypes.map((t) => ({
|
||||
code: t.typeCode,
|
||||
name: t.typeName,
|
||||
})),
|
||||
availableTags: tags.map((t) => ({
|
||||
name: tgName(t.tagName),
|
||||
color: t.colorCode,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* ดึงรายการเวอร์ชันทั้งหมดของ prompt_type ที่กำหนด
|
||||
* @param promptType ประเภทของ prompt (เช่น 'ocr_extraction')
|
||||
* @returns รายการ prompt versions เรียงตาม versionNumber ล่าสุดก่อน
|
||||
*/
|
||||
async findAll(promptType: string): Promise<AiPrompt[]> {
|
||||
return this.aiPromptRepo.find({
|
||||
@@ -48,6 +262,8 @@ export class AiPromptsService {
|
||||
|
||||
/**
|
||||
* ดึง Active prompt จาก Redis cache หรือ DB fallback
|
||||
* @param promptType ประเภทของ prompt
|
||||
* @returns Prompt version ที่เปิดใช้งานอยู่ หรือ null หากไม่พบ
|
||||
*/
|
||||
async getActive(promptType: string): Promise<AiPrompt | null> {
|
||||
const cacheKey = `${this.cachePrefix}${promptType}`;
|
||||
@@ -78,6 +294,10 @@ export class AiPromptsService {
|
||||
|
||||
/**
|
||||
* ค้นหา prompt ที่มีผลใช้งานจริง และแทนที่ placeholder {{ocr_text}} ด้วยข้อความ OCR
|
||||
* @param promptType ประเภทของ prompt
|
||||
* @param ocrText ข้อความที่สกัดจาก OCR
|
||||
* @returns Prompt ที่แทนที่ placeholder แล้ว พร้อม version number
|
||||
* @throws BusinessException หากไม่พบ active prompt
|
||||
*/
|
||||
async resolveActive(
|
||||
promptType: string,
|
||||
@@ -97,6 +317,11 @@ export class AiPromptsService {
|
||||
|
||||
/**
|
||||
* สร้าง Prompt Version ใหม่พร้อมการตรวจสอบ placeholder และ character limit
|
||||
* @param promptType ประเภทของ prompt
|
||||
* @param dto ข้อมูล template และ contextConfig
|
||||
* @param userId ID ของผู้สร้าง
|
||||
* @returns Prompt version ที่สร้างใหม่
|
||||
* @throws ValidationException หาก template ไม่มี placeholder หรือเกิน character limit
|
||||
*/
|
||||
async create(
|
||||
promptType: string,
|
||||
@@ -122,9 +347,12 @@ export class AiPromptsService {
|
||||
const nextVersion =
|
||||
(maxVersionResult?.max ? Number(maxVersionResult.max) : 0) + 1;
|
||||
const newPrompt = this.aiPromptRepo.create({
|
||||
publicId: randomUUID(),
|
||||
promptType,
|
||||
versionNumber: nextVersion,
|
||||
template: dto.template,
|
||||
fieldSchema: null,
|
||||
contextConfig: dto.contextConfig || null,
|
||||
isActive: false,
|
||||
createdBy: userId,
|
||||
});
|
||||
@@ -147,6 +375,11 @@ export class AiPromptsService {
|
||||
|
||||
/**
|
||||
* เปิดใช้งานเวอร์ชันที่กำหนด และยกเลิกการใช้งานเวอร์ชันอื่นทั้งหมดภายใต้ prompt_type เดียวกัน
|
||||
* @param promptType ประเภทของ prompt
|
||||
* @param versionNumber เลขเวอร์ชันที่ต้องการเปิดใช้งาน
|
||||
* @param userId ID ของผู้ดำเนินการ
|
||||
* @returns Prompt version ที่เปิดใช้งานแล้ว
|
||||
* @throws NotFoundException หากไม่พบ prompt version
|
||||
*/
|
||||
async activate(
|
||||
promptType: string,
|
||||
@@ -202,6 +435,11 @@ export class AiPromptsService {
|
||||
|
||||
/**
|
||||
* ลบเวอร์ชันที่ไม่ได้ใช้งาน (ห้ามลบเวอร์ชันที่เป็น active)
|
||||
* @param promptType ประเภทของ prompt
|
||||
* @param versionNumber เลขเวอร์ชันที่ต้องการลบ
|
||||
* @param userId ID ของผู้ดำเนินการ
|
||||
* @throws NotFoundException หากไม่พบ prompt version
|
||||
* @throws BusinessException หากพยายามลบ active version
|
||||
*/
|
||||
async delete(
|
||||
promptType: string,
|
||||
@@ -232,6 +470,11 @@ export class AiPromptsService {
|
||||
|
||||
/**
|
||||
* อัปเดต manual note ของเวอร์ชันที่กำหนด
|
||||
* @param promptType ประเภทของ prompt
|
||||
* @param versionNumber เลขเวอร์ชัน
|
||||
* @param note ข้อความ note หรือ null หากต้องการลบ
|
||||
* @returns Prompt version ที่อัปเดตแล้ว
|
||||
* @throws NotFoundException หากไม่พบ prompt version
|
||||
*/
|
||||
async updateNote(
|
||||
promptType: string,
|
||||
@@ -250,6 +493,9 @@ export class AiPromptsService {
|
||||
|
||||
/**
|
||||
* บันทึกผลทดสอบของเวอร์ชันหลังจากรัน OCR Sandbox
|
||||
* @param promptType ประเภทของ prompt
|
||||
* @param versionNumber เลขเวอร์ชัน
|
||||
* @param resultJson ผลลัพธ์การทดสอบในรูป JSON
|
||||
*/
|
||||
async saveTestResult(
|
||||
promptType: string,
|
||||
@@ -292,3 +538,8 @@ export class AiPromptsService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Helper function to sanitize tag name */
|
||||
function tgName(name: unknown): string {
|
||||
return typeof name === 'string' ? name.trim() : '';
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Change Log
|
||||
// - 2026-05-25: Created AiPromptResponseDto to exclude internal INT PK and expose clean API fields (ADR-029)
|
||||
// - 2026-05-25: Added definite assignment assertion operator (!) to satisfy strictPropertyInitialization
|
||||
// - 2026-05-27: Added publicId field for ADR-019 compliance
|
||||
|
||||
import { Expose } from 'class-transformer';
|
||||
|
||||
@@ -10,6 +11,9 @@ import { Expose } from 'class-transformer';
|
||||
* โดยคัดกรองเฉพาะข้อมูลภายนอกและปิดบัง PK ดั้งเดิมตามนโยบายความปลอดภัย
|
||||
*/
|
||||
export class AiPromptResponseDto {
|
||||
@Expose({ name: 'id' })
|
||||
publicId!: string;
|
||||
|
||||
@Expose()
|
||||
promptType!: string;
|
||||
|
||||
@@ -25,6 +29,9 @@ export class AiPromptResponseDto {
|
||||
@Expose()
|
||||
testResultJson!: Record<string, unknown> | null;
|
||||
|
||||
@Expose()
|
||||
contextConfig!: Record<string, unknown> | null;
|
||||
|
||||
@Expose()
|
||||
manualNote!: string | null;
|
||||
|
||||
|
||||
@@ -3,7 +3,13 @@
|
||||
// - 2026-05-25: Created CreateAiPromptDto for prompt version creation (ADR-029)
|
||||
// - 2026-05-25: Added definite assignment assertion operator (!) to satisfy strictPropertyInitialization
|
||||
|
||||
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||
import {
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsObject,
|
||||
IsString,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
/**
|
||||
* Data Transfer Object สำหรับการสร้าง prompt version ใหม่
|
||||
@@ -13,4 +19,8 @@ export class CreateAiPromptDto {
|
||||
@IsNotEmpty({ message: 'Template text must not be empty' })
|
||||
@MaxLength(4000, { message: 'Template exceeds 4,000 character limit' })
|
||||
template!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject({ message: 'contextConfig must be a valid JSON object' })
|
||||
contextConfig?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Change Log:
|
||||
// - 2026-05-22: เริ่มต้นสร้าง TagsService สำหรับจัดการข้อมูลแท็กและเชื่อมโยงกับเอกสารโต้ตอบตาม ADR-028
|
||||
// - 2026-05-22: แก้ไข type compilation error ของ projectId ใน findOne และ find โดยใช้ IsNull()
|
||||
// - 2026-05-28: เพิ่ม findOrSuggestTags() คืนค่า isNew flag สำหรับ EC-001 edge case
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
@@ -87,6 +88,45 @@ export class TagsService {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* ค้นหาหรือสร้างแท็กจากชื่อหลายๆ ชื่อพร้อมกัน และคืนค่า isNew flag สำหรับแต่ละแท็ก
|
||||
* ใช้ใน EC-001: AI สกัด Tags ออกมาแล้วไม่มีในระบบ จะ suggest เป็น Tag ใหม่ (isNew: true)
|
||||
* @param projectId รหัสโครงการ (null = แท็กทั่วไป)
|
||||
* @param tagNames รายชื่อแท็กที่สกัดจาก AI
|
||||
* @param createdBy รหัสผู้ใช้ที่สร้าง
|
||||
* @returns รายการ { tag, isNew } สำหรับแต่ละแท็กที่ unique
|
||||
*/
|
||||
async findOrSuggestTags(
|
||||
projectId: number | null,
|
||||
tagNames: string[],
|
||||
createdBy?: number | null
|
||||
): Promise<Array<{ tag: Tag; isNew: boolean }>> {
|
||||
const uniqueNames = Array.from(
|
||||
new Set(tagNames.map((name) => this.normalize(name)))
|
||||
).filter(Boolean);
|
||||
const result: Array<{ tag: Tag; isNew: boolean }> = [];
|
||||
for (const name of uniqueNames) {
|
||||
const normalizedName = this.normalize(name);
|
||||
const existing = await this.tagRepo.findOne({
|
||||
where: {
|
||||
projectId: projectId === null ? IsNull() : projectId,
|
||||
tagName: normalizedName,
|
||||
},
|
||||
});
|
||||
if (existing) {
|
||||
result.push({ tag: existing, isNew: false });
|
||||
} else {
|
||||
const created = await this.create({
|
||||
projectId,
|
||||
tagName: name,
|
||||
createdBy,
|
||||
});
|
||||
result.push({ tag: created, isNew: true });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* ทำความสะอาดและปรับรูปแบบชื่อแท็กให้เป็นตัวพิมพ์เล็กและไม่มีช่องว่างส่วนเกิน
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user