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:
@@ -16,7 +16,12 @@ function classifyFetchError(e: unknown): 'network' | 'timeout' | 'dns' | 'other'
|
|||||||
/**
|
/**
|
||||||
* v0.3.11 — vision model 이 'format:json' constraint 를 부분적으로 따라 markdown 코드
|
* v0.3.11 — vision model 이 'format:json' constraint 를 부분적으로 따라 markdown 코드
|
||||||
* 펜스 / prose 가 섞인 응답을 반환할 때 fallback. 첫 '{' ~ 마지막 '}' substring 만
|
* 펜스 / 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 {
|
function parseJsonLoose(raw: string): unknown {
|
||||||
try { return JSON.parse(raw); } catch { /* fallback below */ }
|
try { return JSON.parse(raw); } catch { /* fallback below */ }
|
||||||
@@ -26,7 +31,9 @@ function parseJsonLoose(raw: string): unknown {
|
|||||||
const slice = raw.slice(first, last + 1);
|
const slice = raw.slice(first, last + 1);
|
||||||
try { return JSON.parse(slice); } catch { /* fall through */ }
|
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 {
|
export interface LocalOllamaOptions {
|
||||||
@@ -74,7 +81,10 @@ export class LocalOllamaProvider implements InferenceProvider {
|
|||||||
prompt,
|
prompt,
|
||||||
format: 'json',
|
format: 'json',
|
||||||
stream: false,
|
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) {
|
if (useVision) {
|
||||||
body.images = input.images!.map((i) => i.base64);
|
body.images = input.images!.map((i) => i.base64);
|
||||||
|
|||||||
@@ -47,8 +47,8 @@ function validateDueDate(d: string | null | undefined): string | null {
|
|||||||
function coerceNullable(raw: unknown): unknown {
|
function coerceNullable(raw: unknown): unknown {
|
||||||
if (typeof raw !== 'object' || raw === null) return raw;
|
if (typeof raw !== 'object' || raw === null) return raw;
|
||||||
const obj = { ...(raw as Record<string, unknown>) };
|
const obj = { ...(raw as Record<string, unknown>) };
|
||||||
if (obj.title === null || obj.title === '') obj.title = '(첨부 메모)';
|
if (obj.title === null || obj.title === undefined || obj.title === '') obj.title = '(첨부 메모)';
|
||||||
if (obj.summary === null || obj.summary === '') obj.summary = '내용을 자동으로 정리하지 못했습니다.';
|
if (obj.summary === null || obj.summary === undefined || obj.summary === '') obj.summary = '내용을 자동으로 정리하지 못했습니다.';
|
||||||
// due_date 의 빈 string / regex mismatch 도 null 로 강제 (schema 가 거부하지 않게).
|
// due_date 의 빈 string / regex mismatch 도 null 로 강제 (schema 가 거부하지 않게).
|
||||||
if (obj.due_date === '' || (typeof obj.due_date === 'string' && !ISO_DATE_REGEX.test(obj.due_date))) {
|
if (obj.due_date === '' || (typeof obj.due_date === 'string' && !ISO_DATE_REGEX.test(obj.due_date))) {
|
||||||
obj.due_date = null;
|
obj.due_date = null;
|
||||||
|
|||||||
@@ -45,13 +45,30 @@ describe('LocalOllamaProvider', () => {
|
|||||||
expect(parsed.prompt).toContain('Prefer reusing');
|
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, {
|
mock.get('http://localhost:11434').intercept({ path: '/api/generate', method: 'POST' }).reply(200, {
|
||||||
response: 'not json'
|
response: 'not json'
|
||||||
});
|
});
|
||||||
await expect(
|
const r = await new LocalOllamaProvider().generate({ text: 'x', todayKst: '2026-04-26', dueDateCandidates: [] });
|
||||||
new LocalOllamaProvider().generate({ text: 'x', todayKst: '2026-04-26', dueDateCandidates: [] })
|
expect(r.title).toBe('(첨부 메모)');
|
||||||
).rejects.toThrow(/unparseable/i);
|
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 () => {
|
it('v0.3.11 — generate extracts JSON from markdown fence', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user