690606:1705 ADR-035-135 #06
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user