# 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:

```json
{
  "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 기반:

```ts
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 패턴):

```ts
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):

```ts
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 주입 (테스트 가능):

```ts
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. 인터페이스

```ts
// 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 갱신

```ts
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

```ts
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 추출.

```ts
// 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)
