From 713553a0387c6a1e0ab8d581db5d97098a32c979 Mon Sep 17 00:00:00 2001 From: th-kim0823 Date: Tue, 12 May 2026 13:32:12 +0900 Subject: [PATCH] =?UTF-8?q?chore(release):=20v0.3.12=20=E2=80=94=20vision?= =?UTF-8?q?=20AI=20=EC=9D=91=EB=8B=B5=20robust=20parse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vision model 의 markdown fence / prose 섞인 응답에서 JSON 추출 fallback. prompt 에 title 한국어 / kebab tags / JSON-only 출력 명시 강화. - LocalOllamaProvider: parseJsonLoose 헬퍼 (첫 { ~ 마지막 } 추출) - visionPrompt: 4 규칙 + markdown fence 금지 명시 - 단위 +2 (fence 추출 + prose 추출) Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 22 ++++++++++++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- src/main/ai/LocalOllamaProvider.ts | 21 ++++++++++++++++++--- src/main/ai/visionPrompt.ts | 14 ++++++++++++-- tests/unit/LocalOllamaProvider.test.ts | 19 ++++++++++++++++++- 6 files changed, 73 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25796ab..57183f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,28 @@ 본 파일은 Inkling 의 버전별 사용자 영향 변경 사항을 기록한다. 형식은 [Keep a Changelog](https://keepachangelog.com/) 를 느슨하게 따른다. +## [0.3.12] — 2026-05-12 + +이미지 AI 처리 실패 fix. vision model 의 응답이 strict JSON 이 아닌 경우 (markdown fence / prose 섞임) 가 흔해 schema parse 단계에서 throw → `ai_status='failed'` 도달. + +### 수정 + +- **Vision model 응답 JSON loose parse (P1).** `LocalOllamaProvider.generate` 의 `JSON.parse` 가 strict 라 vision-tuned 모델의 markdown 코드 펜스 / 앞뒤 prose 응답에서 throw. `parseJsonLoose` 헬퍼 추가 — 첫 `{` ~ 마지막 `}` substring 추출 fallback. 실패 시 raw response 200자 snippet 포함한 에러 throw (디버깅 가시화). +- **Vision prompt 강화 (P1).** `buildVisionPrompt` 가 "title 한국어로 요약" 만 명시 → schema 의 KOREAN_REGEX/KEBAB_REGEX 강제와 불일치. 규칙 4건 명시: title 한국어 60자, summary 3줄, tags 영문 kebab-case 3개, due_date ISO 또는 null. "markdown 코드 펜스 금지" 명시로 JSON-only 출력 강제. + +알려진 한계: +- `gemma4:26b` 같이 Ollama 에 실제로 release 안 된 모델명 사용 시 healthCheck 통과해도 generate 가 unknown model 로 throw. 모델 설치 여부는 사용자가 `ollama list` 확인 필요. + +### 게이트 + +- 단위 750 → **752 PASS** (+2: markdown fence 추출 + prose 추출) +- typecheck 0 errors +- 신규 npm dependency 0 + +### 업그레이드 + +v0.3.11 인스톨러 위에 v0.3.12 인스톨러를 같은 위치에 실행하면 in-place 업그레이드. 데이터/마이그레이션 변경 없음. + ## [0.3.11] — 2026-05-12 붙여넣은 이미지 / 저장된 이미지가 양쪽 창에서 표시 안 되던 CSP 누락 hotfix. diff --git a/package-lock.json b/package-lock.json index 2834435..f2666a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "inkling", - "version": "0.3.11", + "version": "0.3.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "inkling", - "version": "0.3.11", + "version": "0.3.12", "dependencies": { "better-sqlite3": "12.9.0", "electron-log": "5.2.0", diff --git a/package.json b/package.json index 25b3068..3773ff8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "inkling", - "version": "0.3.11", + "version": "0.3.12", "private": true, "description": "Inkling — local-first 한 줄 보관 도구", "author": "altair823 ", diff --git a/src/main/ai/LocalOllamaProvider.ts b/src/main/ai/LocalOllamaProvider.ts index 8ad96fb..818df67 100644 --- a/src/main/ai/LocalOllamaProvider.ts +++ b/src/main/ai/LocalOllamaProvider.ts @@ -13,6 +13,22 @@ function classifyFetchError(e: unknown): 'network' | 'timeout' | 'dns' | 'other' return 'other'; } +/** + * v0.3.11 — vision model 이 'format:json' constraint 를 부분적으로 따라 markdown 코드 + * 펜스 / prose 가 섞인 응답을 반환할 때 fallback. 첫 '{' ~ 마지막 '}' substring 만 + * 추출해서 JSON.parse 재시도. 실패하면 raw response 일부 포함한 에러 throw (디버깅용). + */ +function parseJsonLoose(raw: string): unknown { + try { return JSON.parse(raw); } catch { /* fallback below */ } + const first = raw.indexOf('{'); + const last = raw.lastIndexOf('}'); + if (first >= 0 && last > first) { + 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, ' ')}`); +} + export interface LocalOllamaOptions { endpoint?: string; model?: string; @@ -70,9 +86,8 @@ export class LocalOllamaProvider implements InferenceProvider { } const responseBody = (await res.body.json()) as { response?: string }; if (!responseBody.response) throw new Error('missing response field'); - let parsed: unknown; - try { parsed = JSON.parse(responseBody.response); } - catch (err) { throw new Error(`invalid json in response: ${String(err)}`); } + // v0.3.11 — vision model 응답이 markdown fence / prose 섞인 경우 fallback 추출. + const parsed = parseJsonLoose(responseBody.response); return parseAiResponse(parsed); } finally { clearTimeout(timer); diff --git a/src/main/ai/visionPrompt.ts b/src/main/ai/visionPrompt.ts index 0858ab9..7a93989 100644 --- a/src/main/ai/visionPrompt.ts +++ b/src/main/ai/visionPrompt.ts @@ -4,13 +4,23 @@ export function buildVisionPrompt( dueCandidates: string[], vocab: string[] ): string { + // v0.3.11 — vision model 이 'format:json' constraint 를 부분적으로만 따르는 경우가 + // 잦음 (특히 gemma3 vision). title 한국어 + JSON only 를 prompt 에서 명시 강조, + // markdown fence 금지 표기로 schema parse 통과율 개선. return `다음 메모와 첨부 이미지를 종합 분석해 한국어로 요약하세요. 메모 본문 (비어 있을 수 있음): ${text || '(이미지만 있음)'} -이미지 분석 시 주요 시각적 정보 (텍스트, 사람, 장면) 도 포함해 요약하세요. -출력 JSON: { "title": "...", "summary": "...", "tags": [...], "due_date": "..." } +규칙: +- "title" 은 반드시 한국어로 (영어 금지). 60자 이내. +- "summary" 는 3줄. 이미지 시각 정보 (텍스트/사람/장면) 포함. +- "tags" 는 영문 kebab-case (예: meeting-notes), 최대 3개. 한국어 태그 금지. +- "due_date" 는 ISO 형식 YYYY-MM-DD 또는 null. + +오직 JSON 객체 하나만 출력. markdown 코드 펜스 (\`\`\`) / 설명 prose 금지. +출력 형식: {"title":"...","summary":"...","tags":[],"due_date":null} + 오늘: ${todayKst} 가능한 due 후보: ${dueCandidates.join(', ')} 빈출 태그: ${vocab.slice(0, 20).join(', ')}`; diff --git a/tests/unit/LocalOllamaProvider.test.ts b/tests/unit/LocalOllamaProvider.test.ts index b69da29..8672021 100644 --- a/tests/unit/LocalOllamaProvider.test.ts +++ b/tests/unit/LocalOllamaProvider.test.ts @@ -51,7 +51,24 @@ describe('LocalOllamaProvider', () => { }); await expect( new LocalOllamaProvider().generate({ text: 'x', todayKst: '2026-04-26', dueDateCandidates: [] }) - ).rejects.toThrow(/json/i); + ).rejects.toThrow(/unparseable/i); + }); + + it('v0.3.11 — generate extracts JSON from markdown fence', async () => { + // vision model 이 ```json ... ``` 형태로 응답하는 경우 fallback 으로 추출. + mock.get('http://localhost:11434').intercept({ path: '/api/generate', method: 'POST' }).reply(200, { + response: '```json\n{"title":"회의","summary":"a\\nb\\nc","tags":["meet"]}\n```' + }); + const r = await new LocalOllamaProvider().generate({ text: 'x', todayKst: '2026-04-26', dueDateCandidates: [] }); + expect(r.title).toBe('회의'); + }); + + it('v0.3.11 — generate extracts JSON when prose 가 앞뒤로 섞임', async () => { + mock.get('http://localhost:11434').intercept({ path: '/api/generate', method: 'POST' }).reply(200, { + response: 'Here is the response:\n{"title":"회의","summary":"a\\nb\\nc","tags":[]}\nDone.' + }); + const r = await new LocalOllamaProvider().generate({ text: 'x', todayKst: '2026-04-26', dueDateCandidates: [] }); + expect(r.title).toBe('회의'); }); it('generate aborts on timeout', async () => {