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