diff --git a/src/main/ai/LocalOllamaProvider.ts b/src/main/ai/LocalOllamaProvider.ts index ca510da..e08d54e 100644 --- a/src/main/ai/LocalOllamaProvider.ts +++ b/src/main/ai/LocalOllamaProvider.ts @@ -16,7 +16,12 @@ function classifyFetchError(e: unknown): 'network' | 'timeout' | 'dns' | 'other' /** * v0.3.11 — vision model 이 'format:json' constraint 를 부분적으로 따라 markdown 코드 * 펜스 / prose 가 섞인 응답을 반환할 때 fallback. 첫 '{' ~ 마지막 '}' substring 만 - * 추출해서 JSON.parse 재시도. 실패하면 raw response 일부 포함한 에러 throw (디버깅용). + * 추출해서 JSON.parse 재시도. + * + * v0.3.14 — fail 시 throw 대신 `{}` 반환. schema 의 graceful coerce 가 빈 객체를 + * placeholder title/summary 로 채움 → 사용자 데이터 손실 없이 노트 보관 (raw_text 그대로). + * 모델이 repetition loop 로 num_predict cap 도달해 JSON truncate 된 케이스에 robust. + * 원본 응답 snippet 은 console.warn 으로 로그 (디버그성). */ function parseJsonLoose(raw: string): unknown { try { return JSON.parse(raw); } catch { /* fallback below */ } @@ -26,7 +31,9 @@ function parseJsonLoose(raw: string): unknown { const slice = raw.slice(first, last + 1); try { return JSON.parse(slice); } catch { /* fall through */ } } - throw new Error(`unparseable response: ${raw.slice(0, 200).replace(/\s+/g, ' ')}`); + // 빈 객체 반환 → schema coerce 로 placeholder 적용. 원본 일부는 stderr 에 남김. + console.warn(`[LocalOllamaProvider] unparseable response, falling back to {}: ${raw.slice(0, 200).replace(/\s+/g, ' ')}`); + return {}; } export interface LocalOllamaOptions { @@ -74,7 +81,10 @@ export class LocalOllamaProvider implements InferenceProvider { prompt, format: 'json', stream: false, - options: { temperature: this.temperature, num_predict: this.numPredict } + // v0.3.14 — repeat_penalty 추가. vision 모델 (특히 gemma 시리즈) 이 "기기기기..." + // 같은 repetition loop 에 빠져 num_predict cap 도달 → JSON truncate → unparseable. + // 1.15 는 Ollama 권장 범위 (1.0-1.3) 안쪽 conservative 값. + options: { temperature: this.temperature, num_predict: this.numPredict, repeat_penalty: 1.15 } }; if (useVision) { body.images = input.images!.map((i) => i.base64); diff --git a/src/main/ai/schema.ts b/src/main/ai/schema.ts index 61e35e4..09ebd1b 100644 --- a/src/main/ai/schema.ts +++ b/src/main/ai/schema.ts @@ -47,8 +47,8 @@ function validateDueDate(d: string | null | undefined): string | null { function coerceNullable(raw: unknown): unknown { if (typeof raw !== 'object' || raw === null) return raw; const obj = { ...(raw as Record) }; - if (obj.title === null || obj.title === '') obj.title = '(첨부 메모)'; - if (obj.summary === null || obj.summary === '') obj.summary = '내용을 자동으로 정리하지 못했습니다.'; + if (obj.title === null || obj.title === undefined || obj.title === '') obj.title = '(첨부 메모)'; + if (obj.summary === null || obj.summary === undefined || obj.summary === '') obj.summary = '내용을 자동으로 정리하지 못했습니다.'; // due_date 의 빈 string / regex mismatch 도 null 로 강제 (schema 가 거부하지 않게). if (obj.due_date === '' || (typeof obj.due_date === 'string' && !ISO_DATE_REGEX.test(obj.due_date))) { obj.due_date = null; diff --git a/tests/unit/LocalOllamaProvider.test.ts b/tests/unit/LocalOllamaProvider.test.ts index 8672021..2270396 100644 --- a/tests/unit/LocalOllamaProvider.test.ts +++ b/tests/unit/LocalOllamaProvider.test.ts @@ -45,13 +45,30 @@ describe('LocalOllamaProvider', () => { expect(parsed.prompt).toContain('Prefer reusing'); }); - it('generate throws on non-JSON', async () => { + it('v0.3.14 — generate falls back to placeholder when JSON unparseable', async () => { + // 이전엔 throw 했지만 schema graceful coerce 추가 후 placeholder 채워서 통과. + // truncated / repetition-loop 응답에서 사용자 데이터 (raw_text) 무손실 보존. mock.get('http://localhost:11434').intercept({ path: '/api/generate', method: 'POST' }).reply(200, { response: 'not json' }); - await expect( - new LocalOllamaProvider().generate({ text: 'x', todayKst: '2026-04-26', dueDateCandidates: [] }) - ).rejects.toThrow(/unparseable/i); + const r = await new LocalOllamaProvider().generate({ text: 'x', todayKst: '2026-04-26', dueDateCandidates: [] }); + expect(r.title).toBe('(첨부 메모)'); + expect(r.summary.length).toBeGreaterThan(0); + }); + + it('v0.3.14 — body 에 repeat_penalty 포함 (repetition loop 방지)', async () => { + let capturedBody: string = ''; + mock.get('http://localhost:11434').intercept({ + path: '/api/generate', method: 'POST' + }).reply((opts) => { + capturedBody = opts.body as string; + return { statusCode: 200, data: JSON.stringify({ + response: JSON.stringify({ title: '회의', summary: 'a\nb\nc', tags: [] }) + }) }; + }); + await new LocalOllamaProvider().generate({ text: 'x', todayKst: '2026-04-26', dueDateCandidates: [] }); + const parsed = JSON.parse(capturedBody) as { options: { repeat_penalty: number } }; + expect(parsed.options.repeat_penalty).toBe(1.15); }); it('v0.3.11 — generate extracts JSON from markdown fence', async () => {