690606:1705 ADR-035-135 #06
CI / CD Pipeline / build (push) Successful in 5m19s
CI / CD Pipeline / deploy (push) Successful in 3m11s

This commit is contained in:
2026-06-06 17:05:51 +07:00
parent 15dec6c3fc
commit 16aab2279c
15 changed files with 86 additions and 467 deletions
@@ -309,7 +309,13 @@ describe('AiBatchProcessor', () => {
'/files/test.pdf',
'auto'
);
expect(ollamaService.generate).toHaveBeenCalledTimes(1);
expect(ollamaService.generate).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
format: 'json',
timeoutMs: 120000,
})
);
expect(redis.setex).toHaveBeenCalledTimes(2);
expect(redis.setex).toHaveBeenLastCalledWith(
'ai:rag:result:idem-extract-123',
@@ -362,6 +368,7 @@ describe('AiBatchProcessor', () => {
1,
expect.not.stringContaining('\u0002'),
expect.objectContaining({
format: 'json',
timeoutMs: 120000,
})
);
@@ -12,6 +12,7 @@
// - 2026-06-03: ADR-034 — เพิ่ม 'ocr-extract' job type + OCR_JOB_TYPES constant + processOcrExtract() ที่มี model switching logic (unload main → load OCR → generate → reload main)
// - 2026-06-06: แก้ไข bug LLM JSON parse failure — เพิ่ม retry logic (2 attempts), debug log raw response, และปรับปรุง error message ให้แสดงทั้ง raw และ cleaned response
// - 2026-06-06: เพิ่ม OCR text truncation (MAX_OCR_TEXT_CHARS=15000) เพื่อป้องกัน context overflow เมื่อเอกสารยาวมากชน num_ctx 8192
// - 2026-06-06: [T036] เพิ่ม ollamaOptions: { num_ctx: 8192 } ใน generateStructuredJson เพื่อรองรับ prompt ยาว 18k+ chars และแก้ไข bug response ว่างจาก context window ไม่พอ
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
@@ -197,10 +198,18 @@ export class AiBatchProcessor extends WorkerHost {
super();
}
/** เรียก LLM แล้ว parse JSON แบบ retry จริงเมื่อได้ผลลัพธ์ไม่สมบูรณ์ */
/** LLM parse JSON retry
* @param ollamaOptions - Ollama generation options num_ctx prompt
*/
private async generateStructuredJson(
prompt: string,
options: { timeoutMs: number; model?: string; system?: string }
options: {
timeoutMs: number;
model?: string;
system?: string;
format?: 'json';
ollamaOptions?: { num_ctx?: number };
}
): Promise<{
extractedMetadata: Record<string, unknown>;
rawResponse: string;
@@ -209,7 +218,10 @@ export class AiBatchProcessor extends WorkerHost {
let lastRawResponse = '';
let lastCleanedResponse = '';
for (let attempt = 1; attempt <= MAX_JSON_PARSE_ATTEMPTS; attempt += 1) {
const rawResponse = await this.ollamaService.generate(prompt, options);
const rawResponse = await this.ollamaService.generate(prompt, {
...options,
options: options.ollamaOptions,
});
const cleanedResponse = sanitizeLlmJsonResponse(rawResponse);
lastRawResponse = rawResponse;
lastCleanedResponse = cleanedResponse;
@@ -498,6 +510,7 @@ export class AiBatchProcessor extends WorkerHost {
activePrompt,
overrideProjPublicId === 'default' ? undefined : overrideProjPublicId
);
const compactMasterDataContext = JSON.stringify(masterDataContext);
const ocrTextSafe =
sanitizedOcrText.length > MAX_OCR_TEXT_CHARS
@@ -509,19 +522,18 @@ export class AiBatchProcessor extends WorkerHost {
const resolvedPrompt = activePrompt.template
.replace('{{ocr_text}}', ocrTextSafe)
.replace(
'{{master_data_context}}',
JSON.stringify(masterDataContext, null, 2)
);
.replace('{{master_data_context}}', compactMasterDataContext);
this.logger.debug(
`Prompt stats: OCR=${ocrTextSafe.length} chars, MasterData=${JSON.stringify(masterDataContext, null, 2).length} chars, Total=${resolvedPrompt.length} chars`
`Prompt stats: OCR=${ocrTextSafe.length} chars, MasterData=${compactMasterDataContext.length} chars, Total=${resolvedPrompt.length} chars`
);
const { extractedMetadata } = await this.generateStructuredJson(
resolvedPrompt,
{
format: 'json',
timeoutMs: 120000,
ollamaOptions: { num_ctx: 8192 }, // รองรับ prompt ยาว 18k+ chars
}
);
await this.aiPromptsService.saveTestResult(
@@ -704,6 +716,7 @@ export class AiBatchProcessor extends WorkerHost {
targetPrompt,
projectPublicId === 'default' ? undefined : projectPublicId
);
const compactMasterDataContext = JSON.stringify(masterDataContext);
const ocrTextSafe =
ocrText.length > MAX_OCR_TEXT_CHARS
@@ -715,17 +728,16 @@ export class AiBatchProcessor extends WorkerHost {
const resolvedPrompt = targetPrompt.template
.replace('{{ocr_text}}', ocrTextSafe)
.replace(
'{{master_data_context}}',
JSON.stringify(masterDataContext, null, 2)
);
.replace('{{master_data_context}}', compactMasterDataContext);
this.logger.debug(
`Prompt stats: OCR=${ocrTextSafe.length} chars, MasterData=${JSON.stringify(masterDataContext, null, 2).length} chars, Total=${resolvedPrompt.length} chars`
`Prompt stats: OCR=${ocrTextSafe.length} chars, MasterData=${compactMasterDataContext.length} chars, Total=${resolvedPrompt.length} chars`
);
const { extractedMetadata } = await this.generateStructuredJson(
resolvedPrompt,
{
format: 'json',
timeoutMs: 120000,
ollamaOptions: { num_ctx: 8192 }, // รองรับ prompt ยาว 18k+ chars
}
);
@@ -57,6 +57,19 @@ describe('OllamaService (ADR-034)', () => {
expect.anything()
);
});
it('ควรส่ง format=json เมื่อ caller ต้องการ structured output', async () => {
mockedAxios.post = jest
.fn()
.mockResolvedValueOnce({ data: { response: '{"ok":true}' } });
await service.generate('json prompt', {
format: 'json',
});
expect(mockedAxios.post).toHaveBeenCalledWith(
expect.stringContaining('/api/generate'),
expect.objectContaining({ format: 'json' }),
expect.anything()
);
});
it('ควรใช้ options.model เมื่อระบุ model อื่น (ADR-034 model switching)', async () => {
mockedAxios.post = jest
.fn()
@@ -5,6 +5,7 @@
// - 2026-06-02: เพิ่ม loadModel() preloading, ดึงจริงจาก /api/ps และเพิ่ม unloadModel() เพื่อล้างหน่วยความจำ GPU/VRAM (ADR-033, Suggestion 1)
// - 2026-06-03: ADR-034 — เปลี่ยน default model เป็น typhoon2.5-np-dms; เพิ่ม ocrModel field, keepAlive param ใน loadModel(), model option ใน OllamaGenerateOptions, getOcrModelName()
// - 2026-06-06: เพิ่ม system prompt support ใน OllamaGenerateOptions และ generate() method เพื่อรองรับ Typhoon model ที่ต้องการ system prompt แยกต่างหาก
// - 2026-06-06: [T036] แก้ไข default URL เป็น http://192.168.10.100:11434 (Desk-5439) แทน localhost; เพิ่ม options และ keepAlive ใน OllamaGenerateOptions เพื่อรองรับ Typhoon model parameters
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@@ -17,6 +18,18 @@ export interface OllamaGenerateOptions {
model?: string;
/** System prompt สำหรับ Typhoon model ที่ต้องการ system prompt แยกต่างหาก (ใช้ triple quotes) */
system?: string;
/** บังคับ structured output จาก Ollama สำหรับงานที่ต้อง parse JSON */
format?: 'json';
/** Ollama generation options (temperature, top_p, etc.) */
options?: {
temperature?: number;
top_p?: number;
repeat_penalty?: number;
num_gpu?: number;
num_ctx?: number;
};
/** keep_alive: -1 = stay loaded, 0 = unload immediately, N = seconds */
keepAlive?: number;
}
/** บริการเรียก Ollama local-only บน Admin Desktop ตาม ADR-023A */
@@ -32,7 +45,10 @@ export class OllamaService {
constructor(private readonly configService: ConfigService) {
this.ollamaUrl = this.configService.get<string>(
'OLLAMA_URL',
this.configService.get<string>('AI_HOST_URL', 'http://localhost:11434')
this.configService.get<string>(
'AI_HOST_URL',
'http://192.168.10.100:11434'
)
);
this.mainModel = this.configService.get<string>(
'OLLAMA_MODEL_MAIN',
@@ -61,7 +77,10 @@ export class OllamaService {
model: options.model ?? this.mainModel,
prompt,
system: options.system,
format: options.format,
stream: false,
options: options.options,
keep_alive: options.keepAlive ?? -1,
},
{
timeout: options.timeoutMs ?? this.timeoutMs,