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:
@@ -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
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user