Files
inkling/docs/superpowers/specs/2026-05-09-v031-cut-f-design.md
altair823 2b3c3d727e feat(v031): vision capability hints 에 gemma4 추가 (사용자 요청)
본인 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 변종을
설치하면 자동 인식.
2026-05-10 11:12:13 +09:00

10 KiB
Raw Blame History

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.md Cut 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.processJobrepo.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 (visionModel null): 기존 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:

  1. 이미지 capture 빈도 (가설: 일 ≥ 1건 = vision 가치)
  2. vision 결과 사용자 수정 비율 (정확도 측정)
  3. capability detection 정확도 (false-positive / false-negative)