From d3bc972783580e65b799a6bd28614208957fbc3c Mon Sep 17 00:00:00 2001 From: th-kim0823 Date: Tue, 12 May 2026 15:28:58 +0900 Subject: [PATCH] =?UTF-8?q?fix(vision):=20=EB=B3=B8=EB=AC=B8=20=EB=B9=88?= =?UTF-8?q?=20+=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20only=20=EC=BC=80=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20AI=20=ED=98=B8=EC=B6=9C=20skip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gemma4:26b 등 vision 모델이 본문 없는 이미지 단독 입력을 의미 있게 처리 못 함 (여러 prompt 시도에도 빈 응답). 모델 한계 수용: - AiWorker 가 rawText.trim()==='' && media.length>0 detect 시 vision call skip - 자동 placeholder: '첨부 이미지' / '첨부 이미지 N장' + summary - ai_provider='image-only-skip' (디버그성 식별자) - NoteCard 노란 배너 제거 (사용자가 한계 수용, placeholder 자체로 충분) - 사용자는 EditableField 로 제목/요약 직접 편집 가능 cold-start timeout / parseJsonLoose fallback / schema coerce 부담 모두 skip. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/ai/AiWorker.ts | 17 ++++++++ src/renderer/inbox/components/NoteCard.tsx | 13 ------ tests/unit/AiWorker.vision.test.ts | 47 ++++++++++++++++++++++ 3 files changed, 64 insertions(+), 13 deletions(-) diff --git a/src/main/ai/AiWorker.ts b/src/main/ai/AiWorker.ts index 73c4790..7908ccb 100644 --- a/src/main/ai/AiWorker.ts +++ b/src/main/ai/AiWorker.ts @@ -137,6 +137,23 @@ export class AiWorker { const nowDate = this.now(); const todayDate = kstTodayAsDate(nowDate); const todayIso = kstTodayIso(nowDate); + + // v0.3.14 — 본문 빈 + 이미지만 첨부 케이스는 모델이 의미 있는 응답 못 함 + // (gemma4:26b 등 vision 모델의 한계 확인). AI 호출 skip, 자동 placeholder 적용 후 + // 즉시 done. 사용자가 후에 NoteCard 의 EditableField 로 제목/요약 편집 가능. + const rawEmpty = note.rawText.trim().length === 0; + if (rawEmpty && note.media.length > 0) { + const n = note.media.length; + const title = n === 1 ? '첨부 이미지' : `첨부 이미지 ${n}장`; + const summary = `이미지 ${n}장이 첨부된 메모입니다.\n원문 영역에서 이미지 확인할 수 있습니다.\n제목과 요약을 클릭해 직접 편집할 수 있습니다.`; + this.repo.updateAiResult(job.noteId, { + title, summary, tags: [], provider: 'image-only-skip', dueDate: null + }); + this.logger.info('ai.skip.image-only', { noteId: job.noteId, mediaCount: n }); + this.emit(job.noteId); + return; + } + const candidates = parseAllCandidates(note.rawText, todayDate); const vocab = this.repo.getTopUsedTags(VOCAB_TOP_N); // v0.3.1 Cut F — vision path: visionModel + note.media → base64 images diff --git a/src/renderer/inbox/components/NoteCard.tsx b/src/renderer/inbox/components/NoteCard.tsx index e9f6c71..474dd59 100644 --- a/src/renderer/inbox/components/NoteCard.tsx +++ b/src/renderer/inbox/components/NoteCard.tsx @@ -279,19 +279,6 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore

{fallbackTitle}

)} - {/* v0.3.14 — graceful fallback 가시화. title 이 placeholder 면 vision 모델이 - 빈/유효하지 않은 응답 반환한 케이스. 본문 없는 이미지를 모델이 처리 못 하는 - 경우가 가장 빈번 (gemma4:26b 등). 사용자가 직접 편집 유도. */} - {!isTrash && local.aiStatus === 'done' && local.aiTitle === '(첨부 메모)' && local.media.length > 0 && ( -
- 💡 AI 가 이미지 내용을 정리하지 못했습니다. 본문 없이 이미지만 첨부한 경우 일부 - vision 모델 (gemma4:26b 등) 이 빈 응답을 반환합니다. 본문에 한 줄 메모를 추가하거나 - 제목/요약을 직접 클릭해 수정하세요. -
- )} {local.aiStatus === 'done' && ( <> {isTrash ? ( diff --git a/tests/unit/AiWorker.vision.test.ts b/tests/unit/AiWorker.vision.test.ts index bf55985..d13502d 100644 --- a/tests/unit/AiWorker.vision.test.ts +++ b/tests/unit/AiWorker.vision.test.ts @@ -98,6 +98,53 @@ describe('AiWorker — vision path (v0.3.1 Cut F)', () => { expect(calls[0]![0].images).toBeUndefined(); }); + it('v0.3.14 — 본문 빈 + 이미지만 첨부 → generate 호출 skip + 자동 placeholder', async () => { + const { id } = repo.create({ rawText: '' }); // 빈 본문 + await mkdir(join(workDir, 'media', id), { recursive: true }); + await writeFile(join(workDir, 'media', id, '1.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47])); + repo.insertMedia([{ noteId: id, kind: 'image', relPath: `media/${id}/1.png`, mime: 'image/png', bytes: 4 }]); + + const calls: Array> = []; + const generate = vi.fn(async ( + input: Parameters[0], + opts?: Parameters[1] + ): Promise => { + calls.push([input, opts]); + return { title: 't', summary: 'a\nb\nc', tags: [], dueDate: null }; + }); + const getVisionModel = vi.fn(async (): Promise => 'gemma4:26b'); + const worker = makeWorker(generate, getVisionModel); + await worker.enqueue(id); + await worker.drain(); + + // vision 호출 자체 skip + expect(calls.length).toBe(0); + // 노트가 자동 placeholder 로 done + const note = repo.findById(id); + expect(note?.aiStatus).toBe('done'); + expect(note?.aiTitle).toContain('첨부 이미지'); + expect(note?.aiSummary).toContain('이미지'); + expect(note?.aiProvider).toBe('image-only-skip'); + }); + + it('v0.3.14 — 이미지 다수 첨부 시 placeholder 가 개수 포함', async () => { + const { id } = repo.create({ rawText: '' }); + await mkdir(join(workDir, 'media', id), { recursive: true }); + await writeFile(join(workDir, 'media', id, '1.png'), Buffer.from([0x89])); + await writeFile(join(workDir, 'media', id, '2.png'), Buffer.from([0x89])); + repo.insertMedia([ + { noteId: id, kind: 'image', relPath: `media/${id}/1.png`, mime: 'image/png', bytes: 1 }, + { noteId: id, kind: 'image', relPath: `media/${id}/2.png`, mime: 'image/png', bytes: 1 } + ]); + const generate = vi.fn(async (): Promise => ({ title: 't', summary: 'a\nb\nc', tags: [], dueDate: null })); + const getVisionModel = vi.fn(async (): Promise => 'gemma4:26b'); + const worker = makeWorker(generate, getVisionModel); + await worker.enqueue(id); + await worker.drain(); + const note = repo.findById(id); + expect(note?.aiTitle).toContain('2장'); + }); + it('5MB 초과 이미지 → throw → AiWorker 의 fail 분기 (generate 미호출)', async () => { const { id } = repo.create({ rawText: 'big image' }); await mkdir(join(workDir, 'media', id), { recursive: true });