fix(vision): repetition loop 대응 + parseJsonLoose graceful fallback

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) <noreply@anthropic.com>
This commit is contained in:
th-kim0823
2026-05-12 14:39:54 +09:00
parent 218868206b
commit c616555d7d
3 changed files with 36 additions and 9 deletions

View File

@@ -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);

View File

@@ -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<string, unknown>) };
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;

View File

@@ -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 () => {