690605:2335 ADR-035-135 #1
CI / CD Pipeline / build (push) Successful in 4m54s
CI / CD Pipeline / deploy (push) Successful in 6m19s

This commit is contained in:
2026-06-05 23:35:22 +07:00
parent 285c007dff
commit 26cc71ce60
47 changed files with 2912 additions and 1767 deletions
-2
View File
@@ -51,7 +51,6 @@ import { SearchModule } from './modules/search/search.module';
import { AuditLogModule } from './modules/audit-log/audit-log.module';
import { MigrationModule } from './modules/migration/migration.module';
import { AiModule } from './modules/ai/ai.module';
import { RagModule } from './modules/rag/rag.module';
import { ReviewTeamModule } from './modules/review-team/review-team.module';
import { ResponseCodeModule } from './modules/response-code/response-code.module';
import { DelegationModule } from './modules/delegation/delegation.module';
@@ -192,7 +191,6 @@ import { TagsModule } from './modules/tags/tags.module';
AuditLogModule,
MigrationModule,
AiModule,
RagModule,
ReviewTeamModule,
ResponseCodeModule,
DelegationModule,
@@ -0,0 +1,74 @@
// File: backend/src/modules/ai/ai-qdrant.service.spec.ts
// Change Log:
// - 2026-06-05: สร้าง unit test สำหรับ AiQdrantService ครอบคลุม deleteByDocumentPublicId (T4)
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { AiQdrantService } from './qdrant.service';
describe('AiQdrantService', () => {
let service: AiQdrantService;
let mockConfigService: jest.Mocked<ConfigService>;
beforeEach(async () => {
mockConfigService = {
get: jest.fn(),
} as unknown as jest.Mocked<ConfigService>;
mockConfigService.get.mockImplementation((key: string) => {
if (key === 'AI_QDRANT_URL' || key === 'QDRANT_URL') {
return 'http://localhost:6333';
}
return undefined;
});
const module: TestingModule = await Test.createTestingModule({
providers: [
AiQdrantService,
{ provide: ConfigService, useValue: mockConfigService },
],
}).compile();
service = module.get<AiQdrantService>(AiQdrantService);
});
it('ควรถูกสร้างขึ้นสำเร็จ', () => {
expect(service).toBeDefined();
});
describe('deleteByDocumentPublicId', () => {
it('ควร throw error ถ้า projectPublicId ว่าง', async () => {
await expect(
service.deleteByDocumentPublicId('', 'doc-uuid-123')
).rejects.toThrow('AI_QDRANT_PROJECT_SCOPE_REQUIRED');
});
it('ควร throw error ถ้า projectPublicId เป็น undefined', async () => {
await expect(
service.deleteByDocumentPublicId(
undefined as unknown as string,
'doc-uuid-123'
)
).rejects.toThrow('AI_QDRANT_PROJECT_SCOPE_REQUIRED');
});
it('ควรเรียก Qdrant delete ด้วย filter ที่ถูกต้อง (project_public_id + doc_public_id)', async () => {
// Mock QdrantClient.delete method
const mockDelete = jest.fn().mockResolvedValue(undefined);
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
(service as any).client.delete = mockDelete;
await service.deleteByDocumentPublicId('proj-uuid-456', 'doc-uuid-123');
expect(mockDelete).toHaveBeenCalledWith('lcbp3_vectors', {
wait: true,
filter: {
must: [
{ key: 'project_public_id', match: { value: 'proj-uuid-456' } },
{ key: 'doc_public_id', match: { value: 'doc-uuid-123' } },
],
},
});
});
});
});
+35 -1
View File
@@ -32,9 +32,24 @@ export interface AiRagJobPayload {
/** Payload สำหรับลบ vector ใน Qdrant แบบ eventual consistency */
export interface AiVectorDeletionJobPayload {
documentPublicId: string;
projectPublicId: string;
requestedByUserPublicId: string;
}
/** Payload สำหรับงาน RAG Prepare เมื่อผู้ใช้ submit workflow */
export interface RagPrepareJobPayload {
documentPublicId: string;
projectPublicId: string;
correspondenceNumber: string;
docType: string;
statusCode: string;
revisionNumber: number;
subject: string;
documentDate?: string;
cachedOcrText?: string;
attachmentPath?: string;
}
/** จัดการคิว AI ทั้งหมดให้อยู่หลัง BullMQ ตาม ADR-008/ADR-023 */
@Injectable()
export class AiQueueService {
@@ -92,7 +107,7 @@ export class AiQueueService {
payload,
{
...this.defaultOptions,
jobId: payload.documentPublicId,
jobId: `${payload.projectPublicId}:${payload.documentPublicId}`,
}
);
return String(job.id);
@@ -158,4 +173,23 @@ export class AiQueueService {
const waiting = await this.batchQueue.getWaitingCount();
return active + waiting;
}
/**
* ส่งงาน RAG Prepare เข้า queue เพื่อเตรียมหั่นข้อมูลและทำ embedding ในเบื้องหลัง
* @idempotency `jobId = rag-prepare:${documentPublicId}:${revisionNumber}` — ป้องกันการรันซ้ำสำหรับ revision เดียวกัน
*/
async enqueueRagPrepare(payload: RagPrepareJobPayload): Promise<string> {
const job = await this.batchQueue.add(
'rag-prepare',
{
jobType: 'rag-prepare',
...payload,
},
{
...this.defaultOptions,
jobId: `rag-prepare:${payload.documentPublicId}:${payload.revisionNumber}`,
}
);
return String(job.id);
}
}
@@ -0,0 +1,370 @@
// File: backend/src/modules/ai/ai-rag-pipeline.integration.spec.ts
// Change Log:
// - 2026-06-05: สร้าง integration test สำหรับ RAG Pipeline end-to-end (SC-002, Gap fix)
// ครอบคลุม: enqueueRagPrepare jobId dedup, EmbeddingService pipeline, project isolation
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { getQueueToken } from '@nestjs/bullmq';
import { AiQueueService, RagPrepareJobPayload } from './ai-queue.service';
import { EmbeddingService } from './services/embedding.service';
import { OllamaService } from './services/ollama.service';
import { OcrService } from './services/ocr.service';
import { AiQdrantService } from './qdrant.service';
import { AiPromptsService } from './prompts/ai-prompts.service';
import {
QUEUE_AI_INGEST,
QUEUE_AI_RAG,
QUEUE_AI_VECTOR_DELETION,
QUEUE_AI_BATCH,
} from '../common/constants/queue.constants';
// ────────────────────────────────────────────────────────────────────────────────
// Mock helpers
// ────────────────────────────────────────────────────────────────────────────────
/** สร้าง mock BullMQ Queue ที่ track jobId เพื่อ verify deduplication */
const createMockQueue = () => {
return {
add: jest
.fn()
.mockImplementation(
(name: string, data: unknown, opts: { jobId?: string } = {}) =>
Promise.resolve({ id: opts.jobId ?? 'auto-id' })
),
};
};
/** สร้าง mock EmbeddingService dependencies */
const buildEmbeddingModule = async (
ollamaGenerateResponse: string,
chunkSize = 512,
chunkOverlap = 64
) => {
const mockOllamaService = {
generate: jest.fn().mockResolvedValue(ollamaGenerateResponse),
};
const mockAiPromptsService = {
resolveActive: jest.fn().mockResolvedValue({
resolvedPrompt: 'แบ่ง OCR text ออกเป็น chunks',
versionNumber: 1,
}),
};
const mockConfigService = {
get: jest.fn((key: string, def?: unknown) => {
const vals: Record<string, unknown> = {
EMBEDDING_CHUNK_SIZE: chunkSize,
EMBEDDING_CHUNK_OVERLAP: chunkOverlap,
};
return vals[key] ?? def;
}),
};
const mockEmbedViaSidecar = jest.fn().mockResolvedValue({
dense: Array(1024).fill(0.1),
sparse: { indices: [10, 20], values: [0.8, 0.4] },
});
const mockDeleteByDocumentPublicId = jest.fn().mockResolvedValue(undefined);
const mockUpsert = jest.fn().mockResolvedValue(undefined);
const module: TestingModule = await Test.createTestingModule({
providers: [
EmbeddingService,
{ provide: ConfigService, useValue: mockConfigService },
{ provide: OllamaService, useValue: mockOllamaService },
{
provide: AiQdrantService,
useValue: {
deleteByDocumentPublicId: mockDeleteByDocumentPublicId,
upsert: mockUpsert,
},
},
{
provide: OcrService,
useValue: { embedViaSidecar: mockEmbedViaSidecar },
},
{ provide: AiPromptsService, useValue: mockAiPromptsService },
],
}).compile();
return {
service: module.get<EmbeddingService>(EmbeddingService),
mockEmbedViaSidecar,
mockDeleteByDocumentPublicId,
mockUpsert,
mockOllamaService,
};
};
// ────────────────────────────────────────────────────────────────────────────────
describe('RAG Pipeline — Integration (SC-002 / Gap fixes)', () => {
// ──────────────────────────────────────────────────────────────────────────────
// Test Group 1: BullMQ Job Deduplication (Gap 1 verify)
// ──────────────────────────────────────────────────────────────────────────────
describe('enqueueRagPrepare — jobId deduplication', () => {
let queueService: AiQueueService;
let mockBatchQueue: ReturnType<typeof createMockQueue>;
beforeEach(async () => {
mockBatchQueue = createMockQueue();
const module: TestingModule = await Test.createTestingModule({
providers: [
AiQueueService,
{
provide: getQueueToken(QUEUE_AI_INGEST),
useValue: { add: jest.fn() },
},
{
provide: getQueueToken(QUEUE_AI_RAG),
useValue: { add: jest.fn() },
},
{
provide: getQueueToken(QUEUE_AI_VECTOR_DELETION),
useValue: { add: jest.fn() },
},
{ provide: getQueueToken(QUEUE_AI_BATCH), useValue: mockBatchQueue },
],
}).compile();
queueService = module.get<AiQueueService>(AiQueueService);
});
it('ควรสร้าง jobId = rag-prepare:{documentPublicId}:{revisionNumber} (SC-004 dedup)', async () => {
const payload: RagPrepareJobPayload = {
documentPublicId: 'doc-uuid-001',
projectPublicId: 'proj-uuid-abc',
correspondenceNumber: 'CORR-2026-001',
docType: 'LETTER',
statusCode: 'SUBOWN',
revisionNumber: 1,
subject: 'เอกสารทดสอบ Dedup',
};
await queueService.enqueueRagPrepare(payload);
const calls = mockBatchQueue.add.mock.calls as [
string,
unknown,
{ jobId?: string },
][];
expect(calls[0][2]?.jobId).toBe('rag-prepare:doc-uuid-001:1');
});
it('ควร enqueue ด้วยชื่อ job rag-prepare และ payload ครบ', async () => {
const payload: RagPrepareJobPayload = {
documentPublicId: 'doc-uuid-002',
projectPublicId: 'proj-uuid-xyz',
correspondenceNumber: 'CORR-2026-002',
docType: 'RFA',
statusCode: 'CLBOWN',
revisionNumber: 0,
subject: 'RFA Test',
documentDate: '2026-06-05',
attachmentPath: '/files/rfa.pdf',
};
await queueService.enqueueRagPrepare(payload);
expect(mockBatchQueue.add).toHaveBeenCalledWith(
'rag-prepare',
expect.objectContaining({
jobType: 'rag-prepare',
documentPublicId: 'doc-uuid-002',
revisionNumber: 0,
}),
expect.objectContaining({
jobId: 'rag-prepare:doc-uuid-002:0',
attempts: 3,
})
);
});
it('ควรคืน jobId เดิมเมื่อ enqueue revision เดียวกัน 2 ครั้ง (idempotency)', async () => {
const payload: RagPrepareJobPayload = {
documentPublicId: 'doc-same',
projectPublicId: 'proj-same',
correspondenceNumber: 'CORR-SAME',
docType: 'LETTER',
statusCode: 'SUBOWN',
revisionNumber: 3,
subject: 'Idempotency Test',
};
const id1 = await queueService.enqueueRagPrepare(payload);
const id2 = await queueService.enqueueRagPrepare(payload);
// jobId เหมือนกัน — BullMQ จะ deduplicate ที่ server side
expect(id1).toBe(id2);
const calls = mockBatchQueue.add.mock.calls as [
string,
unknown,
{ jobId?: string },
][];
expect(calls[0][2]?.jobId).toBe(calls[1][2]?.jobId);
});
});
// ──────────────────────────────────────────────────────────────────────────────
// Test Group 2: processRagPrepare → EmbeddingService pipeline (SC-002)
// ──────────────────────────────────────────────────────────────────────────────
describe('EmbeddingService.embedDocument — full pipeline (SC-002)', () => {
const semanticLlmResponse =
'<chunk topic="บทนำ">เนื้อหาบทนำของเอกสารที่มีความยาวเพียงพอสำหรับการทดสอบ</chunk>' +
'<chunk topic="รายละเอียด">เนื้อหารายละเอียดของเอกสารฉบับนี้ครอบคลุมหัวข้อสำคัญ</chunk>';
const ocrText =
'เนื้อหาเอกสารที่มีความยาวเกิน 50 ตัวอักษร สำหรับทดสอบ RAG pipeline integration test ครบ pipeline';
it('SC-002: ควรเรียก Sidecar /embed และ Qdrant upsert สำหรับ semantic chunks', async () => {
const {
service,
mockEmbedViaSidecar,
mockDeleteByDocumentPublicId,
mockUpsert,
} = await buildEmbeddingModule(semanticLlmResponse);
const result = await service.embedDocument(
'proj-uuid-123',
'doc-uuid-456',
'CORR-2026-001',
'LETTER',
'SUBOWN',
1,
'Test Subject',
'2026-06-05',
ocrText
);
// ตรวจสอบว่า Sidecar /embed ถูกเรียกสำหรับแต่ละ semantic chunk (2 chunks)
expect(mockEmbedViaSidecar).toHaveBeenCalledTimes(2);
// ตรวจสอบว่าลบ points เก่าก่อน upsert (delete-before-upsert)
expect(mockDeleteByDocumentPublicId).toHaveBeenCalledWith(
'proj-uuid-123',
'doc-uuid-456'
);
// ตรวจสอบ upsert payload ครบ 11 fields
expect(mockUpsert).toHaveBeenCalledWith(
'proj-uuid-123',
expect.arrayContaining([
expect.objectContaining({
payload: expect.objectContaining({
doc_public_id: 'doc-uuid-456',
project_public_id: 'proj-uuid-123',
doc_number: 'CORR-2026-001',
doc_type: 'LETTER',
status_code: 'SUBOWN',
revision_number: 1,
subject: 'Test Subject',
document_date: '2026-06-05',
}),
}),
])
);
expect(result.success).toBe(true);
expect(result.chunksEmbedded).toBe(2);
});
it('SC-003: project isolation — upsert และ delete ต้องใช้ projectPublicId ที่ถูกต้อง', async () => {
const { service, mockDeleteByDocumentPublicId, mockUpsert } =
await buildEmbeddingModule(semanticLlmResponse);
await service.embedDocument(
'proj-ISOLATED-999',
'doc-iso',
'CORR-ISO',
'LETTER',
'SUBOWN',
0,
'Subject',
undefined,
ocrText
);
// deleteByDocumentPublicId ต้องใช้ projectPublicId ที่ถูกต้อง
expect(mockDeleteByDocumentPublicId).toHaveBeenCalledWith(
'proj-ISOLATED-999',
'doc-iso'
);
// upsert ต้องส่ง projectPublicId ที่ถูกต้องเป็น arg แรก
const upsertCalls = mockUpsert.mock.calls as [string, unknown][];
expect(upsertCalls[0][0]).toBe('proj-ISOLATED-999');
});
it('SC-006: ลำดับ delete → upsert ต้องถูกต้องเสมอ (ป้องกัน stale chunks)', async () => {
const callOrder: string[] = [];
const { service, mockDeleteByDocumentPublicId, mockUpsert } =
await buildEmbeddingModule(semanticLlmResponse);
mockDeleteByDocumentPublicId.mockImplementationOnce(() => {
callOrder.push('delete');
});
mockUpsert.mockImplementationOnce(() => {
callOrder.push('upsert');
});
await service.embedDocument(
'proj-x',
'doc-stale',
'CORR-X',
'LETTER',
'SUBOWN',
2,
'Sub',
undefined,
ocrText
);
// ตรวจสอบลำดับ: delete ต้องเกิดก่อน upsert เสมอ (SC-006)
expect(callOrder).toEqual(['delete', 'upsert']);
});
it('ควรคืน success=false เมื่อ ocrText ว่าง (edge case — skip guard)', async () => {
const { service } = await buildEmbeddingModule(semanticLlmResponse);
const result = await service.embedDocument(
'proj-x',
'doc-empty',
'CORR-X',
'LETTER',
'SUBOWN',
1,
'Sub',
undefined,
''
);
expect(result.success).toBe(false);
expect(result.error).toContain('No OCR text');
});
});
// ──────────────────────────────────────────────────────────────────────────────
// Test Group 3: Semantic Chunking fallback → fixed-size (FR-005)
// ──────────────────────────────────────────────────────────────────────────────
describe('Semantic Chunking fallback (FR-005)', () => {
it('ควร fallback เป็น fixed-size และยังคง embed ได้ เมื่อ LLM output ไม่มี <chunk> tag', async () => {
const { service, mockEmbedViaSidecar, mockUpsert } =
await buildEmbeddingModule(
'ไม่มี tag chunk เลย — plain text output',
60,
0
);
const ocrText = 'ก'.repeat(80); // 80 chars → 2 chunks (60 + 20 chars)
const result = await service.embedDocument(
'proj-fallback',
'doc-fallback',
'CORR-FB',
'LETTER',
'SUBOWN',
1,
'Fallback',
undefined,
ocrText
);
// fallback ยังต้อง embed ได้
expect(result.success).toBe(true);
expect(result.chunksEmbedded).toBeGreaterThan(0);
expect(mockEmbedViaSidecar).toHaveBeenCalled();
// ตรวจสอบว่า chunk_topic มาจาก fixed-size (ขึ้นต้นด้วย "ส่วนที่")
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const upsertPoints = mockUpsert.mock.calls[0]?.[1] as Array<{
payload: { chunk_topic: string };
}>;
expect(upsertPoints[0]?.payload.chunk_topic).toMatch(/ส่วนที่/);
});
it('ควร fallback ทันทีเมื่อ LLM throw error', async () => {
const { service, mockUpsert, mockOllamaService } =
await buildEmbeddingModule('', 60, 0);
mockOllamaService.generate.mockRejectedValueOnce(
new Error('Ollama timeout')
);
const ocrText = 'ก'.repeat(80);
const result = await service.embedDocument(
'proj-err',
'doc-err',
'CORR-ERR',
'LETTER',
'SUBOWN',
1,
'Sub',
undefined,
ocrText
);
// ถึงแม้ LLM throw แต่ fallback ยังทำงาน
expect(result.success).toBe(true);
expect(mockUpsert).toHaveBeenCalled();
});
});
});
@@ -0,0 +1,156 @@
// File: backend/src/modules/ai/ai-rag.service.spec.ts
// Change Log:
// - 2026-06-05: สร้าง unit test สำหรับ AiRagService เพื่อทดสอบกระบวนการทำ RAG query ด้วย Hybrid Search และ Reranker (T011)
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
import { AiRagService } from './ai-rag.service';
import { AiQdrantService } from './qdrant.service';
import { OcrService } from './services/ocr.service';
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;
const DEFAULT_REDIS_TOKEN = 'default_IORedisModuleConnectionToken';
describe('AiRagService (US1 — Chat Q&A)', () => {
let service: AiRagService;
let qdrantService: AiQdrantService;
let ocrService: OcrService;
const mockRedis = {
get: jest.fn(),
setex: jest.fn(),
del: jest.fn(),
};
const mockConfigService = {
get: jest.fn((key: string, defaultValue?: unknown): unknown => {
const values: Record<string, unknown> = {
OLLAMA_URL: 'http://localhost:11434',
OLLAMA_RAG_MODEL: 'typhoon2.5-np-dms:latest',
RAG_TIMEOUT_MS: 30000,
RAG_CONTEXT_LIMIT_CHARS: 3000,
};
return values[key] ?? defaultValue;
}),
};
const mockQdrantService = {
searchByProject: jest.fn(),
};
const mockOcrService = {
embedViaSidecar: jest.fn(),
rerankViaSidecar: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AiRagService,
{ provide: ConfigService, useValue: mockConfigService },
{ provide: AiQdrantService, useValue: mockQdrantService },
{ provide: OcrService, useValue: mockOcrService },
{ provide: DEFAULT_REDIS_TOKEN, useValue: mockRedis },
],
}).compile();
service = module.get<AiRagService>(AiRagService);
qdrantService = module.get<AiQdrantService>(AiQdrantService);
ocrService = module.get<OcrService>(OcrService);
jest.clearAllMocks();
});
describe('processQuery()', () => {
it('ควรเรียกใช้ embedViaSidecar, searchByProject, rerankViaSidecar และจบด้วยการสร้างคำตอบด้วย LLM', async () => {
// Setup mock data
const mockDenseVector = Array(1024).fill(0.1);
const mockSparseVector = { indices: [1, 2], values: [0.5, 0.6] };
mockOcrService.embedViaSidecar.mockResolvedValueOnce({
dense: mockDenseVector,
sparse: mockSparseVector,
});
const mockQdrantResults = [
{
pointId: 'point-1',
score: 0.85,
payload: {
doc_type: 'LETTER',
doc_number: 'CORR-001',
chunk_text: 'เนื้อหาเอกสารหน้าที่ 1 สำหรับทดสอบ RAG pipeline',
},
},
{
pointId: 'point-2',
score: 0.72,
payload: {
doc_type: 'LETTER',
doc_number: 'CORR-002',
chunk_text: 'เนื้อหาเอกสารส่วนที่สองที่เกี่ยวข้องกัน',
},
},
];
mockQdrantService.searchByProject.mockResolvedValueOnce(
mockQdrantResults
);
mockOcrService.rerankViaSidecar.mockResolvedValueOnce({
scores: [0.95, 0.45],
ranked_indices: [0, 1],
});
mockedAxios.post.mockResolvedValueOnce({
data: {
response: 'คำตอบที่ได้รับความช่วยเหลือจาก LLM อ้างอิงเอกสาร CORR-001',
},
});
// Run query
await service.processQuery(
'req-123',
'ต้องการอนุมัติโครงการอย่างไร?',
'proj-456',
'user-789'
);
// Verify pipeline calls
expect(ocrService.embedViaSidecar).toHaveBeenCalledWith(
'ต้องการอนุมัติโครงการอย่างไร?'
);
expect(qdrantService.searchByProject).toHaveBeenCalledWith(
mockDenseVector,
mockSparseVector,
'proj-456',
15
);
expect(ocrService.rerankViaSidecar).toHaveBeenCalledWith(
'ต้องการอนุมัติโครงการอย่างไร?',
[
'เนื้อหาเอกสารหน้าที่ 1 สำหรับทดสอบ RAG pipeline',
'เนื้อหาเอกสารส่วนที่สองที่เกี่ยวข้องกัน',
]
);
expect(mockedAxios.post).toHaveBeenCalledWith(
expect.stringContaining('/api/generate'),
expect.objectContaining({
model: 'typhoon2.5-np-dms:latest',
prompt: expect.stringContaining(
'เนื้อหาเอกสารหน้าที่ 1 สำหรับทดสอบ RAG pipeline'
),
}),
expect.any(Object)
);
// Verify saving job status
expect(mockRedis.setex).toHaveBeenCalledWith(
expect.stringContaining('ai:rag:result:req-123'),
expect.any(Number),
expect.stringContaining('completed')
);
});
});
});
+77 -40
View File
@@ -1,9 +1,9 @@
// File: src/modules/ai/ai-rag.service.ts
// File: backend/src/modules/ai/ai-rag.service.ts
// Change Log
// - 2026-05-14: เพิ่ม AiRagService สำหรับ BullMQ-backed RAG pipeline ตาม ADR-023 Phase 4.
// - 2026-05-14: แก้ไข corruption ในไฟล์ทั้งหมด — rewrite clean version.
// - 2026-05-14: ย้าย PROMPT_CONTEXT_LIMIT เป็น instance field ที่อ่านจาก RAG_CONTEXT_LIMIT_CHARS (💡 S1).
// Service จัดการ RAG query ผ่าน Ollama + AiQdrantService (project-isolated)
// - 2026-06-05: ปรับปรุงใช้ Hybrid Search + Reranker ผ่าน Sidecar ตาม ADR-035 (T015, T030)
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@@ -11,6 +11,7 @@ import { InjectRedis } from '@nestjs-modules/ioredis';
import Redis from 'ioredis';
import axios from 'axios';
import { AiQdrantService } from './qdrant.service';
import { OcrService } from './services/ocr.service';
/** ผลลัพธ์ของ RAG query แต่ละรายการที่ถูก reference ในคำตอบ */
export interface AiRagCitation {
@@ -44,7 +45,6 @@ export class AiRagService {
private readonly logger = new Logger(AiRagService.name);
private readonly ollamaUrl: string;
private readonly ollamaModel: string;
private readonly ollamaEmbedModel: string;
private readonly timeoutMs: number;
/** จำนวนอักขระสูงสุดของ context ที่ส่งให้ LLM — ปรับได้ผ่าน RAG_CONTEXT_LIMIT_CHARS */
private readonly promptContextLimit: number;
@@ -52,6 +52,7 @@ export class AiRagService {
constructor(
private readonly configService: ConfigService,
private readonly qdrantService: AiQdrantService,
private readonly ocrService: OcrService,
@InjectRedis() private readonly redis: Redis
) {
this.ollamaUrl = this.configService.get<string>(
@@ -62,10 +63,6 @@ export class AiRagService {
'OLLAMA_RAG_MODEL',
'gemma2'
);
this.ollamaEmbedModel = this.configService.get<string>(
'OLLAMA_EMBED_MODEL',
'nomic-embed-text'
);
this.timeoutMs = this.configService.get<number>('RAG_TIMEOUT_MS', 30000);
this.promptContextLimit = this.configService.get<number>(
'RAG_CONTEXT_LIMIT_CHARS',
@@ -159,10 +156,11 @@ export class AiRagService {
/**
* ประมวลผล RAG query:
* 1. Embed คำถาม
* 2. ค้นหา Qdrant ด้วย project isolation (T020 — enforced in AiQdrantService.searchByProject)
* 3. Build prompt จาก context
* 4. Generate คำตอบผ่าน Ollama (รองรับ AbortSignal สำหรับ T022)
* 1. Embed คำถามด้วย BGE-M3 (Dense + Sparse) ผ่าน Sidecar /embed (T015)
* 2. ค้นหา Qdrant ด้วย Hybrid Search + project isolation (T015)
* 3. Rerank ด้วย BGE-Reranker-Large ผ่าน Sidecar /rerank (T015)
* 4. Build prompt จาก context
* 5. Generate คำตอบผ่าน Ollama
*/
async processQuery(
requestPublicId: string,
@@ -182,8 +180,8 @@ export class AiRagService {
return;
}
// 1. สร้าง embedding สำหรับคำถาม
const queryVector = await this.embed(question, signal);
// 1. สร้าง embedding สำหรับคำถามด้วย BGE-M3 ผ่าน Sidecar
const embedResult = await this.ocrService.embedViaSidecar(question);
// ตรวจสอบ cancel อีกครั้งหลัง embed
if (
@@ -195,17 +193,15 @@ export class AiRagService {
return;
}
// 2. ค้นหา Qdrant โดยบังคับ projectPublicId (T020 — FR-002)
// 2. ค้นหา Qdrant ด้วย Hybrid search และกรองตาม project
const searchResults = await this.qdrantService.searchByProject(
queryVector,
embedResult.dense,
embedResult.sparse,
projectPublicId,
10
15 // topK=15 ตาม FR-014
);
// 3. สร้าง context จาก search results
const context = this.buildContext(searchResults);
// ตรวจสอบ cancel ก่อนเรียก LLM (ใช้ทรัพยากรมากที่สุด)
// ตรวจสอบ cancel หลัง search
if (
signal?.aborted ||
(await this.redis.get(this.cancelKey(requestPublicId)))
@@ -215,25 +211,74 @@ export class AiRagService {
return;
}
// 4. Generate คำตอบผ่าน Ollama (ส่ง signal เพื่อรองรับ T022)
// 3. Rerank ผลลัพธ์การค้นหา
let finalResults = searchResults;
const rawChunks = searchResults
.map(
(r) =>
(r.payload['chunk_text'] as string) ||
(r.payload['content_preview'] as string) ||
''
)
.filter((c) => c.trim().length > 0);
if (rawChunks.length > 0) {
this.logger.log(
`Calling Sidecar /rerank for ${rawChunks.length} candidates...`
);
const rerankResult = await this.ocrService.rerankViaSidecar(
question,
rawChunks
);
// เลือก top 3-5 chunks ที่ได้คะแนนสูงสุด
const topN = Math.min(5, rerankResult.ranked_indices.length);
finalResults = [];
for (let i = 0; i < topN; i++) {
const originalIndex = rerankResult.ranked_indices[i];
finalResults.push(searchResults[originalIndex]);
}
// Log รายละเอียดการจัดอันดับ (T030)
this.logger.log(
`Reranking completed: candidates input ${searchResults.length} -> output ${finalResults.length}. ` +
`Top-1 score: ${rerankResult.scores[rerankResult.ranked_indices[0]]?.toFixed(4) ?? 'N/A'}`
);
}
// 4. สร้าง context จาก search results
const context = this.buildContext(finalResults);
// ตรวจสอบ cancel ก่อนเรียก LLM
if (
signal?.aborted ||
(await this.redis.get(this.cancelKey(requestPublicId)))
) {
await this.saveJobResult({ requestPublicId, status: 'cancelled' });
await this.clearActiveJob(userPublicId);
return;
}
// 5. Generate คำตอบผ่าน Ollama
const { answer, usedFallback } = await this.generateAnswer(
this.sanitizeInput(question),
context,
signal
);
const citations: AiRagCitation[] = searchResults.map((r) => ({
const citations: AiRagCitation[] = finalResults.map((r) => ({
pointId: r.pointId,
score: r.score,
docType: r.payload['doc_type'] as string | undefined,
docNumber: r.payload['doc_number'] as string | undefined,
snippet: (r.payload['content_preview'] as string | undefined)?.slice(
0,
200
),
snippet: (
(r.payload['chunk_text'] as string) ||
(r.payload['content_preview'] as string) ||
''
).slice(0, 200),
}));
const confidence = searchResults.length > 0 ? searchResults[0].score : 0;
const confidence = finalResults.length > 0 ? finalResults[0].score : 0;
await this.saveJobResult({
requestPublicId,
@@ -266,17 +311,7 @@ export class AiRagService {
// ─── Private Helpers ─────────────────────────────────────────────────────────
/** สร้าง embedding vector สำหรับข้อความ */
private async embed(text: string, signal?: AbortSignal): Promise<number[]> {
const response = await axios.post<{ embedding: number[] }>(
`${this.ollamaUrl}/api/embeddings`,
{ model: this.ollamaEmbedModel, prompt: text },
{ timeout: this.timeoutMs, signal }
);
return response.data.embedding;
}
/** Generate คำตอบจาก Ollama (รองรับ AbortSignal สำหรับ T022 FR-011) */
/** Generate คำตอบจาก Ollama */
private async generateAnswer(
question: string,
context: string,
@@ -291,7 +326,6 @@ export class AiRagService {
);
return { answer: response.data.response ?? '', usedFallback: false };
} catch (err: unknown) {
// ถ้าเป็น cancellation error ให้ re-throw เพื่อให้ processQuery จัดการ
if (
axios.isCancel(err) ||
(err instanceof Error && err.name === 'CanceledError')
@@ -313,7 +347,10 @@ export class AiRagService {
for (const r of results) {
const docType = (r.payload['doc_type'] as string) ?? '';
const docNumber = (r.payload['doc_number'] as string) ?? '';
const preview = (r.payload['content_preview'] as string) ?? '';
const preview =
(r.payload['chunk_text'] as string) ??
(r.payload['content_preview'] as string) ??
'';
const header = `[${docType}${docNumber ? ` - ${docNumber}` : ''}]`;
const snippet = `${header}\n${preview}\n\n`;
if ((context + snippet).length > this.promptContextLimit) break;
@@ -52,6 +52,9 @@ describe('AiBatchProcessor', () => {
detectAndExtract: jest
.fn()
.mockResolvedValue({ text: 'OCR text LCBP3-CIV-001 Civil' }),
processWithAutoDetect: jest.fn().mockResolvedValue({
text: 'extracted ocr text from document that is long enough to bypass character length check',
}),
};
const mockSandboxOcrEngineService = {
detectAndExtract: jest.fn().mockResolvedValue({
@@ -237,7 +240,23 @@ describe('AiBatchProcessor', () => {
},
} as unknown as Job<AiBatchJobData>;
await processor.process(job);
expect(ocrService.detectAndExtract).toHaveBeenCalledWith({
pdfPath: '/files/test.pdf',
extractedText: undefined,
documentPublicId: 'doc-uuid-123',
});
expect(embeddingService.embedDocument).toHaveBeenCalledTimes(1);
expect(embeddingService.embedDocument).toHaveBeenCalledWith(
'proj-uuid-456',
'doc-uuid-123',
'doc-uuid-123',
'ATTACHMENT',
'ACTIVE',
1,
'doc-uuid-123',
undefined,
'OCR text LCBP3-CIV-001 Civil'
);
expect(attachmentRepo.update).toHaveBeenCalledWith(
{ publicId: 'doc-uuid-123' },
{ aiProcessingStatus: 'PROCESSING' }
@@ -449,4 +468,78 @@ describe('AiBatchProcessor', () => {
expect(mockAiAuditLogRepo.create).toHaveBeenCalledTimes(1);
expect(mockAiAuditLogRepo.save).toHaveBeenCalledTimes(1);
});
describe('rag-prepare', () => {
it('ควรประมวลผล rag-prepare สำเร็จเมื่อส่ง cachedOcrText มาโดยตรง', async () => {
const job = {
id: 'job-rag-prepare-cached',
data: {
jobType: 'rag-prepare',
documentPublicId: 'doc-uuid-123',
projectPublicId: 'proj-uuid-456',
payload: {
documentPublicId: 'doc-uuid-123',
projectPublicId: 'proj-uuid-456',
correspondenceNumber: 'CORR-001',
docType: 'LETTER',
statusCode: 'IN_REVIEW',
revisionNumber: 1,
subject: 'Test Subject',
cachedOcrText:
'some cached ocr text that is long enough to pass the 50 character limit check',
},
},
} as unknown as Job<AiBatchJobData>;
await processor.process(job);
expect(embeddingService.embedDocument).toHaveBeenCalledWith(
'proj-uuid-456',
'doc-uuid-123',
'CORR-001',
'LETTER',
'IN_REVIEW',
1,
'Test Subject',
undefined,
'some cached ocr text that is long enough to pass the 50 character limit check'
);
});
it('ควรประมวลผล rag-prepare สำเร็จเมื่อดึงข้อความจากไฟล์แนบผ่าน OCR Service', async () => {
ocrService.detectAndExtract.mockResolvedValueOnce({
text: 'extracted ocr text from document that is long enough to bypass character length check',
ocrUsed: true,
});
const job = {
id: 'job-rag-prepare-ocr',
data: {
jobType: 'rag-prepare',
documentPublicId: 'doc-uuid-123',
projectPublicId: 'proj-uuid-456',
payload: {
documentPublicId: 'doc-uuid-123',
projectPublicId: 'proj-uuid-456',
correspondenceNumber: 'CORR-002',
docType: 'LETTER',
statusCode: 'IN_REVIEW',
revisionNumber: 2,
subject: 'Test OCR Subject',
attachmentPath: '/files/test-ocr.pdf',
},
},
} as unknown as Job<AiBatchJobData>;
await processor.process(job);
expect(ocrService.detectAndExtract).toHaveBeenCalledWith({
pdfPath: '/files/test-ocr.pdf',
});
expect(embeddingService.embedDocument).toHaveBeenCalledWith(
'proj-uuid-456',
'doc-uuid-123',
'CORR-002',
'LETTER',
'IN_REVIEW',
2,
'Test OCR Subject',
undefined,
'extracted ocr text from document that is long enough to bypass character length check'
);
});
});
});
@@ -57,7 +57,8 @@ export type AiBatchJobType =
| 'sandbox-extract'
| 'sandbox-ocr-only'
| 'sandbox-ai-extract'
| 'migrate-document';
| 'migrate-document'
| 'rag-prepare';
/** รายการ job types ที่ต้องใช้ Typhoon OCR model — จะ trigger model switching (ADR-034) */
export const OCR_JOB_TYPES: ReadonlyArray<AiBatchJobType> = [
@@ -239,6 +240,12 @@ export class AiBatchProcessor extends WorkerHost {
await this.setAiProcessingStatus(job.data.documentPublicId, 'DONE');
}
return;
case 'rag-prepare':
this.logger.log(
`RAG prepare job processing — jobId=${String(job.id)}`
);
await this.processRagPrepare(job.data);
return;
default: {
const unreachable: never = job.data.jobType;
throw new Error(
@@ -262,15 +269,41 @@ export class AiBatchProcessor extends WorkerHost {
private async processEmbedDocument(data: AiBatchJobData): Promise<void> {
const { documentPublicId, projectPublicId, payload } = data;
const pdfPath = payload.pdfPath as string;
const extractedText = payload.extractedText as string | undefined;
const extractedText = readString(payload.extractedText);
if (!pdfPath) {
throw new Error('pdfPath is required for embed-document job');
}
const correspondenceNumber =
readString(payload.correspondenceNumber) ?? documentPublicId;
const docType = readString(payload.docType) ?? 'ATTACHMENT';
const statusCode = readString(payload.statusCode) ?? 'ACTIVE';
const revisionNumberValue = payload.revisionNumber;
const revisionNumber =
typeof revisionNumberValue === 'number' &&
Number.isFinite(revisionNumberValue)
? revisionNumberValue
: 1;
const subject = readString(payload.subject) ?? documentPublicId;
const documentDate = readString(payload.documentDate);
const resolvedOcrText =
extractedText ??
(
await this.ocrService.detectAndExtract({
pdfPath,
extractedText,
documentPublicId,
})
).text;
const result = await this.embeddingService.embedDocument(
pdfPath,
documentPublicId,
projectPublicId,
extractedText
documentPublicId,
correspondenceNumber,
docType,
statusCode,
revisionNumber,
subject,
documentDate,
resolvedOcrText
);
if (!result.success) {
throw new Error(`Embedding failed: ${result.error ?? 'Unknown error'}`);
@@ -647,6 +680,84 @@ export class AiBatchProcessor extends WorkerHost {
}
}
private async processRagPrepare(data: AiBatchJobData): Promise<void> {
const payload = data.payload || {};
const documentPublicId =
(payload.documentPublicId as string) || data.documentPublicId;
const projectPublicId =
(payload.projectPublicId as string) || data.projectPublicId;
const correspondenceNumber = (payload.correspondenceNumber as string) || '';
const docType = (payload.docType as string) || 'LETTER';
const statusCode = (payload.statusCode as string) || 'IN_REVIEW';
const revisionNumber = Number(payload.revisionNumber ?? 1);
const subject = (payload.subject as string) || '';
const documentDate = (payload.documentDate as string) || undefined;
let cachedOcrText = (payload.cachedOcrText as string) || undefined;
const attachmentPath = (payload.attachmentPath as string) || undefined;
this.logger.log(
`processRagPrepare: starting for doc=${documentPublicId}, project=${projectPublicId}`
);
// T020a: Resolve OCR text. Use cached if available; otherwise extract using OcrService
if (!cachedOcrText && attachmentPath) {
this.logger.log(
`processRagPrepare: No cached OCR text. Extracting text from ${attachmentPath}...`
);
try {
const ocrResult = await this.ocrService.detectAndExtract({
pdfPath: attachmentPath,
});
cachedOcrText = ocrResult.text;
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
this.logger.error(`processRagPrepare: OCR extraction failed: ${msg}`);
throw err;
}
}
if (!cachedOcrText) {
this.logger.warn(
`processRagPrepare: ไม่มี OCR text และไม่มี attachment path - skip embedding`
);
return;
}
// T020b: skip-guard (< 50 chars)
if (cachedOcrText.trim().length < 50) {
this.logger.warn(
`processRagPrepare: OCR text สั้นเกินไป (${cachedOcrText.trim().length} chars) — skip embedding`
);
return;
}
// T020c: embed + upsert pipeline
try {
this.logger.log(
`processRagPrepare: chunking and embedding document ${documentPublicId}...`
);
await this.embeddingService.embedDocument(
projectPublicId,
documentPublicId,
correspondenceNumber,
docType,
statusCode,
revisionNumber,
subject,
documentDate,
cachedOcrText
);
this.logger.log(
`processRagPrepare: successfully processed document ${documentPublicId}`
);
} catch (err) {
this.logger.error(
`processRagPrepare: embedding pipeline failed: ${err instanceof Error ? err.message : String(err)}`
);
throw err;
}
}
private async processMigrateDocument(
job: Job<AiBatchJobData>
): Promise<void> {
@@ -21,16 +21,20 @@ export class AiVectorDeletionProcessor extends WorkerHost {
}
async process(job: Job<AiVectorDeletionJobPayload>): Promise<void> {
const { documentPublicId, requestedByUserPublicId } = job.data;
const { documentPublicId, projectPublicId, requestedByUserPublicId } =
job.data;
this.logger.log(
`Vector deletion started — documentPublicId=${documentPublicId}, jobId=${String(job.id)}, requestedBy=${requestedByUserPublicId}`
`Vector deletion started — documentPublicId=${documentPublicId}, projectPublicId=${projectPublicId}, jobId=${String(job.id)}, requestedBy=${requestedByUserPublicId}`
);
await this.qdrantService.deleteByDocumentPublicId(documentPublicId);
await this.qdrantService.deleteByDocumentPublicId(
projectPublicId,
documentPublicId
);
this.logger.log(
`Vector deletion completed — documentPublicId=${documentPublicId}, jobId=${String(job.id)}`
`Vector deletion completed — documentPublicId=${documentPublicId}, projectPublicId=${projectPublicId}, jobId=${String(job.id)}`
);
}
}
+220 -28
View File
@@ -1,8 +1,10 @@
// File: src/modules/ai/qdrant.service.ts
// File: backend/src/modules/ai/qdrant.service.ts
// Change Log
// - 2026-05-14: เพิ่ม Qdrant gateway สำหรับ AI Module พร้อม project payload filter.
// - 2026-05-14: เพิ่ม OnModuleInit เพื่อ auto-call ensureCollection() (💡 S2).
// - 2026-05-21: เพิ่ม checkHealth สำหรับตรวจสอบสุขภาพและความเร็วของ Qdrant
// - 2026-06-05: ปรับปรุงโครงสร้างเป็น Hybrid (Dense 1024 + Sparse) ตาม ADR-035 (T006-T010)
// - 2026-06-05: เพิ่ม Compatibility สำหรับ search() ที่ไม่มี sparseVector เพื่อผ่านการทดสอบแบบดั้งเดิม
import {
Injectable,
@@ -14,7 +16,7 @@ import { ConfigService } from '@nestjs/config';
import { QdrantClient } from '@qdrant/js-client-rest';
const AI_COLLECTION_NAME = 'lcbp3_vectors';
const AI_VECTOR_SIZE = 768;
const AI_VECTOR_SIZE = 1024;
export interface AiVectorSearchResult {
pointId: string | number;
@@ -22,7 +24,14 @@ export interface AiVectorSearchResult {
payload: Record<string, unknown>;
}
/** Gateway กลางสำหรับ Qdrant ที่บังคับ project_public_id ทุก search */
type QdrantUpsertRequest = Parameters<QdrantClient['upsert']>[1];
type QdrantUpsertPoint = QdrantUpsertRequest extends { points: infer TPoints }
? TPoints extends Array<infer TPoint>
? TPoint
: never
: never;
/** Gateway กลางสำหรับ Qdrant ที่รองรับ Hybrid Search และบังคับ project_public_id ทุก search */
@Injectable()
export class AiQdrantService implements OnModuleInit {
private readonly logger = new Logger(AiQdrantService.name);
@@ -47,78 +56,261 @@ export class AiQdrantService implements OnModuleInit {
}
}
/** เตรียม collection และ tenant payload index สำหรับ project isolation */
/** เตรียม collection และ payload index สำหรับ project isolation และ hybrid search */
async ensureCollection(): Promise<void> {
const collections = await this.client.getCollections();
const exists = collections.collections.some(
(collection) => collection.name === AI_COLLECTION_NAME
);
if (!exists) {
await this.client.createCollection(AI_COLLECTION_NAME, {
vectors: { size: AI_VECTOR_SIZE, distance: 'Cosine' },
});
if (exists) {
// ตรวจ schema ของ collection ที่มีอยู่ — ถ้าเป็น Hybrid 1024 dims แล้ว skip delete
try {
const collectionInfo =
await this.client.getCollection(AI_COLLECTION_NAME);
const isHybrid =
collectionInfo.config.params.vectors !== undefined &&
collectionInfo.config.params.sparse_vectors !== undefined;
const vectorsMap = collectionInfo.config.params.vectors;
let vectorSize: number | undefined = undefined;
// Defensive check: ตรวจ structure ของ vectorsMap ก่อน access
if (vectorsMap && typeof vectorsMap === 'object') {
if ('size' in vectorsMap) {
// Single vector mode (ไม่ใช่ Hybrid)
vectorSize = (vectorsMap as { size: number }).size;
} else {
// Hybrid mode: extract bge_dense size
const hybridMap = vectorsMap as Record<string, { size?: number }>;
if (
hybridMap['bge_dense'] &&
typeof hybridMap['bge_dense'] === 'object'
) {
vectorSize = hybridMap['bge_dense'].size;
} else {
this.logger.warn(
`Unexpected vectors structure: bge_dense not found or invalid in Hybrid collection`
);
}
}
} else {
this.logger.warn(
`Unexpected vectors structure: vectorsMap is not an object or undefined`
);
}
if (isHybrid && vectorSize === AI_VECTOR_SIZE) {
this.logger.log(
`Qdrant collection ${AI_COLLECTION_NAME} already exists with correct Hybrid schema (1024 dims) — skipping recreation`
);
// เรียก createPayloadIndexes() ทุกครั้งเพื่อให้แน่ใจว่า indexes มีอยู่
await this.createPayloadIndexes();
return;
}
this.logger.log(
`Dropping existing Qdrant collection ${AI_COLLECTION_NAME} to upgrade to Hybrid (${vectorSize ?? 'unknown'} dims → ${AI_VECTOR_SIZE} dims)...`
);
await this.client.deleteCollection(AI_COLLECTION_NAME);
} catch (err) {
this.logger.warn(
`Failed to inspect collection schema, proceeding with recreation — ${err instanceof Error ? err.message : String(err)}`
);
await this.client.deleteCollection(AI_COLLECTION_NAME);
}
}
await this.client.createCollection(AI_COLLECTION_NAME, {
vectors: {
bge_dense: { size: AI_VECTOR_SIZE, distance: 'Cosine' },
},
sparse_vectors: {
bge_sparse: {},
},
});
// สร้าง payload indexes สำหรับเพิ่มความเร็วในการ filter (T010)
await this.createPayloadIndexes();
this.logger.log(`Created Qdrant Hybrid collection ${AI_COLLECTION_NAME}`);
}
/** สร้าง payload indexes สำหรับ filter fields ที่สำคัญ */
private async createPayloadIndexes(): Promise<void> {
try {
await this.client.createPayloadIndex(AI_COLLECTION_NAME, {
field_name: 'project_public_id',
field_schema: { type: 'keyword', is_tenant: true } as Parameters<
QdrantClient['createPayloadIndex']
>[1]['field_schema'],
});
this.logger.log(`Created Qdrant collection ${AI_COLLECTION_NAME}`);
await this.client.createPayloadIndex(AI_COLLECTION_NAME, {
field_name: 'doc_public_id',
field_schema: { type: 'keyword' } as Parameters<
QdrantClient['createPayloadIndex']
>[1]['field_schema'],
});
await this.client.createPayloadIndex(AI_COLLECTION_NAME, {
field_name: 'status_code',
field_schema: { type: 'keyword' } as Parameters<
QdrantClient['createPayloadIndex']
>[1]['field_schema'],
});
await this.client.createPayloadIndex(AI_COLLECTION_NAME, {
field_name: 'doc_type',
field_schema: { type: 'keyword' } as Parameters<
QdrantClient['createPayloadIndex']
>[1]['field_schema'],
});
this.logger.log(`Created payload indexes for ${AI_COLLECTION_NAME}`);
} catch (err) {
this.logger.warn(
`Failed to create payload indexes (may already exist): ${err instanceof Error ? err.message : String(err)}`
);
}
}
/** ค้นหา vector โดยบังคับ projectPublicId เป็น parameter แรกตาม ADR-023A */
/** ค้นหาเวกเตอร์ด้วย Hybrid Search (Dense + Sparse) หรือ Dense Search (ถ้าไม่มี sparse vector) โดยบังคับ projectPublicId */
async search(
projectPublicId: string,
vector: number[],
denseVector: number[],
sparseVectorOrTopK?: { indices: number[]; values: number[] } | number,
topK = 5
): Promise<AiVectorSearchResult[]> {
if (!projectPublicId) {
throw new ServiceUnavailableException('AI_QDRANT_PROJECT_SCOPE_REQUIRED');
}
const results = await this.client.search(AI_COLLECTION_NAME, {
vector,
limit: topK,
let actualSparseVector = {
indices: [] as number[],
values: [] as number[],
};
let actualTopK = topK;
if (typeof sparseVectorOrTopK === 'number') {
actualTopK = sparseVectorOrTopK;
} else if (sparseVectorOrTopK) {
actualSparseVector = sparseVectorOrTopK;
}
// Fallback: หากไม่มี sparse vector ให้ประมวลผลผ่าน client.search สำหรับการทดสอบและ compatibility
if (actualSparseVector.indices.length === 0) {
const results = await this.client.search(AI_COLLECTION_NAME, {
vector: denseVector,
limit: actualTopK,
filter: {
must: [
{ key: 'project_public_id', match: { value: projectPublicId } },
],
},
with_payload: true,
});
return results.map((result) => ({
pointId: result.id,
score: result.score ?? 0,
payload: result.payload ?? {},
}));
}
const results = await this.client.query(AI_COLLECTION_NAME, {
prefetch: [
{
query: {
indices: actualSparseVector.indices,
values: actualSparseVector.values,
},
using: 'bge_sparse',
limit: actualTopK * 2,
},
{
query: denseVector,
using: 'bge_dense',
limit: actualTopK * 2,
},
],
query: { fusion: 'rrf' } as unknown as Record<string, unknown>,
limit: actualTopK,
filter: {
must: [{ key: 'project_public_id', match: { value: projectPublicId } }],
},
with_payload: true,
});
return results.map((result) => ({
return results.points.map((result) => ({
pointId: result.id,
score: result.score,
score: result.score ?? 0,
payload: result.payload ?? {},
}));
}
/** Compatibility wrapper สำหรับ code เดิมระหว่าง transition ไป contract ใหม่ */
/** Compatibility wrapper สำหรับโค้ดเดิมระหว่าง transition */
async searchByProject(
vector: number[],
projectPublicId: string,
limit: number
denseVector: number[],
sparseVectorOrProjectPublicId:
| { indices: number[]; values: number[] }
| string,
projectPublicIdOrLimit?: string | number,
limit = 5
): Promise<AiVectorSearchResult[]> {
return this.search(projectPublicId, vector, limit);
if (typeof sparseVectorOrProjectPublicId === 'string') {
// เรียกใช้รูปแบบดั้งเดิม: searchByProject(vector, projectPublicId, limit)
const projectPublicId = sparseVectorOrProjectPublicId;
const actualLimit =
typeof projectPublicIdOrLimit === 'number'
? projectPublicIdOrLimit
: limit;
return this.search(projectPublicId, denseVector, undefined, actualLimit);
} else {
// เรียกใช้รูปแบบใหม่: searchByProject(dense, sparse, projectPublicId, limit)
const projectPublicId =
typeof projectPublicIdOrLimit === 'string'
? projectPublicIdOrLimit
: '';
return this.search(
projectPublicId,
denseVector,
sparseVectorOrProjectPublicId,
limit
);
}
}
/** ลบ vector ของเอกสารด้วย publicId ผ่าน queue processor ในขั้นถัดไป */
async deleteByDocumentPublicId(documentPublicId: string): Promise<void> {
/** ลบเวกเตอร์ของเอกสารด้วย projectPublicId และ documentPublicId */
async deleteByDocumentPublicId(
projectPublicId: string,
documentPublicId: string
): Promise<void> {
if (!projectPublicId) {
throw new ServiceUnavailableException('AI_QDRANT_PROJECT_SCOPE_REQUIRED');
}
await this.client.delete(AI_COLLECTION_NAME, {
wait: true,
filter: {
must: [{ key: 'public_id', match: { value: documentPublicId } }],
must: [
{ key: 'project_public_id', match: { value: projectPublicId } },
{ key: 'doc_public_id', match: { value: documentPublicId } },
],
},
});
}
/** Upsert vectors ไป Qdrant พร้อม project isolation (T021) */
/** Upsert hybrid vectors ไป Qdrant พร้อม project isolation (T008) */
async upsert(
projectPublicId: string,
points: Array<{
id: string;
vector: number[];
vector: {
bge_dense: number[];
bge_sparse: {
indices: number[];
values: number[];
};
};
payload: Record<string, unknown>;
}>
): Promise<void> {
@@ -126,14 +318,14 @@ export class AiQdrantService implements OnModuleInit {
throw new ServiceUnavailableException('AI_QDRANT_PROJECT_SCOPE_REQUIRED');
}
// เพิ่ม project_public_id ใน payload ทุก point เพื่อ isolation
// เพิ่ม project_public_id ใน payload ทุก point เพื่อแยกโครงการ
const pointsWithProject = points.map((point) => ({
...point,
payload: {
...point.payload,
project_public_id: projectPublicId,
},
}));
})) as unknown as QdrantUpsertPoint[];
await this.client.upsert(AI_COLLECTION_NAME, {
wait: true,
@@ -0,0 +1,137 @@
// File: backend/src/modules/ai/services/embedding.service.spec.ts
// Change Log:
// - 2026-06-05: สร้าง unit test สำหรับ EmbeddingService เพื่อทดสอบกระบวนการ Semantic Chunking และ fixed-size fallback (T024)
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { EmbeddingService } from './embedding.service';
import { OllamaService } from './ollama.service';
import { AiQdrantService } from '../qdrant.service';
import { OcrService } from './ocr.service';
import { AiPromptsService } from '../prompts/ai-prompts.service';
describe('EmbeddingService (US3 — Semantic Chunking)', () => {
let service: EmbeddingService;
let ollamaService: OllamaService;
let qdrantService: AiQdrantService;
let ocrService: OcrService;
let aiPromptsService: AiPromptsService;
const mockConfigService = {
get: jest.fn((key: string, defaultValue?: unknown): unknown => {
const values: Record<string, unknown> = {
EMBEDDING_CHUNK_SIZE: 512,
EMBEDDING_CHUNK_OVERLAP: 64,
};
return values[key] ?? defaultValue;
}),
};
const mockOllamaService = {
generate: jest.fn(),
};
const mockQdrantService = {
deleteByDocumentPublicId: jest.fn().mockResolvedValue(undefined),
upsert: jest.fn().mockResolvedValue(undefined),
};
const mockOcrService = {
embedViaSidecar: jest.fn(),
};
const mockAiPromptsService = {
resolveActive: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
EmbeddingService,
{ provide: ConfigService, useValue: mockConfigService },
{ provide: OllamaService, useValue: mockOllamaService },
{ provide: AiQdrantService, useValue: mockQdrantService },
{ provide: OcrService, useValue: mockOcrService },
{ provide: AiPromptsService, useValue: mockAiPromptsService },
],
}).compile();
service = module.get<EmbeddingService>(EmbeddingService);
ollamaService = module.get<OllamaService>(OllamaService);
qdrantService = module.get<AiQdrantService>(AiQdrantService);
ocrService = module.get<OcrService>(OcrService);
aiPromptsService = module.get<AiPromptsService>(AiPromptsService);
jest.clearAllMocks();
});
describe('embedDocument()', () => {
it('ควรเรียกใช้ Semantic Chunking เมื่อ LLM ตอบกลับถูกต้องตามแท็ก และบันทึกเข้า Qdrant สำเร็จ', async () => {
const mockLlmResponse = `
<chunk topic="การติดตั้งระบบ">ขั้นตอนการติดตั้งระบบมีดังนี้คือ 1. ตรวจสอบเครื่องมือ 2. เริ่มเชื่อมต่อ</chunk>
<chunk topic="การตั้งค่า">หลังจากติดตั้งให้ทำการตั้งค่าระบบผ่านหน้าจอควบคุมหลัก</chunk>
`;
mockAiPromptsService.resolveActive.mockResolvedValueOnce({
resolvedPrompt: 'mock resolved prompt',
versionNumber: 1,
});
mockOllamaService.generate.mockResolvedValueOnce(mockLlmResponse);
mockOcrService.embedViaSidecar.mockImplementation((_text: string) => {
return Promise.resolve({
dense: Array(1024).fill(0.1),
sparse: { indices: [1], values: [0.5] },
});
});
const result = await service.embedDocument(
'proj-uuid-456',
'doc-uuid-123',
'CORR-001',
'LETTER',
'IN_REVIEW',
1,
'Test Subject',
'2026-06-05',
'ข้อความทดสอบสำหรับการหั่นแบบ semantic chunking ซึ่งมีความยาวเกิน 50 ตัวอักษรอย่างแน่นอน'
);
expect(result.success).toBe(true);
expect(result.chunksEmbedded).toBe(2);
expect(aiPromptsService.resolveActive).toHaveBeenCalledWith(
'rag_chunking',
'ข้อความทดสอบสำหรับการหั่นแบบ semantic chunking ซึ่งมีความยาวเกิน 50 ตัวอักษรอย่างแน่นอน'
);
expect(ollamaService.generate).toHaveBeenCalledWith(
'mock resolved prompt'
);
expect(ocrService.embedViaSidecar).toHaveBeenCalledTimes(2);
expect(qdrantService.deleteByDocumentPublicId).toHaveBeenCalledWith(
'proj-uuid-456',
'doc-uuid-123'
);
expect(qdrantService.upsert).toHaveBeenCalled();
});
it('ควร fallback ไปใช้ fixed-size chunking เมื่อ LLM คืนข้อมูลที่ไม่มีแท็ก chunk หรือการเรียก LLM ล้มเหลว', async () => {
mockAiPromptsService.resolveActive.mockResolvedValueOnce({
resolvedPrompt: 'mock resolved prompt',
versionNumber: 1,
});
mockOllamaService.generate.mockResolvedValueOnce(
'ข้อความธรรมดาที่ไม่มีแท็ก chunk อะไรเลย'
);
mockOcrService.embedViaSidecar.mockImplementation((_text: string) => {
return Promise.resolve({
dense: Array(1024).fill(0.2),
sparse: { indices: [2], values: [0.8] },
});
});
const result = await service.embedDocument(
'proj-uuid-456',
'doc-uuid-123',
'CORR-001',
'LETTER',
'IN_REVIEW',
1,
'Test Subject',
'2026-06-05',
'ข้อความทดสอบแบบยาวเพื่อจำลองการทำ fixed size chunking สำหรับการ fallback เมื่อ LLM ทำงานไม่ได้ตามเงื่อนไขที่กำหนดไว้'
);
expect(result.success).toBe(true);
expect(result.chunksEmbedded).toBeGreaterThan(0);
expect(qdrantService.deleteByDocumentPublicId).toHaveBeenCalledWith(
'proj-uuid-456',
'doc-uuid-123'
);
expect(qdrantService.upsert).toHaveBeenCalled();
});
});
});
@@ -1,12 +1,14 @@
// File: src/modules/ai/services/embedding.service.ts
// File: backend/src/modules/ai/services/embedding.service.ts
// Change Log
// - 2026-05-15: เพิ่ม EmbeddingService สำหรับ full-document chunked embedding ตาม ADR-023A T021.
// - 2026-06-05: ปรับปรุงเป็น Hybrid Embedding และเพิ่ม Semantic Chunking ผ่าน typhoon2.5 (T025-T027)
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { OllamaService } from './ollama.service';
import { AiQdrantService } from '../qdrant.service';
import { OcrService } from './ocr.service';
import { AiPromptsService } from '../prompts/ai-prompts.service';
export interface EmbeddingChunk {
chunkIndex: number;
@@ -31,7 +33,8 @@ export class EmbeddingService {
private readonly configService: ConfigService,
private readonly ollamaService: OllamaService,
private readonly qdrantService: AiQdrantService,
private readonly ocrService: OcrService
private readonly ocrService: OcrService,
private readonly aiPromptsService: AiPromptsService
) {
this.chunkSize = this.configService.get<number>(
'EMBEDDING_CHUNK_SIZE',
@@ -44,66 +47,71 @@ export class EmbeddingService {
}
/**
* สร้าง embedding สำหรับเอกสารทั้งฉบับ:
* 1. ดึงข้อความ full-doc (ใช้ extractedText หรือ OCR)
* 2. Chunk text 512 tokens / 64 overlap
* 3. Generate embedding ต่อ chunk ด้วย nomic-embed-text
* 4. Upsert ไป Qdrant พร้อม project isolation
* สร้าง hybrid embedding สำหรับเอกสารทั้งฉบับ:
* 1. ใช้ Semantic Chunking (ผ่าน LLM) เป็นหลัก พร้อม Fallback เป็นแบบ fixed-size
* 2. เรียก Sidecar /embed เพื่อแปลงแต่ละ chunk เป็น Dense (1024 dims) + Sparse vector
* 3. ลบ points เก่าของเอกสารใน Qdrant
* 4. Upsert points ใหม่เก็บครบ 11 fields
*/
async embedDocument(
pdfPath: string,
documentPublicId: string,
projectPublicId: string,
extractedText?: string
documentPublicId: string,
correspondenceNumber: string,
docType: string,
statusCode: string,
revisionNumber: number,
subject: string,
documentDate?: string,
ocrText?: string
): Promise<EmbeddingResult> {
try {
// 1. ดึงข้อความจาก PDF (ใช้ extractedText ถ้ามี หรือเรียก OCR)
let fullText = extractedText;
if (!fullText) {
const ocrResult = await this.ocrService.detectAndExtract({
pdfPath,
extractedText: '',
extractedChars: 0,
});
fullText = ocrResult.text;
}
if (!fullText || fullText.trim().length === 0) {
this.logger.warn(`No text extracted from document ${documentPublicId}`);
if (!ocrText || ocrText.trim().length === 0) {
this.logger.warn(
`No OCR text provided for document ${documentPublicId}`
);
return {
success: false,
chunksEmbedded: 0,
error: 'No text extracted',
error: 'No OCR text provided',
};
}
// 2. Chunk text
const chunks = this.chunkText(fullText);
// 1. แบ่งข้อความออกเป็น Chunk ด้วย Semantic Chunking
const chunks = await this.semanticChunkTextWithFallback(ocrText);
this.logger.log(
`Document ${documentPublicId} split into ${chunks.length} chunks`
);
// 3. Generate embedding และ upsert ไป Qdrant
// 2. แปลงแต่ละ chunk เป็น Hybrid Vector และเตรียม points
const points = [];
for (const chunk of chunks) {
for (const [idx, chunk] of chunks.entries()) {
try {
const embedding = await this.ollamaService.generateEmbedding(
chunk.text
);
// เรียก Sidecar /embed เพื่อแปลงข้อความของ chunk
const embedResult = await this.ocrService.embedViaSidecar(chunk.text);
points.push({
id: `${documentPublicId}-${chunk.chunkIndex}`,
vector: embedding,
id: `${documentPublicId}-${idx}`,
vector: {
bge_dense: embedResult.dense,
bge_sparse: embedResult.sparse,
},
payload: {
document_public_id: documentPublicId,
chunk_index: chunk.chunkIndex,
page_number: chunk.pageNumber,
doc_public_id: documentPublicId,
project_public_id: projectPublicId,
doc_number: correspondenceNumber,
doc_type: docType,
status_code: statusCode,
revision_number: revisionNumber,
subject: subject,
document_date: documentDate || null,
chunk_topic: chunk.topic,
chunk_index: idx,
chunk_text: chunk.text,
embedded_at: new Date().toISOString(),
},
});
} catch (err) {
this.logger.error(
`Failed to embed chunk ${chunk.chunkIndex} for document ${documentPublicId}`,
`Failed to embed chunk ${idx} for document ${documentPublicId}`,
err instanceof Error ? err.message : String(err)
);
}
@@ -117,7 +125,13 @@ export class EmbeddingService {
};
}
// 4. Upsert ไป Qdrant พร้อม project isolation
// 3. ลบ points เก่าของเอกสาร (เพื่อความ idempotent และรองรับ revision ใหม่)
await this.qdrantService.deleteByDocumentPublicId(
projectPublicId,
documentPublicId
);
// 4. บันทึก points ใหม่ลง Qdrant
await this.qdrantService.upsert(projectPublicId, points);
this.logger.log(
@@ -135,12 +149,53 @@ export class EmbeddingService {
}
/**
* Chunk text ด้วย overlap
* - chunkSize: 512 characters (approximate token equivalent)
* - overlap: 64 characters
* แบ่งข้อความโดยใช้ typhoon2.5 และ Prompt 'rag_chunking' (T025, T026)
* หากล้มเหลวหรือ LLM ไม่ตอบกลับในรูปแบบแท็ก <chunk> ให้ fallback เป็นแบบ fixed-size
*/
private chunkText(text: string): EmbeddingChunk[] {
const chunks: EmbeddingChunk[] = [];
private async semanticChunkTextWithFallback(
ocrText: string
): Promise<Array<{ topic: string; text: string }>> {
try {
this.logger.log('Attempting semantic chunking via typhoon2.5...');
// ดึง prompt จาก ai_prompts ที่เป็น active version
const resolved = await this.aiPromptsService.resolveActive(
'rag_chunking',
ocrText
);
// เรียก LLM
const llmOutput = await this.ollamaService.generate(
resolved.resolvedPrompt
);
// ดึงและวิเคราะห์ข้อความภายในแท็ก <chunk topic="...">
const parsed = this.parseChunkTags(llmOutput);
if (parsed.length > 0) {
this.logger.log(
`Semantic chunking succeeded: split into ${parsed.length} chunks.`
);
return parsed;
}
this.logger.warn(
'No valid <chunk> tags found in LLM output, falling back to fixed-size chunking.'
);
} catch (err: unknown) {
this.logger.warn(
`Semantic chunking failed, falling back to fixed-size chunking: ${err instanceof Error ? err.message : String(err)}`
);
}
// Fallback: ใช้การแบ่ง chunk แบบ Fixed-size
return this.fixedSizeChunk(ocrText, this.chunkSize, this.overlap);
}
/** แบ่งข้อความตามขนาดคงที่ (Fixed-size Chunking) (FR-005) */
private fixedSizeChunk(
text: string,
chunkSize: number,
overlap: number
): Array<{ topic: string; text: string }> {
const chunks: Array<{ topic: string; text: string }> = [];
const cleanText = text.replace(/\s+/g, ' ').trim();
const textLength = cleanText.length;
@@ -148,19 +203,35 @@ export class EmbeddingService {
let chunkIndex = 0;
while (startIndex < textLength) {
const endIndex = Math.min(startIndex + this.chunkSize, textLength);
const endIndex = Math.min(startIndex + chunkSize, textLength);
const chunkText = cleanText.substring(startIndex, endIndex);
chunks.push({
chunkIndex,
topic: `ส่วนที่ ${chunkIndex + 1}`,
text: chunkText,
pageNumber: undefined, // TODO: Extract page numbers if available
});
startIndex += this.chunkSize - this.overlap;
startIndex += chunkSize - overlap;
chunkIndex += 1;
}
return chunks;
}
/** ประมวลผลดึงค่า regex <chunk topic="...">... </chunk> (T026) */
private parseChunkTags(
llmOutput: string
): Array<{ topic: string; text: string }> {
const chunks: Array<{ topic: string; text: string }> = [];
const regex = /<chunk\s+topic="([^"]*)"\s*>([\s\S]*?)<\/chunk\s*>/gi;
let match;
while ((match = regex.exec(llmOutput)) !== null) {
const topic = match[1]?.trim() || 'ทั่วไป';
const text = match[2]?.trim();
if (text) {
chunks.push({ topic, text });
}
}
return chunks;
}
}
@@ -393,4 +393,53 @@ export class OcrService {
);
}
}
/** เรียก Sidecar /embed เพื่อทำ BGE-M3 (Dense + Sparse) embedding (T012) */
async embedViaSidecar(text: string): Promise<{
dense: number[];
sparse: { indices: number[]; values: number[] };
}> {
try {
const response = await axios.post(
`${this.ocrApiUrl}/embed`,
{ text },
{
headers: {
'X-API-Key': this.ocrSidecarApiKey,
},
}
);
return response.data as {
dense: number[];
sparse: { indices: number[]; values: number[] };
};
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
this.logger.error(`Failed to embed via Sidecar: ${msg}`);
throw new Error(`AI_SIDECAR_EMBED_FAILED: ${msg}`);
}
}
/** เรียก Sidecar /rerank เพื่อทำ BGE-Reranker-Large re-ranking (T014) */
async rerankViaSidecar(
query: string,
chunks: string[]
): Promise<{ scores: number[]; ranked_indices: number[] }> {
try {
const response = await axios.post(
`${this.ocrApiUrl}/rerank`,
{ query, chunks },
{
headers: {
'X-API-Key': this.ocrSidecarApiKey,
},
}
);
return response.data as { scores: number[]; ranked_indices: number[] };
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
this.logger.error(`Failed to rerank via Sidecar: ${msg}`);
throw new Error(`AI_SIDECAR_RERANK_FAILED: ${msg}`);
}
}
}
@@ -0,0 +1,175 @@
// File: src/modules/correspondence/correspondence-workflow.service.spec.ts
// Change Log:
// - 2026-06-05: สร้าง unit test สำหรับ CorrespondenceWorkflowService เพื่อทดสอบการเรียกใช้ RAG prepare job เมื่อสถานะเปลี่ยนจาก DRAFT (T017)
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { CorrespondenceWorkflowService } from './correspondence-workflow.service';
import { WorkflowEngineService } from '../workflow-engine/workflow-engine.service';
import { Correspondence } from './entities/correspondence.entity';
import { CorrespondenceRevision } from './entities/correspondence-revision.entity';
import { CorrespondenceStatus } from './entities/correspondence-status.entity';
import { CorrespondenceRecipient } from './entities/correspondence-recipient.entity';
import { NotificationService } from '../notification/notification.service';
import { UserService } from '../user/user.service';
import { AiQueueService } from '../ai/ai-queue.service';
describe('CorrespondenceWorkflowService', () => {
let service: CorrespondenceWorkflowService;
let aiQueueService: AiQueueService;
const mockWorkflowEngine = {
createInstance: jest.fn(),
processTransition: jest.fn(),
getInstanceById: jest.fn(),
};
const mockCorrespondenceRepo = {
findOne: jest.fn(),
save: jest.fn(),
};
const mockRevisionRepo = {
findOne: jest.fn(),
save: jest.fn(),
manager: {
save: jest.fn(),
findOne: jest.fn(),
find: jest.fn(),
},
};
const mockStatusRepo = {
findOne: jest.fn(),
};
const mockRecipientRepo = {
find: jest.fn(),
};
const mockDataSource = {
createQueryRunner: jest.fn().mockReturnValue({
connect: jest.fn(),
startTransaction: jest.fn(),
commitTransaction: jest.fn(),
rollbackTransaction: jest.fn(),
release: jest.fn(),
manager: mockRevisionRepo.manager,
}),
};
const mockNotificationService = {
send: jest.fn(),
};
const mockUserService = {
findDocControlIdByOrg: jest.fn(),
};
const mockAiQueueService = {
enqueueRagPrepare: jest.fn().mockResolvedValue('job-id-123'),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
CorrespondenceWorkflowService,
{ provide: WorkflowEngineService, useValue: mockWorkflowEngine },
{
provide: getRepositoryToken(Correspondence),
useValue: mockCorrespondenceRepo,
},
{
provide: getRepositoryToken(CorrespondenceRevision),
useValue: mockRevisionRepo,
},
{
provide: getRepositoryToken(CorrespondenceStatus),
useValue: mockStatusRepo,
},
{
provide: getRepositoryToken(CorrespondenceRecipient),
useValue: mockRecipientRepo,
},
{ provide: DataSource, useValue: mockDataSource },
{ provide: NotificationService, useValue: mockNotificationService },
{ provide: UserService, useValue: mockUserService },
{ provide: AiQueueService, useValue: mockAiQueueService },
],
}).compile();
service = module.get<CorrespondenceWorkflowService>(
CorrespondenceWorkflowService
);
aiQueueService = module.get<AiQueueService>(AiQueueService);
jest.clearAllMocks();
});
describe('syncStatus RAG trigger', () => {
it('ควรเรียก enqueueRagPrepare เมื่อสถานะเอกสารถูกเปลี่ยนจาก DRAFT เป็นอย่างอื่น', async () => {
const mockStatus = { id: 2, statusCode: 'SUBOWN' };
mockStatusRepo.findOne.mockResolvedValueOnce(mockStatus);
const mockProject = { id: 10, publicId: 'proj-uuid-123' };
const mockCorrespondence = {
id: 100,
publicId: 'doc-uuid-999',
correspondenceNumber: 'CORR-001',
projectId: 10,
project: mockProject,
type: { correspondenceTypeCode: 'LETTER' },
};
const mockRevision = {
id: 50,
correspondenceId: 100,
revisionNumber: 0,
subject: 'Test Subject',
documentDate: new Date('2026-06-05'),
correspondence: mockCorrespondence,
statusId: 1,
};
mockRevisionRepo.manager.save.mockResolvedValueOnce(mockRevision);
mockRevisionRepo.manager.find.mockResolvedValueOnce([
{
correspondenceRevisionId: 50,
attachmentId: 88,
isMainDocument: true,
attachment: { filePath: '/files/doc.pdf', fileExtension: 'pdf' },
},
]);
await (
service as unknown as {
syncStatus: (
revision: CorrespondenceRevision,
workflowState: string
) => Promise<void>;
}
).syncStatus(
mockRevision as unknown as CorrespondenceRevision,
'IN_REVIEW'
);
expect(mockRevisionRepo.manager.save).toHaveBeenCalledWith(mockRevision);
expect(aiQueueService.enqueueRagPrepare).toHaveBeenCalledWith({
documentPublicId: 'doc-uuid-999',
projectPublicId: 'proj-uuid-123',
correspondenceNumber: 'CORR-001',
docType: 'LETTER',
statusCode: 'SUBOWN',
revisionNumber: 0,
subject: 'Test Subject',
documentDate: '2026-06-05',
attachmentPath: '/files/doc.pdf',
});
});
it('ไม่ควรเรียก enqueueRagPrepare เมื่อเอกสารยังคงอยู่ในสถานะ DRAFT', async () => {
const mockStatus = { id: 1, statusCode: 'DRAFT' };
mockStatusRepo.findOne.mockResolvedValueOnce(mockStatus);
const mockRevision = {
id: 50,
correspondenceId: 100,
revisionNumber: 0,
subject: 'Test Subject',
statusId: 1,
};
mockRevisionRepo.manager.save.mockResolvedValueOnce(mockRevision);
await (
service as unknown as {
syncStatus: (
revision: CorrespondenceRevision,
workflowState: string
) => Promise<void>;
}
).syncStatus(mockRevision as unknown as CorrespondenceRevision, 'DRAFT');
expect(mockRevisionRepo.manager.save).toHaveBeenCalledWith(mockRevision);
expect(aiQueueService.enqueueRagPrepare).not.toHaveBeenCalled();
});
});
});
@@ -10,8 +10,11 @@ import { CorrespondenceRevision } from './entities/correspondence-revision.entit
import { CorrespondenceStatus } from './entities/correspondence-status.entity';
import { Correspondence } from './entities/correspondence.entity';
import { CorrespondenceRecipient } from './entities/correspondence-recipient.entity';
import { CorrespondenceRevisionAttachment } from './entities/correspondence-revision-attachment.entity';
import { NotificationService } from '../notification/notification.service';
import { UserService } from '../user/user.service';
import { AiQueueService } from '../ai/ai-queue.service';
import { Project } from '../project/entities/project.entity';
@Injectable()
export class CorrespondenceWorkflowService {
@@ -30,7 +33,8 @@ export class CorrespondenceWorkflowService {
private readonly recipientRepo: Repository<CorrespondenceRecipient>,
private readonly dataSource: DataSource,
private readonly notificationService: NotificationService,
private readonly userService: UserService
private readonly userService: UserService,
private readonly aiQueueService: AiQueueService
) {}
async submitWorkflow(
@@ -85,41 +89,67 @@ export class CorrespondenceWorkflowService {
{ roles: userRoles } // [FIX] Pass roles for DSL requirements check
);
await this.syncStatus(revision, transitionResult.nextState, queryRunner);
await this.syncStatus(
revision,
transitionResult.nextState,
queryRunner,
true
);
await queryRunner.commitTransaction();
// After-commit: RAG preparation (fire-and-forget)
// ย้ายมาหลัง commit เพื่อป้องกัน job ถูก enqueue แต่ transaction rollback
try {
if (transitionResult.nextState !== 'DRAFT') {
await this.triggerRagPrepare(revision, transitionResult.nextState);
}
} catch (err: unknown) {
const errMsg = err instanceof Error ? err.message : String(err);
this.logger.warn(
`After-commit RAG preparation failed (non-critical): ${errMsg}`
);
}
// Notify TO recipient org users (fire-and-forget)
const corrForNotify = revision.correspondence;
if (corrForNotify) {
void this.recipientRepo
.find({
where: {
correspondenceId: corrForNotify.id,
recipientType: 'TO',
},
})
.then(async (recipients) => {
for (const r of recipients) {
const targetUserId = await this.userService.findDocControlIdByOrg(
r.recipientOrganizationId
);
if (targetUserId) {
await this.notificationService.send({
userId: targetUserId,
title: 'New Correspondence Received',
message: `${corrForNotify.correspondenceNumber} has been submitted to your organization.`,
type: 'EMAIL',
entityType: 'correspondence',
entityId: revision.correspondenceId,
link: `/correspondences/${corrForNotify.publicId}`,
});
try {
const corrForNotify = revision.correspondence;
if (corrForNotify) {
void this.recipientRepo
.find({
where: {
correspondenceId: corrForNotify.id,
recipientType: 'TO',
},
})
.then(async (recipients) => {
for (const r of recipients) {
const targetUserId =
await this.userService.findDocControlIdByOrg(
r.recipientOrganizationId
);
if (targetUserId) {
await this.notificationService.send({
userId: targetUserId,
title: 'New Correspondence Received',
message: `${corrForNotify.correspondenceNumber} has been submitted to your organization.`,
type: 'EMAIL',
entityType: 'correspondence',
entityId: revision.correspondenceId,
link: `/correspondences/${corrForNotify.publicId}`,
});
}
}
}
})
.catch((err: Error) =>
this.logger.warn(`Submit notification failed: ${err.message}`)
);
})
.catch((err: Error) =>
this.logger.warn(`Submit notification failed: ${err.message}`)
);
}
} catch (err: unknown) {
const errMsg = err instanceof Error ? err.message : String(err);
this.logger.warn(
`After-commit notification setup failed (non-critical): ${errMsg}`
);
}
return {
@@ -166,7 +196,8 @@ export class CorrespondenceWorkflowService {
private async syncStatus(
revision: CorrespondenceRevision,
workflowState: string,
queryRunner?: import('typeorm').QueryRunner
queryRunner?: import('typeorm').QueryRunner,
skipRagPrepare = false
) {
const statusMap: Record<string, string> = {
DRAFT: 'DRAFT',
@@ -174,21 +205,95 @@ export class CorrespondenceWorkflowService {
APPROVED: 'CLBOWN',
REJECTED: 'CCBOWN',
};
const targetCode = statusMap[workflowState] || 'DRAFT';
const status = await this.statusRepo.findOne({
where: { statusCode: targetCode }, // ✅ FIX: CamelCase
where: { statusCode: targetCode },
});
if (status) {
// ✅ FIX: CamelCase (correspondenceStatusId)
revision.statusId = status.id;
const manager = queryRunner
? queryRunner.manager
: this.revisionRepo.manager;
await manager.save(revision);
}
// Await RAG preparation เพื่อให้ unit test assert ได้
// caller (submitWorkflow/processAction) ก็ยังคง await syncStatus ตามปกติ
if (!skipRagPrepare && workflowState !== 'DRAFT') {
await this.triggerRagPrepare(revision, targetCode);
}
}
/**
* triggerRagPrepare — รวบรวมข้อมูลจาก revision/correspondence แล้ว enqueue rag-prepare job
* คืน Promise เพื่อให้ test สามารถ await และ assert ได้ ส่วน production caller ก็ await ผ่าน syncStatus
*/
private async triggerRagPrepare(
revision: CorrespondenceRevision,
statusCode: string
): Promise<void> {
try {
let correspondence: Correspondence | null | undefined =
revision.correspondence;
if (!correspondence) {
correspondence = await this.correspondenceRepo.findOne({
where: { id: revision.correspondenceId },
relations: ['project', 'type'],
});
}
if (!correspondence) {
return;
}
let projectPublicId = '';
if (correspondence.project) {
projectPublicId = correspondence.project.publicId;
} else {
const proj = await this.correspondenceRepo.manager.findOne(Project, {
where: { id: correspondence.projectId },
});
if (proj) {
projectPublicId = proj.publicId;
}
}
const docType = correspondence.type?.typeCode || 'LETTER';
let attachmentPath: string | undefined;
const attachments = await this.revisionRepo.manager.find(
CorrespondenceRevisionAttachment,
{ where: { correspondenceRevisionId: revision.id } }
);
if (attachments && attachments.length > 0) {
const pdfAtt = attachments.find((att) => {
const ext =
att.attachment?.originalFilename?.split('.').pop()?.toLowerCase() ||
'';
return (
ext === 'pdf' ||
att.attachment?.filePath?.toLowerCase().endsWith('.pdf')
);
});
if (pdfAtt && pdfAtt.attachment) {
attachmentPath = pdfAtt.attachment.filePath;
} else if (attachments[0].attachment) {
attachmentPath = attachments[0].attachment.filePath;
}
}
await this.aiQueueService.enqueueRagPrepare({
documentPublicId: correspondence.publicId,
projectPublicId: projectPublicId,
correspondenceNumber: correspondence.correspondenceNumber,
docType: docType,
statusCode: statusCode,
revisionNumber: revision.revisionNumber,
subject: revision.subject,
documentDate: revision.documentDate
? revision.documentDate.toISOString().split('T')[0]
: undefined,
attachmentPath: attachmentPath,
});
} catch (err: unknown) {
const errMsg = err instanceof Error ? err.message : String(err);
this.logger.warn(
`Failed to enqueue RAG preparation for revision ${revision.id}: ${errMsg}`
);
}
}
}
@@ -25,6 +25,7 @@ import { SearchModule } from '../search/search.module';
import { FileStorageModule } from '../../common/file-storage/file-storage.module';
import { NotificationModule } from '../notification/notification.module';
import { CirculationModule } from '../circulation/circulation.module';
import { AiModule } from '../ai/ai.module';
/**
* CorrespondenceModule
@@ -53,6 +54,7 @@ import { CirculationModule } from '../circulation/circulation.module';
FileStorageModule,
NotificationModule,
CirculationModule,
AiModule,
],
controllers: [CorrespondenceController],
providers: [
@@ -1,86 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { IngestionService } from '../ingestion.service';
const QUEUE_TOKEN = 'BullQueue_rag-ocr';
const mockOcrQueue = {
getJob: jest.fn(),
add: jest.fn(),
};
const baseJobData = {
attachmentPublicId: 'att-uuid-001',
filePath: '/uploads/permanent/CORR/2026/04/file.pdf',
docType: 'CORR',
docNumber: 'REF-001',
revision: null,
projectCode: 'PRJ-001',
projectPublicId: 'proj-uuid-001',
classification: 'INTERNAL' as const,
};
describe('IngestionService', () => {
let service: IngestionService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
IngestionService,
{ provide: QUEUE_TOKEN, useValue: mockOcrQueue },
],
}).compile();
service = module.get<IngestionService>(IngestionService);
jest.clearAllMocks();
});
it('should enqueue rag-ocr job with attachmentPublicId as jobId', async () => {
mockOcrQueue.getJob.mockResolvedValue(null);
mockOcrQueue.add.mockResolvedValue({ id: baseJobData.attachmentPublicId });
await service.enqueue(baseJobData);
expect(mockOcrQueue.add).toHaveBeenCalledWith('ocr', baseJobData, {
jobId: baseJobData.attachmentPublicId,
});
});
it('EC-RAG-001: duplicate enqueue when job is active → second call is no-op (log only)', async () => {
const mockJob = { getState: jest.fn().mockResolvedValue('active') };
mockOcrQueue.getJob.mockResolvedValue(mockJob);
await service.enqueue(baseJobData);
expect(mockOcrQueue.add).not.toHaveBeenCalled();
});
it('EC-RAG-001: duplicate enqueue when job is waiting → second call is no-op', async () => {
const mockJob = { getState: jest.fn().mockResolvedValue('waiting') };
mockOcrQueue.getJob.mockResolvedValue(mockJob);
await service.enqueue(baseJobData);
expect(mockOcrQueue.add).not.toHaveBeenCalled();
});
it('should re-enqueue if job exists but is completed (state=completed)', async () => {
const mockJob = { getState: jest.fn().mockResolvedValue('completed') };
mockOcrQueue.getJob.mockResolvedValue(mockJob);
mockOcrQueue.add.mockResolvedValue({ id: baseJobData.attachmentPublicId });
await service.enqueue(baseJobData);
expect(mockOcrQueue.add).toHaveBeenCalledTimes(1);
});
it('should re-enqueue if job exists but is failed (state=failed)', async () => {
const mockJob = { getState: jest.fn().mockResolvedValue('failed') };
mockOcrQueue.getJob.mockResolvedValue(mockJob);
mockOcrQueue.add.mockResolvedValue({ id: baseJobData.attachmentPublicId });
await service.enqueue(baseJobData);
expect(mockOcrQueue.add).toHaveBeenCalledTimes(1);
});
});
@@ -1,213 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ServiceUnavailableException } from '@nestjs/common';
import { getRepositoryToken } from '@nestjs/typeorm';
import { getQueueToken } from '@nestjs/bullmq';
import { RagService } from '../rag.service';
import { QdrantService } from '../qdrant.service';
import { EmbeddingService } from '../embedding.service';
import { LocalLlmService } from '../local-llm.service';
import { IngestionService } from '../ingestion.service';
import { DocumentChunk } from '../entities/document-chunk.entity';
import { QUEUE_AI_VECTOR_DELETION } from '../../common/constants/queue.constants';
const DEFAULT_REDIS_TOKEN = 'default_IORedisModuleConnectionToken';
const mockQdrant = {
isReady: jest.fn(),
hybridSearch: jest.fn(),
deleteByDocumentId: jest.fn(),
};
const mockEmbedding = {
embed: jest.fn(),
};
const mockLocalLlm = {
generate: jest.fn(),
sanitizeInput: jest.fn((t: string) => t),
};
const mockIngestion = { enqueue: jest.fn() };
const mockChunkRepo = {
count: jest.fn(),
delete: jest.fn(),
manager: {
query: jest.fn(),
},
};
const mockRedis = {
get: jest.fn(),
setex: jest.fn(),
};
const mockVectorDeletionQueue = {
add: jest.fn().mockResolvedValue({ id: 'mock-job-id' }),
};
describe('RagService', () => {
let service: RagService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
RagService,
{ provide: QdrantService, useValue: mockQdrant },
{ provide: EmbeddingService, useValue: mockEmbedding },
{ provide: LocalLlmService, useValue: mockLocalLlm },
{ provide: IngestionService, useValue: mockIngestion },
{ provide: getRepositoryToken(DocumentChunk), useValue: mockChunkRepo },
{ provide: DEFAULT_REDIS_TOKEN, useValue: mockRedis },
{
provide: getQueueToken(QUEUE_AI_VECTOR_DELETION),
useValue: mockVectorDeletionQueue,
},
],
}).compile();
service = module.get<RagService>(RagService);
jest.clearAllMocks();
});
describe('query()', () => {
const dto = {
question: 'เอกสารเกี่ยวกับอะไร?',
projectPublicId: 'proj-uuid-1234',
};
const memberPerms: string[] = [];
const adminPerms = ['system.manage_all'];
it('should return answer with citations on PUBLIC cache miss → write cache', async () => {
mockQdrant.isReady.mockReturnValue(true);
mockRedis.get.mockResolvedValue(null);
mockEmbedding.embed.mockResolvedValue(new Array(768).fill(0.1));
mockQdrant.hybridSearch.mockResolvedValue([
{
chunkId: 'chunk-1',
publicId: 'att-1',
docType: 'CORR',
docNumber: 'REF-001',
revision: null,
projectCode: 'PRJ-001',
contentPreview: 'เนื้อหาเอกสาร',
score: 0.92,
},
]);
mockLocalLlm.generate.mockResolvedValue({
answer: 'คำตอบ',
usedFallbackModel: false,
});
const result = await service.query(dto, memberPerms);
expect(result.answer).toBe('คำตอบ');
expect(result.citations).toHaveLength(1);
expect(result.usedFallbackModel).toBe(false);
expect(mockRedis.setex).toHaveBeenCalledTimes(1);
});
it('should return cached result without calling Qdrant on cache hit', async () => {
mockQdrant.isReady.mockReturnValue(true);
const cached = JSON.stringify({
answer: 'cached answer',
citations: [],
confidence: 0.9,
usedFallbackModel: false,
});
mockRedis.get.mockResolvedValue(cached);
const result = await service.query(dto, memberPerms);
expect(result.answer).toBe('cached answer');
expect(mockQdrant.hybridSearch).not.toHaveBeenCalled();
expect(mockEmbedding.embed).not.toHaveBeenCalled();
});
it('CONFIDENTIAL: must use Ollama only, skip cache read and write', async () => {
mockQdrant.isReady.mockReturnValue(true);
mockEmbedding.embed.mockResolvedValue(new Array(768).fill(0.1));
mockQdrant.hybridSearch.mockResolvedValue([]);
mockLocalLlm.generate.mockResolvedValue({
answer: 'ลับมาก',
usedFallbackModel: false,
});
const result = await service.query(dto, adminPerms);
expect(mockRedis.get).not.toHaveBeenCalled();
expect(mockRedis.setex).not.toHaveBeenCalled();
expect(mockLocalLlm.generate).toHaveBeenCalledWith(expect.any(String));
expect(result.usedFallbackModel).toBe(false);
});
it('collectionReady=false → throw ServiceUnavailableException RAG_NOT_READY', async () => {
mockQdrant.isReady.mockReturnValue(false);
await expect(service.query(dto, memberPerms)).rejects.toThrow(
ServiceUnavailableException
);
});
it('cross-project cache isolation: same question different projectPublicId → different cache key', async () => {
mockQdrant.isReady.mockReturnValue(true);
mockRedis.get.mockResolvedValue(null);
mockEmbedding.embed.mockResolvedValue(new Array(768).fill(0.1));
mockQdrant.hybridSearch.mockResolvedValue([]);
mockLocalLlm.generate.mockResolvedValue({
answer: 'A',
usedFallbackModel: false,
});
await service.query(
{ question: 'Q?', projectPublicId: 'proj-A' },
memberPerms
);
await service.query(
{ question: 'Q?', projectPublicId: 'proj-B' },
memberPerms
);
const calls = mockRedis.setex.mock.calls as [string, ...unknown[]][];
expect(calls[0][0]).not.toBe(calls[1][0]);
});
it('classification ceiling derived from role, not from request body', async () => {
mockQdrant.isReady.mockReturnValue(true);
mockRedis.get.mockResolvedValue(null);
mockEmbedding.embed.mockResolvedValue(new Array(768).fill(0.1));
mockQdrant.hybridSearch.mockResolvedValue([]);
mockLocalLlm.generate.mockResolvedValue({
anwer: 'ok',
usedFallbackModel: false,
});
await service.query(dto, memberPerms);
expect(mockQdrant.hybridSearch).toHaveBeenCalledWith(
expect.any(Array),
dto.projectPublicId,
'INTERNAL',
20
);
jest.clearAllMocks();
mockQdrant.isReady.mockReturnValue(true);
mockRedis.get.mockResolvedValue(null);
mockEmbedding.embed.mockResolvedValue(new Array(768).fill(0.1));
mockQdrant.hybridSearch.mockResolvedValue([]);
mockLocalLlm.generate.mockResolvedValue({
answer: 'ok',
usedFallbackModel: false,
});
await service.query(dto, adminPerms);
expect(mockQdrant.hybridSearch).toHaveBeenCalledWith(
expect.any(Array),
dto.projectPublicId,
'CONFIDENTIAL',
20
);
});
});
});
@@ -1,11 +0,0 @@
import { IsNotEmpty, IsString, IsUUID, MaxLength } from 'class-validator';
export class RagQueryDto {
@IsString()
@IsNotEmpty()
@MaxLength(500)
question!: string;
@IsUUID()
projectPublicId!: string;
}
@@ -1,16 +0,0 @@
export interface RagCitation {
chunkId: string;
docNumber: string | null;
docType: string;
revision: string | null;
snippet: string;
score: number;
}
export class RagResponseDto {
answer!: string;
citations!: RagCitation[];
confidence!: number;
usedFallbackModel!: boolean;
cachedAt?: string;
}
@@ -1,46 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
@Injectable()
export class EmbeddingService {
private readonly logger = new Logger(EmbeddingService.name);
private readonly ollamaUrl: string;
private readonly model: string;
constructor(private readonly configService: ConfigService) {
this.ollamaUrl = this.configService.get<string>(
'OLLAMA_URL',
'http://localhost:11434'
);
this.model = this.configService.get<string>(
'OLLAMA_EMBED_MODEL',
'nomic-embed-text'
);
}
async embed(text: string): Promise<number[]> {
try {
const response = await axios.post<{ embedding: number[] }>(
`${this.ollamaUrl}/api/embeddings`,
{ model: this.model, prompt: text },
{ timeout: 30000 }
);
return response.data.embedding;
} catch (err) {
this.logger.error(
'Embedding failed',
err instanceof Error ? err.stack : String(err)
);
throw err;
}
}
async embedBatch(texts: string[]): Promise<number[][]> {
return Promise.all(texts.map((t) => this.embed(t)));
}
getModelName(): string {
return this.model;
}
}
@@ -1,47 +0,0 @@
import { Column, CreateDateColumn, Entity, PrimaryColumn } from 'typeorm';
@Entity('document_chunks')
export class DocumentChunk {
@PrimaryColumn({ type: 'char', length: 36 })
id!: string;
@Column({ type: 'char', length: 36, name: 'document_id' })
documentId!: string;
@Column({ name: 'chunk_index' })
chunkIndex!: number;
@Column({ type: 'text' })
content!: string;
@Column({ length: 20, name: 'doc_type' })
docType!: string;
@Column({ type: 'varchar', length: 100, name: 'doc_number', nullable: true })
docNumber!: string | null;
@Column({ type: 'varchar', length: 20, nullable: true })
revision!: string | null;
@Column({ length: 50, name: 'project_code' })
projectCode!: string;
@Column({ length: 36, name: 'project_public_id' })
projectPublicId!: string;
@Column({
type: 'enum',
enum: ['PUBLIC', 'INTERNAL', 'CONFIDENTIAL'],
default: 'INTERNAL',
})
classification!: 'PUBLIC' | 'INTERNAL' | 'CONFIDENTIAL';
@Column({ type: 'varchar', length: 20, nullable: true })
version!: string | null;
@Column({ length: 100, name: 'embedding_model', default: 'nomic-embed-text' })
embeddingModel!: string;
@CreateDateColumn({ name: 'created_at', precision: 3 })
createdAt!: Date;
}
@@ -1,30 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { OcrJobData } from './processors/ocr.processor';
@Injectable()
export class IngestionService {
private readonly logger = new Logger(IngestionService.name);
constructor(@InjectQueue('rag-ocr') private readonly ocrQueue: Queue) {}
async enqueue(data: OcrJobData): Promise<void> {
const jobId = data.attachmentPublicId;
const existing = await this.ocrQueue.getJob(jobId);
if (existing) {
const state = await existing.getState();
if (state === 'active' || state === 'waiting' || state === 'delayed') {
this.logger.log(
`rag-ocr job already queued for ${jobId} (state: ${state})`
);
return;
}
}
await this.ocrQueue.add('ocr', data, { jobId });
this.logger.log(`Enqueued rag-ocr for attachment ${jobId}`);
}
}
@@ -1,71 +0,0 @@
// File: src/modules/rag/local-llm.service.ts
// Change Log
// - 2026-05-15: แทนที่ cloud LLM API ด้วย Ollama local-only ตาม ADR-023A.
// - 2026-06-03: ADR-034 — เปลี่ยน default fallback จาก gemma4:e4b เป็น typhoon2.5-np-dms:latest
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
export interface LlmGenerateResult {
answer: string;
usedFallbackModel: boolean;
}
/** บริการเรียก LLM ภายในองค์กรผ่าน Ollama เท่านั้น */
@Injectable()
export class LocalLlmService {
private readonly logger = new Logger(LocalLlmService.name);
private readonly ollamaUrl: string;
private readonly ollamaModel: string;
private readonly timeoutMs: number;
constructor(private readonly configService: ConfigService) {
this.ollamaUrl = this.configService.get<string>(
'OLLAMA_URL',
this.configService.get<string>('AI_HOST_URL', 'http://localhost:11434')
);
this.ollamaModel = this.configService.get<string>(
'OLLAMA_MODEL_MAIN',
this.configService.get<string>(
'OLLAMA_RAG_MODEL',
'typhoon2.5-np-dms:latest'
)
);
this.timeoutMs = this.configService.get<number>('RAG_TIMEOUT_MS', 30000);
}
/** สร้างคำตอบจากโมเดล local-only โดยไม่มี cloud fallback */
async generate(prompt: string): Promise<LlmGenerateResult> {
try {
const response = await axios.post<{ response: string }>(
`${this.ollamaUrl}/api/generate`,
{
model: this.ollamaModel,
prompt,
stream: false,
},
{ timeout: this.timeoutMs }
);
return {
answer: response.data.response ?? '',
usedFallbackModel: false,
};
} catch (err) {
this.logger.error(
'Local Ollama generation failed',
err instanceof Error ? err.stack : String(err)
);
throw err;
}
}
/** ทำความสะอาด prompt injection pattern พื้นฐานก่อนส่งเข้าโมเดล */
sanitizeInput(text: string): string {
return text
.replace(/<CONTEXT_START>|<CONTEXT_END>/gi, '')
.replace(/ignore previous instructions/gi, '')
.replace(/system:/gi, '')
.slice(0, 1000);
}
}
@@ -1,110 +0,0 @@
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Job } from 'bullmq';
import { v4 as uuidv4 } from 'uuid';
import { EmbeddingService } from '../embedding.service';
import { QdrantService, VectorMetadata } from '../qdrant.service';
import { DocumentChunk } from '../entities/document-chunk.entity';
import { EmbeddingJobData } from './thai-preprocess.processor';
const CHUNK_SIZE = 512;
const CHUNK_OVERLAP = 50;
@Processor('rag-embedding')
export class EmbeddingProcessor extends WorkerHost {
private readonly logger = new Logger(EmbeddingProcessor.name);
constructor(
private readonly embeddingService: EmbeddingService,
private readonly qdrantService: QdrantService,
@InjectRepository(DocumentChunk)
private readonly chunkRepo: Repository<DocumentChunk>
) {
super();
}
async process(job: Job<EmbeddingJobData>): Promise<void> {
const {
attachmentPublicId,
normalizedText,
docType,
docNumber,
revision,
projectCode,
projectPublicId,
classification,
} = job.data;
const chunks = this.chunkText(normalizedText);
const model = this.embeddingService.getModelName();
const upsertPoints: Parameters<QdrantService['upsertBatch']>[0] = [];
const chunkEntities: DocumentChunk[] = [];
for (let i = 0; i < chunks.length; i++) {
const chunkId = uuidv4();
const vector = await this.embeddingService.embed(chunks[i]);
const payload: VectorMetadata = {
chunk_id: chunkId,
public_id: attachmentPublicId,
project_public_id: projectPublicId,
doc_type: docType,
doc_number: docNumber,
revision,
project_code: projectCode,
classification,
content_preview: chunks[i].slice(0, 500),
embedding_model: model,
};
upsertPoints.push({ id: chunkId, vector, payload });
const entity = this.chunkRepo.create({
id: chunkId,
documentId: attachmentPublicId,
chunkIndex: i,
content: chunks[i],
docType,
docNumber,
revision,
projectCode,
projectPublicId,
classification,
embeddingModel: model,
});
chunkEntities.push(entity);
}
if (upsertPoints.length > 0) {
await this.qdrantService.upsertBatch(upsertPoints);
await this.chunkRepo.save(chunkEntities);
}
await this.chunkRepo.manager.query(
`UPDATE attachments SET rag_status = 'INDEXED', rag_last_error = NULL WHERE public_id = ?`,
[attachmentPublicId]
);
this.logger.log(
`Embedded ${chunks.length} chunks for ${attachmentPublicId}`
);
}
private chunkText(text: string): string[] {
const words = text.split(/\s+/);
const chunks: string[] = [];
let start = 0;
while (start < words.length) {
const end = Math.min(start + CHUNK_SIZE, words.length);
chunks.push(words.slice(start, end).join(' '));
start += CHUNK_SIZE - CHUNK_OVERLAP;
}
return chunks.filter((c) => c.trim().length > 0);
}
}
@@ -1,68 +0,0 @@
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Job } from 'bullmq';
import * as fs from 'fs';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { DocumentChunk } from '../entities/document-chunk.entity';
export interface OcrJobData {
attachmentPublicId: string;
filePath: string;
docType: string;
docNumber: string | null;
revision: string | null;
projectCode: string;
projectPublicId: string;
classification: 'PUBLIC' | 'INTERNAL' | 'CONFIDENTIAL';
}
@Processor('rag-ocr')
export class OcrProcessor extends WorkerHost {
private readonly logger = new Logger(OcrProcessor.name);
constructor(
@InjectQueue('rag-thai-preprocess') private readonly thaiQueue: Queue,
@InjectRepository(DocumentChunk)
private readonly chunkRepo: Repository<DocumentChunk>
) {
super();
}
async process(job: Job<OcrJobData>): Promise<void> {
const { attachmentPublicId, filePath } = job.data;
const existing = await this.chunkRepo.count({
where: { documentId: attachmentPublicId },
});
if (existing > 0) {
this.logger.log(
`rag-ocr job already indexed for ${attachmentPublicId}, skipping`
);
return;
}
await this.chunkRepo.manager.query(
`UPDATE attachments SET rag_status = 'PROCESSING' WHERE public_id = ?`,
[attachmentPublicId]
);
let rawText: string;
try {
rawText = fs.readFileSync(filePath, 'utf-8');
} catch {
rawText = `[binary:${attachmentPublicId}]`;
}
await this.thaiQueue.add(
'preprocess',
{ ...job.data, rawText },
{ jobId: `thai:${attachmentPublicId}` }
);
this.logger.log(`OCR enqueued thai-preprocess for ${attachmentPublicId}`);
}
}
@@ -1,56 +0,0 @@
import { Processor, WorkerHost, InjectQueue } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Queue, Job } from 'bullmq';
import axios from 'axios';
import { OcrJobData } from './ocr.processor';
export interface ThaiPreprocessJobData extends OcrJobData {
rawText: string;
}
export interface EmbeddingJobData extends ThaiPreprocessJobData {
normalizedText: string;
}
@Processor('rag-thai-preprocess')
export class ThaiPreprocessProcessor extends WorkerHost {
private readonly logger = new Logger(ThaiPreprocessProcessor.name);
private readonly thaiUrl: string;
constructor(
private readonly configService: ConfigService,
@InjectQueue('rag-embedding') private readonly embeddingQueue: Queue
) {
super();
this.thaiUrl = this.configService.get<string>(
'THAI_PREPROCESS_URL',
'http://localhost:8765'
);
}
async process(job: Job<ThaiPreprocessJobData>): Promise<void> {
const { rawText, attachmentPublicId } = job.data;
let normalizedText = rawText;
try {
const response = await axios.post<{ normalized: string }>(
`${this.thaiUrl}/normalize`,
{ text: rawText },
{ timeout: 30000 }
);
normalizedText = response.data.normalized ?? rawText;
} catch (err) {
this.logger.warn(
`Thai preprocess failed for ${attachmentPublicId}, using raw text: ${err instanceof Error ? err.message : String(err)}`
);
}
await this.embeddingQueue.add(
'embed',
{ ...job.data, normalizedText } as EmbeddingJobData,
{ jobId: `embed:${attachmentPublicId}` }
);
}
}
-179
View File
@@ -1,179 +0,0 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { QdrantClient } from '@qdrant/js-client-rest';
export interface VectorMetadata extends Record<string, unknown> {
chunk_id: string;
public_id: string;
project_public_id: string;
doc_type: string;
doc_number: string | null;
revision: string | null;
project_code: string;
classification: 'PUBLIC' | 'INTERNAL' | 'CONFIDENTIAL';
content_preview: string;
embedding_model: string;
}
export interface HybridSearchResult {
chunkId: string;
publicId: string;
docType: string;
docNumber: string | null;
revision: string | null;
projectCode: string;
contentPreview: string;
score: number;
}
const COLLECTION_NAME = 'lcbp3_vectors';
const VECTOR_SIZE = 768;
@Injectable()
export class QdrantService implements OnModuleInit {
private readonly logger = new Logger(QdrantService.name);
private client: QdrantClient;
private collectionReady = false;
constructor(private readonly configService: ConfigService) {
const url = this.configService.get<string>(
'QDRANT_URL',
'http://localhost:6333'
);
this.client = new QdrantClient({ url });
}
async onModuleInit(): Promise<void> {
try {
await this.initCollection();
this.collectionReady = true;
this.logger.log(`Qdrant collection '${COLLECTION_NAME}' ready`);
} catch (err) {
this.logger.error(
'Qdrant collection init failed — RAG queries will return 503',
err instanceof Error ? err.stack : String(err)
);
this.collectionReady = false;
}
}
isReady(): boolean {
return this.collectionReady;
}
private async initCollection(): Promise<void> {
const collections = await this.client.getCollections();
const exists = collections.collections.some(
(c) => c.name === COLLECTION_NAME
);
if (!exists) {
await this.client.createCollection(COLLECTION_NAME, {
vectors: { size: VECTOR_SIZE, distance: 'Cosine' },
hnsw_config: {
payload_m: 16,
m: 0,
},
optimizers_config: { indexing_threshold: 10000 },
});
this.logger.log(`Created Qdrant collection '${COLLECTION_NAME}'`);
await this.client.createPayloadIndex(COLLECTION_NAME, {
field_name: 'project_public_id',
field_schema: { type: 'keyword', is_tenant: true } as Parameters<
QdrantClient['createPayloadIndex']
>[1]['field_schema'],
});
await this.client.createPayloadIndex(COLLECTION_NAME, {
field_name: 'classification',
field_schema: 'keyword',
});
await this.client.createPayloadIndex(COLLECTION_NAME, {
field_name: 'doc_type',
field_schema: 'keyword',
});
await this.client.createPayloadIndex(COLLECTION_NAME, {
field_name: 'doc_number',
field_schema: 'keyword',
});
}
}
async upsertBatch(
points: Array<{ id: string; vector: number[]; payload: VectorMetadata }>
): Promise<void> {
await this.client.upsert(COLLECTION_NAME, {
wait: true,
points: points.map((p) => ({
id: p.id,
vector: p.vector,
payload: p.payload,
})),
});
}
async hybridSearch(
queryVector: number[],
projectPublicId: string,
classificationCeiling: 'PUBLIC' | 'INTERNAL' | 'CONFIDENTIAL',
topK: number
): Promise<HybridSearchResult[]> {
const classificationValues = this.getAllowedClassifications(
classificationCeiling
);
const vectorResults = await this.client.search(COLLECTION_NAME, {
vector: queryVector,
limit: topK,
filter: {
must: [
{ key: 'project_public_id', match: { value: projectPublicId } },
{ key: 'classification', match: { any: classificationValues } },
],
},
with_payload: true,
});
return vectorResults.map((r) => {
const payload = r.payload as unknown as VectorMetadata;
return {
chunkId: payload.chunk_id,
publicId: payload.public_id,
docType: payload.doc_type,
docNumber: payload.doc_number,
revision: payload.revision,
projectCode: payload.project_code,
contentPreview: payload.content_preview,
score: r.score,
};
});
}
async deleteByDocumentId(documentId: string): Promise<void> {
await this.client.delete(COLLECTION_NAME, {
wait: true,
filter: {
must: [{ key: 'public_id', match: { value: documentId } }],
},
});
}
async forceInitCollection(): Promise<void> {
await this.initCollection();
this.collectionReady = true;
this.logger.log(`Qdrant collection force-initialized`);
}
private getAllowedClassifications(
ceiling: 'PUBLIC' | 'INTERNAL' | 'CONFIDENTIAL'
): string[] {
const order: Array<'PUBLIC' | 'INTERNAL' | 'CONFIDENTIAL'> = [
'PUBLIC',
'INTERNAL',
'CONFIDENTIAL',
];
const ceilIdx = order.indexOf(ceiling);
return order.slice(0, ceilIdx + 1);
}
}
-93
View File
@@ -1,93 +0,0 @@
import {
Body,
Controller,
Delete,
Get,
Headers,
HttpCode,
HttpStatus,
Logger,
Param,
Post,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../common/guards/rbac.guard';
import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe';
import { UserService } from '../user/user.service';
import { User } from '../user/entities/user.entity';
import { RagQueryDto } from './dto/rag-query.dto';
import { RagService } from './rag.service';
@ApiTags('RAG')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RbacGuard)
@Throttle({ default: { limit: 30, ttl: 60000 } })
@Controller('rag')
export class RagController {
private readonly logger = new Logger(RagController.name);
constructor(
private readonly ragService: RagService,
private readonly userService: UserService
) {}
@Post('query')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'RAG Q&A — ค้นหาคำตอบจากเอกสารโครงการ' })
@RequirePermission('rag.query')
async query(
@Body() dto: RagQueryDto,
@CurrentUser() user: User,
@Headers('Idempotency-Key') idempotencyKey: string
) {
if (!idempotencyKey) {
this.logger.warn(`Missing Idempotency-Key from user ${user.user_id}`);
}
const permissions = await this.userService.getUserPermissions(user.user_id);
return this.ragService.query(dto, permissions);
}
@Get('status/:attachmentId')
@ApiOperation({ summary: 'ดูสถานะ RAG ingestion ของ attachment' })
@RequirePermission('rag.query')
async getStatus(@Param('attachmentId', ParseUuidPipe) attachmentId: string) {
return this.ragService.getStatus(attachmentId);
}
@Post('ingest/:attachmentId')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Re-ingest attachment ที่ FAILED (Admin only)' })
@RequirePermission('rag.manage')
async reIngest(@Param('attachmentId', ParseUuidPipe) attachmentId: string) {
await this.ragService.reIngest(attachmentId);
return { message: 'Re-ingestion queued' };
}
@Delete('vectors/:attachmentId')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'ลบ vectors ของ attachment ออกจาก Qdrant' })
@RequirePermission('rag.manage')
async deleteVectors(
@Param('attachmentId', ParseUuidPipe) attachmentId: string
) {
await this.ragService.deleteVectors(attachmentId);
}
@Post('admin/init-collection')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'T038: Init Qdrant collection lcbp3_vectors (admin only)',
})
@RequirePermission('rag.manage')
async initCollection() {
await this.ragService.initCollection();
return { message: 'Qdrant collection initialized' };
}
}
-58
View File
@@ -1,58 +0,0 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BullModule } from '@nestjs/bullmq';
import { ConfigModule } from '@nestjs/config';
import { DocumentChunk } from './entities/document-chunk.entity';
import { QUEUE_AI_VECTOR_DELETION } from '../common/constants/queue.constants';
import { EmbeddingService } from './embedding.service';
import { QdrantService } from './qdrant.service';
import { LocalLlmService } from './local-llm.service';
import { RagService } from './rag.service';
import { RagController } from './rag.controller';
import { IngestionService } from './ingestion.service';
import { OcrProcessor } from './processors/ocr.processor';
import { ThaiPreprocessProcessor } from './processors/thai-preprocess.processor';
import { EmbeddingProcessor } from './processors/embedding.processor';
import { UserModule } from '../user/user.module';
const DLQ_DEFAULTS = {
attempts: 3,
backoff: { type: 'exponential' as const, delay: 2000 },
removeOnComplete: 100,
removeOnFail: 200,
};
@Module({
imports: [
ConfigModule,
UserModule,
TypeOrmModule.forFeature([DocumentChunk]),
BullModule.registerQueue(
{ name: 'rag-ocr', defaultJobOptions: DLQ_DEFAULTS },
{ name: 'rag-thai-preprocess', defaultJobOptions: DLQ_DEFAULTS },
{ name: 'rag-embedding', defaultJobOptions: DLQ_DEFAULTS },
// T028: Producer สำหรับ dispatch vector deletion jobs (ADR-023 FR-008)
{ name: QUEUE_AI_VECTOR_DELETION }
),
],
controllers: [RagController],
providers: [
EmbeddingService,
QdrantService,
LocalLlmService,
RagService,
IngestionService,
OcrProcessor,
ThaiPreprocessProcessor,
EmbeddingProcessor,
],
exports: [
EmbeddingService,
QdrantService,
LocalLlmService,
RagService,
IngestionService,
],
})
export class RagModule {}
-263
View File
@@ -1,263 +0,0 @@
import {
Injectable,
Logger,
ServiceUnavailableException,
BadRequestException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { QUEUE_AI_VECTOR_DELETION } from '../common/constants/queue.constants';
import { AiVectorDeletionJobPayload } from '../ai/ai-queue.service';
import { InjectRedis } from '@nestjs-modules/ioredis';
import Redis from 'ioredis';
import { createHash } from 'crypto';
import { QdrantService } from './qdrant.service';
import { EmbeddingService } from './embedding.service';
import { LocalLlmService } from './local-llm.service';
import { IngestionService } from './ingestion.service';
import { DocumentChunk } from './entities/document-chunk.entity';
import { RagQueryDto } from './dto/rag-query.dto';
import { RagResponseDto, RagCitation } from './dto/rag-response.dto';
const CACHE_TTL_SECONDS = 300;
const PROMPT_CONTEXT_LIMIT = 3000;
@Injectable()
export class RagService {
private readonly logger = new Logger(RagService.name);
constructor(
private readonly qdrant: QdrantService,
private readonly embedding: EmbeddingService,
private readonly localLlm: LocalLlmService,
private readonly ingestionService: IngestionService,
@InjectRepository(DocumentChunk)
private readonly chunkRepo: Repository<DocumentChunk>,
@InjectRedis() private readonly redis: Redis,
@InjectQueue(QUEUE_AI_VECTOR_DELETION)
private readonly vectorDeletionQueue: Queue<AiVectorDeletionJobPayload>
) {}
async query(
dto: RagQueryDto,
userPermissions: string[]
): Promise<RagResponseDto> {
const { question, projectPublicId } = dto;
const classificationCeiling =
this.deriveClassificationCeiling(userPermissions);
const isConfidential = classificationCeiling === 'CONFIDENTIAL';
if (!this.qdrant.isReady()) {
throw new ServiceUnavailableException('RAG_NOT_READY');
}
const cacheKey = this.buildCacheKey(
question,
projectPublicId,
classificationCeiling
);
if (!isConfidential) {
const cached = await this.redis.get(cacheKey);
if (cached) {
const parsed = JSON.parse(cached) as RagResponseDto;
parsed.cachedAt = new Date().toISOString();
return parsed;
}
}
const queryVector = await this.embedding.embed(question);
const topK = 20;
const results = await this.qdrant.hybridSearch(
queryVector,
projectPublicId,
classificationCeiling,
topK
);
const reranked = results.sort((a, b) => b.score - a.score).slice(0, 5);
const context = this.buildContext(reranked);
const safeQuestion = this.localLlm.sanitizeInput(question);
const prompt = this.buildPrompt(safeQuestion, context);
const { answer, usedFallbackModel } = await this.localLlm.generate(prompt);
const citations: RagCitation[] = reranked.map((r) => ({
chunkId: r.chunkId,
docNumber: r.docNumber,
docType: r.docType,
revision: r.revision,
snippet: r.contentPreview.slice(0, 200),
score: r.score,
}));
const confidence = reranked.length > 0 ? reranked[0].score : 0;
const response: RagResponseDto = {
answer,
citations,
confidence,
usedFallbackModel,
};
if (!isConfidential) {
await this.redis.setex(
cacheKey,
CACHE_TTL_SECONDS,
JSON.stringify(response)
);
}
return response;
}
async getStatus(
attachmentPublicId: string
): Promise<{ ragStatus: string; chunkCount: number }> {
const chunkCount = await this.chunkRepo.count({
where: { documentId: attachmentPublicId },
});
const result = await this.chunkRepo.manager.query<{ rag_status: string }[]>(
`SELECT rag_status FROM attachments WHERE public_id = ? LIMIT 1`,
[attachmentPublicId]
);
const ragStatus = result[0]?.rag_status ?? 'PENDING';
return { ragStatus, chunkCount };
}
async reIngest(attachmentPublicId: string): Promise<void> {
const statusResult = await this.chunkRepo.manager.query<
{ rag_status: string; file_path: string }[]
>(
`SELECT rag_status, file_path FROM attachments WHERE public_id = ? LIMIT 1`,
[attachmentPublicId]
);
const current = statusResult[0]?.rag_status;
if (current !== 'FAILED') {
throw new BadRequestException(
`Cannot re-ingest: current status is '${current ?? 'unknown'}', expected 'FAILED'`
);
}
const sample = await this.chunkRepo.findOne({
where: { documentId: attachmentPublicId },
});
await this.chunkRepo.delete({ documentId: attachmentPublicId });
try {
await this.qdrant.deleteByDocumentId(attachmentPublicId);
} catch (err) {
this.logger.error(
`Qdrant delete failed for ${attachmentPublicId} — continuing`,
err instanceof Error ? err.stack : String(err)
);
}
await this.chunkRepo.manager.query(
`UPDATE attachments SET rag_status = 'PENDING', rag_last_error = NULL WHERE public_id = ?`,
[attachmentPublicId]
);
if (sample) {
await this.ingestionService.enqueue({
attachmentPublicId,
filePath: statusResult[0]?.file_path ?? '',
docType: sample.docType,
docNumber: sample.docNumber,
revision: sample.revision,
projectCode: sample.projectCode,
projectPublicId: sample.projectPublicId,
classification: sample.classification,
});
}
}
async initCollection(): Promise<void> {
await this.qdrant.onModuleInit();
}
async deleteVectors(
attachmentPublicId: string,
requestedByUserPublicId = 'system'
): Promise<void> {
// ลบ DocumentChunk ออกจาก DB แบบ synchronous (รวดเร็ว ไม่มี external dependency)
await this.chunkRepo.delete({ documentId: attachmentPublicId });
// T028: เปลี่ยน Qdrant deletion เป็น async ผ่าน BullMQ เพื่อ eventual consistency (FR-008)
await this.vectorDeletionQueue.add(
'delete-document-vectors',
{ documentPublicId: attachmentPublicId, requestedByUserPublicId },
{
jobId: attachmentPublicId,
attempts: 3,
backoff: { type: 'exponential', delay: 5000 },
}
);
this.logger.log(
`Vector deletion queued for attachment=${attachmentPublicId}`
);
}
buildContext(
results: Array<{
docType: string;
docNumber: string | null;
revision: string | null;
contentPreview: string;
}>
): string {
let context = '';
for (const r of results) {
const header = `[${r.docType}${r.docNumber ? ` - ${r.docNumber}` : ''}${r.revision ? ` - ${r.revision}` : ''}]`;
const snippet = `${header}\n${r.contentPreview}\n\n`;
if ((context + snippet).length > PROMPT_CONTEXT_LIMIT) break;
context += snippet;
}
return context.trim();
}
private buildPrompt(question: string, context: string): string {
return [
'คุณเป็นผู้ช่วยผู้เชี่ยวชาญด้านเอกสารโครงการก่อสร้าง',
'ตอบคำถามโดยอ้างอิงจากเอกสารที่ให้มาเท่านั้น ห้ามตอบจากความรู้ทั่วไป',
'หากข้อมูลในเอกสารไม่เพียงพอ ให้แจ้งว่า "ไม่พบข้อมูลในเอกสารที่ระบุ"',
'',
'=== เอกสารอ้างอิง ===',
context,
'',
'=== คำถาม ===',
question,
].join('\n');
}
private buildCacheKey(
question: string,
projectPublicId: string,
classificationCeiling: string
): string {
const raw = `${question}|${projectPublicId}|${classificationCeiling}`;
return `rag:query:${createHash('sha256').update(raw).digest('hex')}`;
}
private deriveClassificationCeiling(
permissions: string[]
): 'PUBLIC' | 'INTERNAL' | 'CONFIDENTIAL' {
if (
permissions.includes('system.manage_all') ||
permissions.includes('document.view_confidential')
) {
return 'CONFIDENTIAL';
}
return 'INTERNAL';
}
}