From c616555d7d80c90b2f4d7649dad052beaf075744 Mon Sep 17 00:00:00 2001
From: th-kim0823
Date: Tue, 12 May 2026 14:39:54 +0900
Subject: [PATCH] =?UTF-8?q?fix(vision):=20repetition=20loop=20=EB=8C=80?=
=?UTF-8?q?=EC=9D=91=20+=20parseJsonLoose=20graceful=20fallback?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
gemma4:26b 가 "기기기기..." repetition loop 에 빠져 num_predict cap 도달 →
JSON truncate → unparseable. 두 가지 fix:
- Ollama body 에 repeat_penalty: 1.15 추가 (token repetition 억제)
- parseJsonLoose fail 시 throw 대신 {} 반환 → schema graceful coerce 가
placeholder title/summary 채움. raw_text 는 보존 → 사용자 데이터 무손실.
- coerceNullable 가 undefined 도 처리 (빈 객체 케이스).
Co-Authored-By: Claude Opus 4.7 (1M context)
---
src/main/ai/LocalOllamaProvider.ts | 16 +++++++++++++---
src/main/ai/schema.ts | 4 ++--
tests/unit/LocalOllamaProvider.test.ts | 25 +++++++++++++++++++++----
3 files changed, 36 insertions(+), 9 deletions(-)
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 () => {