690603:2041 ADR-034-134 #01
CI / CD Pipeline / build (push) Failing after 4m28s
CI / CD Pipeline / deploy (push) Has been skipped

This commit is contained in:
2026-06-03 20:41:42 +07:00
parent 754d609399
commit 3274dede7a
197 changed files with 1575 additions and 42 deletions
@@ -7,12 +7,17 @@
// - 2026-05-27: เพิ่ม Mock สำหรับ getActive และ resolveContext ของ AiPromptsService เพื่อรองรับ Context-Aware Prompt (T017)
// - 2026-05-28: เพิ่ม test สำหรับ EC-001 (NEW_TAG_SUGGESTED) และ EC-002 (UNRESOLVED_SENDER/RECIPIENT_UUID)
// - 2026-05-29: แก้ไข mockAttachmentRepo เพิ่ม property manager เพื่อรองรับ jest.spyOn ใน EC-001, EC-002, และ migrate-document tests
// - 2026-06-03: ADR-034 — เพิ่ม OCR_JOB_TYPES import, mock unloadModel/loadModel/getOcrModelName, อัปเดต getMainModelName เป็น typhoon2.5, เพิ่ม test ocr-extract model switching
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Job } from 'bullmq';
import { AiBatchProcessor, AiBatchJobData } from './ai-batch.processor';
import {
AiBatchProcessor,
AiBatchJobData,
OCR_JOB_TYPES,
} from './ai-batch.processor';
import { EmbeddingService } from '../services/embedding.service';
import { AiRagService } from '../ai-rag.service';
import { Attachment } from '../../../common/file-storage/entities/attachment.entity';
@@ -57,7 +62,10 @@ describe('AiBatchProcessor', () => {
}),
};
const mockOllamaService = {
getMainModelName: jest.fn().mockReturnValue('gemma4:e4b'),
getMainModelName: jest.fn().mockReturnValue('typhoon2.5-np-dms:latest'),
getOcrModelName: jest.fn().mockReturnValue('typhoon-np-dms-ocr:latest'),
loadModel: jest.fn().mockResolvedValue(true),
unloadModel: jest.fn().mockResolvedValue(true),
generate: jest.fn().mockResolvedValue(
JSON.stringify({
documentNumber: 'LCBP3-CIV-001',
@@ -174,6 +182,49 @@ describe('AiBatchProcessor', () => {
attachmentRepo = module.get(getRepositoryToken(Attachment));
jest.clearAllMocks();
});
it('OCR_JOB_TYPES ควรมี ocr-extract เป็นสมาชิก (ADR-034)', () => {
expect(OCR_JOB_TYPES).toContain('ocr-extract');
});
it('ocr-extract: ควร unload main → load OCR (keep_alive:0) → generate → reload main (ADR-034)', async () => {
const job = {
id: 'job-ocr-extract',
data: {
jobType: 'ocr-extract',
documentPublicId: 'doc-ocr-uuid-001',
projectPublicId: 'proj-uuid-456',
payload: { prompt: 'Extract OCR text from this document.' },
idempotencyKey: 'idem-ocr-001',
},
} as unknown as Job<AiBatchJobData>;
await processor.process(job);
expect(mockOllamaService.unloadModel).toHaveBeenCalledWith(
'typhoon2.5-np-dms:latest'
);
expect(mockOllamaService.loadModel).toHaveBeenCalledWith(
'typhoon-np-dms-ocr:latest',
0
);
expect(mockOllamaService.generate).toHaveBeenCalledWith(
'Extract OCR text from this document.',
expect.objectContaining({
model: 'typhoon-np-dms-ocr:latest',
timeoutMs: 120000,
})
);
expect(mockOllamaService.loadModel).toHaveBeenCalledWith(
'typhoon2.5-np-dms:latest',
-1
);
expect(mockRedis.setex).toHaveBeenCalledWith(
'ai:ocr:result:doc-ocr-uuid-001',
3600,
expect.stringContaining('typhoon-np-dms-ocr:latest')
);
expect(attachmentRepo.update).toHaveBeenCalledWith(
{ publicId: 'doc-ocr-uuid-001' },
{ aiProcessingStatus: 'DONE' }
);
});
it('ควรสามารถเรียก process embed-document และอัปเดตสถานะใน database', async () => {
const job = {
id: 'job-embed',
@@ -9,6 +9,7 @@
// - 2026-05-25: เพิ่ม AiPromptsService เพื่อดึง Dynamic Prompt สำหรับ OCR extraction ใน sandbox และ migration pipeline
// - 2026-05-26: แก้ไข bug lockDuration=30000ms ทำให้ sandbox-extract job stall เมื่อ Ollama ใช้เวลา >30s — เพิ่ม lockDuration: 150000
// - 2026-05-28: EC-001 ใช้ findOrSuggestTags เพื่อตรวจจับ Tag ใหม่และบันทึก aiIssues; EC-002 ตรวจสอบ UUID ของผู้ส่ง/ผู้รับ และ Flag เมื่อหาไม่พบ
// - 2026-06-03: ADR-034 — เพิ่ม 'ocr-extract' job type + OCR_JOB_TYPES constant + processOcrExtract() ที่มี model switching logic (unload main → load OCR → generate → reload main)
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
@@ -49,6 +50,7 @@ interface MigrateDocumentMetadata extends Record<string, unknown> {
export type AiBatchJobType =
| 'ocr'
| 'ocr-extract'
| 'extract-metadata'
| 'embed-document'
| 'sandbox-rag'
@@ -57,6 +59,11 @@ export type AiBatchJobType =
| 'sandbox-ai-extract'
| 'migrate-document';
/** รายการ job types ที่ต้องใช้ Typhoon OCR model — จะ trigger model switching (ADR-034) */
export const OCR_JOB_TYPES: ReadonlyArray<AiBatchJobType> = [
'ocr-extract',
] as const;
export interface AiBatchJobData {
jobType: AiBatchJobType;
documentPublicId: string;
@@ -177,6 +184,13 @@ export class AiBatchProcessor extends WorkerHost {
await this.setAiProcessingStatus(job.data.documentPublicId, 'DONE');
}
return;
case 'ocr-extract':
this.logger.log(
`OCR-extract (Typhoon OCR) job processing — jobId=${String(job.id)}`
);
await this.processOcrExtract(job.data);
await this.setAiProcessingStatus(job.data.documentPublicId, 'DONE');
return;
case 'extract-metadata':
this.logger.log(
`Metadata extraction job processing — jobId=${String(job.id)}`
@@ -296,6 +310,45 @@ export class AiBatchProcessor extends WorkerHost {
);
}
/** ประมวลผล ocr-extract job ด้วย Typhoon OCR model — model switching ตาม ADR-034:
* unload main → load OCR (keep_alive:0) → generate OCR → OCR auto-unloads → reload main */
private async processOcrExtract(data: AiBatchJobData): Promise<void> {
const { documentPublicId, payload } = data;
const mainModel = this.ollamaService.getMainModelName();
const ocrModel = this.ollamaService.getOcrModelName();
const prompt = (payload.prompt as string) || '';
this.logger.log(
`[ModelSwitch] Unloading ${mainModel} — documentPublicId=${documentPublicId}`
);
await this.ollamaService.unloadModel(mainModel);
this.logger.log(`[ModelSwitch] Loading ${ocrModel} (keep_alive:0)`);
await this.ollamaService.loadModel(ocrModel, 0);
let ocrText = '';
try {
this.logger.log(`[ModelSwitch] Running OCR extraction with ${ocrModel}`);
ocrText = await this.ollamaService.generate(prompt, {
model: ocrModel,
timeoutMs: 120000,
});
} finally {
this.logger.log(`[ModelSwitch] Reloading ${mainModel} (keep_alive:-1)`);
await this.ollamaService.loadModel(mainModel, -1);
}
await this.redis.setex(
`ai:ocr:result:${documentPublicId}`,
3600,
JSON.stringify({
documentPublicId,
ocrText,
model: ocrModel,
completedAt: new Date().toISOString(),
})
);
this.logger.log(
`[ModelSwitch] OCR-extract complete — documentPublicId=${documentPublicId}`
);
}
/** ประมวลผล sandbox OCR + Metadata extraction โดยไม่บันทึกลง database */
private async processSandboxExtract(data: AiBatchJobData): Promise<void> {
const { idempotencyKey, payload, projectPublicId } = data;
@@ -1,6 +1,7 @@
// File: src/modules/ai/processors/ai-realtime.processor.ts
// Change Log
// - 2026-05-15: เพิ่ม processor สำหรับ ai-realtime queue และ pause/resume ai-batch ตาม ADR-023A.
// - 2026-06-03: ADR-034 — เปลี่ยน aiModel ใน audit log จาก hardcode 'gemma4' เป็น ollamaService.getMainModelName()
import {
Processor,
@@ -113,7 +114,7 @@ export class AiRealtimeProcessor extends WorkerHost {
await this.aiAuditLogRepo.save(
this.aiAuditLogRepo.create({
documentPublicId: job.data.documentPublicId,
aiModel: 'gemma4',
aiModel: this.ollamaService.getMainModelName(),
modelName: this.ollamaService.getMainModelName(),
aiSuggestionJson: normalizedSuggestion,
confidenceScore: this.extractConfidence(normalizedSuggestion),
@@ -135,7 +136,7 @@ export class AiRealtimeProcessor extends WorkerHost {
await this.aiAuditLogRepo.save(
this.aiAuditLogRepo.create({
documentPublicId: job.data.documentPublicId,
aiModel: 'gemma4',
aiModel: this.ollamaService.getMainModelName(),
modelName: this.ollamaService.getMainModelName(),
processingTimeMs: Date.now() - startTime,
status: AiAuditStatus.FAILED,