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

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