fix(vision): 본문 빈 + 이미지 only 케이스 AI 호출 skip

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) <noreply@anthropic.com>
This commit is contained in:
th-kim0823
2026-05-12 15:28:58 +09:00
parent 30b14d2b74
commit d3bc972783
3 changed files with 64 additions and 13 deletions

View File

@@ -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

View File

@@ -279,19 +279,6 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 600 }}>{fallbackTitle}</h3>
</div>
)}
{/* v0.3.14 — graceful fallback 가시화. title 이 placeholder 면 vision 모델이
빈/유효하지 않은 응답 반환한 케이스. 본문 없는 이미지를 모델이 처리 못 하는
경우가 가장 빈번 (gemma4:26b 등). 사용자가 직접 편집 유도. */}
{!isTrash && local.aiStatus === 'done' && local.aiTitle === '(첨부 메모)' && local.media.length > 0 && (
<div style={{
marginTop: 4, padding: '6px 10px', background: '#fff8e1',
borderRadius: 4, fontSize: 12, color: '#7a5a00'
}}>
💡 AI .
vision (gemma4:26b ) .
/ .
</div>
)}
{local.aiStatus === 'done' && (
<>
{isTrash ? (

View File

@@ -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<Parameters<InferenceProvider['generate']>> = [];
const generate = vi.fn(async (
input: Parameters<InferenceProvider['generate']>[0],
opts?: Parameters<InferenceProvider['generate']>[1]
): Promise<AiResponse> => {
calls.push([input, opts]);
return { title: 't', summary: 'a\nb\nc', tags: [], dueDate: null };
});
const getVisionModel = vi.fn(async (): Promise<string | null> => '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<AiResponse> => ({ title: 't', summary: 'a\nb\nc', tags: [], dueDate: null }));
const getVisionModel = vi.fn(async (): Promise<string | null> => '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 });