본인 dogfood 환경 = gemma4:e4b (텍스트). vision 변종은 현재 gemma3 (vision-capable) 또는 향후 gemma4 출시 시. 양 family 모두 hint 에 포함 — capability detection 이 future-proof. - VisionDetect.VISION_FAMILIES + VISION_NAME_HINTS 에 'gemma4' 추가 - isVisionCapable test 2건 추가 (gemma4 family / gemma4 name hint detection) - spec §1 + §2 의 'gemma3 family default' → 'gemma family — gemma3 / gemma4' 영향: 기존 detection 정확도 무영향 (set 추가만), 사용자가 gemma4 vision 변종을 설치하면 자동 인식.
10 KiB
v0.3.1 — Cut F Design (멀티모달 vision AI)
작성일: 2026-05-09 선행 문서:
docs/superpowers/specs/2026-04-25-dogfood-feedback.md(F24)docs/superpowers/strategy/v028plus-roadmap.mdCut F
Cut 라벨: v0.3.1 — patch (vision 추가, 기존 기능 영향 X)
1. Cut 정체성
Ollama vision 모델 (gemma family — gemma3 / gemma4 default capable) 활용 — 이미지 + raw_text 결합 prompt 또는 이미지 단독 분석 → title/summary/tags 자동 생성. F22 prerequisite (Cut A) 이미 완료.
2. 범위
| 항목 | 결정 |
|---|---|
| F24 default 모델 | gemma family — gemma3 / gemma4 둘 다 vision-capable hint (한국어 + 이미지 둘 다 강함, 본인 메모 gemma4:e4b 텍스트 모델과 같은 가족) |
| prompt 모드 | 단일 vision 모델 호출 (vision 모델이 텍스트도 처리). 모델 capability 부족 시 2단계 fallback (자동) |
| capability detection | app launch 시 1회 + 설정 페이지 manual refresh 버튼 |
| F23 OFF 시 자동 OFF | ai_enabled=false → vision 도 자동 OFF (자명) |
3. Capability Detection
3-1. Ollama API 활용
GET /api/tags → 사용자 Ollama instance 의 모델 목록. response:
{
"models": [
{ "name": "gemma4:e4b", "details": { "family": "gemma" } },
{ "name": "gemma3:12b-vision", "details": { "family": "gemma3", "families": ["gemma3"] } },
{ "name": "llava:13b", "details": { "family": "llava" } }
]
}
vision capable 판정 — 모델 이름 또는 family 기반:
const VISION_FAMILIES = new Set(['gemma3', 'llava', 'llama3.2-vision', 'minicpm-v', 'pixtral']);
const VISION_NAME_HINTS = ['vision', 'vl', 'multimodal', 'gemma3'];
function isVisionCapable(model: { name: string; details?: { family?: string; families?: string[] } }): boolean {
if (model.details?.family && VISION_FAMILIES.has(model.details.family)) return true;
if (model.details?.families?.some(f => VISION_FAMILIES.has(f))) return true;
return VISION_NAME_HINTS.some(h => model.name.toLowerCase().includes(h));
}
3-2. Settings storage (실제 SettingsService API)
zod schema 확장 (기존 ai_enabled / sync_* 와 동일 strict 패턴):
const SettingsSchema = z.object({
// ... 기존 ollama / ai_enabled / onboarding_completed / sync_*
vision_model: z.string().nullable().optional(),
vision_capable_cache: z.array(z.string()).optional(),
vision_cache_at: z.string().optional()
}).strict();
신규 SettingsService 메서드 (개별 setter/getter — get/set 일반화 X):
async getVisionModel(): Promise<string | null>;
async setVisionModel(value: string | null): Promise<void>;
async getVisionCapableCache(): Promise<{ models: string[]; at: string | null }>;
async setVisionCapableCache(models: string[], now: Date): Promise<void>;
3-3. AppLaunchDetect
src/main/services/VisionDetect.ts 신규 — pure 함수 + 외부 fetch 주입 (테스트 가능):
export async function refreshVisionCache(deps: {
settings: SettingsService;
endpoint: string;
now?: () => Date;
fetchImpl?: typeof fetch;
}): Promise<{ ok: true; models: string[] } | { ok: false; reason: string }> {
if (!(await deps.settings.isAiEnabled())) {
return { ok: false, reason: 'ai_disabled' };
}
const fetchFn = deps.fetchImpl ?? fetch;
let body: { models?: Array<{ name: string; details?: { family?: string; families?: string[] } }> };
try {
const r = await fetchFn(`${deps.endpoint}/api/tags`);
if (!r.ok) return { ok: false, reason: `tags http ${r.status}` };
body = await r.json();
} catch (e) {
return { ok: false, reason: `unreachable: ${(e as Error).message}` };
}
const capable = (body.models ?? []).filter(isVisionCapable).map((m) => m.name);
await deps.settings.setVisionCapableCache(capable, deps.now ? deps.now() : new Date());
return { ok: true, models: capable };
}
main process whenReady 안에서 fire-and-forget 호출. 실패 silent (cache 유지). settings:refresh-vision-cache IPC 가 동일 함수 호출 (manual "다시 감지" 버튼).
3-4. 설정 페이지 UI (AI 제공자 섹션 확장)
[AI 제공자]
Endpoint: [http://localhost:11434]
모델: [gemma4:e4b]
[이미지 분석 모델 (선택사항)]
[gemma3:12b-vision ▾] ← dropdown, 비어 있으면 비활성
가능한 모델: gemma3:12b-vision, llava:13b, ...
[ 다시 감지 ] 마지막 감지: 2026-05-09 14:30
dropdown — vision_capable_cache 결과 + 빈 옵션. "다시 감지" → refreshVisionCache() + UI 갱신.
4. InferenceProvider 확장
4-1. 인터페이스
// src/main/ai/InferenceProvider.ts
interface GenerateInput {
text: string;
images?: Array<{ base64: string; mime: string }>; // NEW
todayKst: string;
dueDateCandidates: string[];
vocab?: string[];
}
interface InferenceProvider {
generate(input: GenerateInput, opts?: { visionModel?: string }): Promise<AiResponse>;
abort?(): void;
}
4-2. LocalOllamaProvider 갱신
async generate(input: GenerateInput, opts?: { visionModel?: string }): Promise<AiResponse> {
const useVision = !!opts?.visionModel && (input.images?.length ?? 0) > 0;
const model = useVision ? opts.visionModel : this.textModel;
const body: any = {
model,
prompt: useVision
? buildVisionPrompt(input.text, input.todayKst, input.dueDateCandidates, input.vocab ?? [])
: buildPrompt(input.text, input.todayKst, input.dueDateCandidates, input.vocab ?? []),
stream: false,
format: 'json'
};
if (useVision) {
body.images = input.images!.map(i => i.base64);
}
const res = await request(`${this.endpoint}/api/generate`, body);
// ... 기존 parse
}
4-3. buildVisionPrompt
function buildVisionPrompt(text: string, todayKst: string, dueCandidates: string[], vocab: string[]): string {
return `다음 메모와 첨부 이미지를 종합 분석해 한국어로 요약하세요.
메모 본문 (비어 있을 수 있음):
${text || '(이미지만 있음)'}
이미지 분석 시 주요 시각적 정보 (텍스트, 사람, 장면) 도 포함해 요약하세요.
출력 JSON: { "title": "...", "summary": "...", "tags": [...], "due_date": "..." }
오늘: ${todayKst}
가능한 due 후보: ${dueCandidates.join(', ')}
빈출 태그: ${vocab.slice(0, 20).join(', ')}`;
}
5. AiWorker 통합 (실제 API 정정)
기존 AiWorker.processJob 이 repo.findById(noteId) 로 hydrate 된 Note 받음 — note.media 가 이미 join 결과로 채워져 있어 별도 listMediaByNote 호출 불필요. MediaStore.absolutePath(relPath) 로 디스크 path 추출.
// src/main/ai/AiWorker.ts processJob 흐름
const note = this.repo.findById(job.noteId);
if (!note || ...) return;
const visionModel = await this.settings.getVisionModel();
let images: Array<{ base64: string; mime: string }> | undefined;
if (visionModel && note.media.length > 0) {
images = await Promise.all(
note.media.map(async (m) => {
const buf = await readFile(this.mediaStore.absolutePath(m.relPath));
// 이미지당 5MB cap (base64 메모리 폭주 방지)
if (buf.byteLength > 5 * 1024 * 1024) {
throw new Error(`image ${m.relPath} exceeds 5MB cap`);
}
return { base64: buf.toString('base64'), mime: m.mime };
})
);
}
const res = await this.holder.get().generate({
text: note.rawText,
images,
todayKst,
dueDateCandidates: candidates,
vocab
}, { visionModel });
visionModel && note.media.length > 0 둘 다 true 일 때만 vision path. 그 외는 기존 text-only path 유지 (호환 보존). image 5MB cap 초과 시 throw → 기존 AiWorker 의 attempts 카운트 + ai_status='failed' 분기 활용.
AiWorker 의 settings: SettingsService 의존성 추가 — 기존 생성자에 신규 파라미터.
6. 이미지만 있는 capture (정정 — 신규 enum 도입 X)
raw_text 빈 값 + media 첨부만 케이스:
- vision enabled (
visionModel설정 + media 있음): AiWorker 의 vision path → 의미 있는 title/summary/tags 응답 - vision disabled (
visionModelnull): 기존 text-only 흐름 그대로 — 빈 prompt → AI 응답이 무의미하면 ai_status='failed' 분기 (재시도 가능). dogfood 시 빈도 측정 후 'skipped' enum 도입 여부 재평가.
'skipped' 신규 enum 미도입 (YAGNI): m008 마이그레이션 (CHECK relax via table recreate) 부담 + 이미지-only capture 가 본 cut 의 main use case 가 아님. 사용자가 vision 활성 후 retry 하거나 raw_text 추가 후 reprocess 하는 우회로 충분. 정책 검토는 dogfood 후 별도 cut.
7. 테스트 전략
| 영역 | 단위 |
|---|---|
isVisionCapable |
family / families / name hint 별 판정 |
refreshVisionCache |
mock /api/tags → capable 추출 + settings 저장 |
| 설정 페이지 dropdown | cache 기반 옵션 + "다시 감지" 클릭 → IPC |
LocalOllamaProvider.generate vision path |
images 비어있음 → text-only / images 있음 + visionModel → vision body |
buildVisionPrompt |
빈 text + images 만 케이스 정확 prompt |
AiWorker.processJob vision integration |
media + visionModel 있을 때만 base64 변환 |
| 이미지 only capture | raw_text='' + media → vision 결과 정상 또는 'skipped' 분기 |
목표: 단위 679 → 약 701 (+22, isVisionCapable 5 + refreshVisionCache 4 + SettingsService vision 4 + LocalOllamaProvider vision path 3 + buildVisionPrompt 2 + AiWorker vision integration 3 + UI dropdown 1), typecheck 0.
8. Risk
| Risk | 대응 |
|---|---|
| vision 모델 추론 latency 큼 (수 초~분) | AiWorker backend 처리 — 사용자 대기 X. NoteCard 가 ai_status='processing' 표시 |
| 이미지 base64 메모리 부담 | media 1개당 평균 < 1MB. 다중 이미지 시 N×base64 = 메모리 N배. cap (이미지당 max size 5MB) 적용 |
| capability detection 실패 시 fallback | cache 부재 → vision dropdown 비어있음 표시 + "다시 감지" 안내 |
| vision 모델 한국어 정확도 | dogfood 검증. gemma3 가 한국어 약하면 다른 family 추천 갱신 (메모리 정책 갱신) |
| Ollama 가 vision images 필드 무시 (모델이 multimodal 미지원) | 본 cut 미구현 (YAGNI) — 자동 2단계 fallback (caption 추출 → 텍스트 모델 종합) 은 v0.3.2+ 검토. dogfood 시 capability detection 정확도 우선 |
9. v0.3.1 후
Cut G (v0.3.2) — F25 사이드바 + notebook_id.
dogfood verify:
- 이미지 capture 빈도 (가설: 일 ≥ 1건 = vision 가치)
- vision 결과 사용자 수정 비율 (정확도 측정)
- capability detection 정확도 (false-positive / false-negative)