Add vertical slice design spec for Inkling MVP

Initial design document covering the first end-to-end slice of Inkling:
Quick Capture, SQLite storage, Local Ollama AI pipeline (gemma4:9b),
and Inbox with inline editing. Scoped for dogfood on Windows/macOS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-24 19:12:21 +00:00
commit bbbe292fc6
2 changed files with 780 additions and 0 deletions

View File

@@ -0,0 +1,474 @@
# Inkling — Vertical Slice v0.1 설계 문서
**작성일:** 2026-04-24
**저자:** 김태현 (dlsrks0734@gmail.com)
**대상 기획서:** `inkling.md` v1.4
**문서 성격:** Phase 1 MVP의 첫 서브프로젝트 설계 (vertical slice)
---
## 0. 개요
이 문서는 `inkling.md` 기획서의 Phase 1 MVP(§8.1) 중 **종단 end-to-end 경로** 한 줄만 최소 구현하기 위한 설계다. 목적은 두 가지 핵심 가설을 가장 얇은 경로로 검증하는 것이다.
1. "전역 단축키 → 입력 → 저장"이 실제로 3초 내 마찰 없이 이루어지는가. (§3.2-3)
2. 로컬 Ollama(Gemma 4 9B)가 제목·요약·태그를 실용 가능한 품질로 생성하는가. (§4.2, §6.5)
Slice는 dogfood만을 목적으로 하며, 패키징·서명·배포·알림·검색·내보내기는 전부 후속 spec의 범위다.
---
## 1. 범위
### 1.1 In-scope
- **플랫폼:** Windows + macOS Electron 빌드. Windows를 dogfood 우선으로 하고 macOS는 빌드 통과 및 기본 동작 수준을 유지.
- **Quick Capture:** 전역 단축키 `Ctrl/Cmd+Shift+J`, 단일 입력창. 텍스트 + 클립보드 붙여넣기 이미지 입력.
- **저장:** 로컬 SQLite 단일 프로필(`default`). 미디어는 프로필 디렉터리 `media/` 하위 파일로 저장.
- **AI 파이프라인:** 비동기 처리. `LocalOllamaProvider` 1개 구현체. `gemma4:9b` 표준 티어 고정. 출력은 제목 + 3줄 요약 + 태그 최대 3개.
- **Inbox:** 날짜 내림차순 리스트. 카드에 제목·요약·태그·원문·미디어 썸네일. AI 필드 인라인 편집 및 노트 삭제 지원. 원문은 읽기 전용.
- **스트릭:** 연속 기록 일수를 Inbox 상단 배지에 표시. SQL 집계 기반.
### 1.2 Out-of-scope (후속 spec에서 다룸)
- 음성 입력 / STT
- 관련 메모 링크 / 프로젝트 자동 분류 / 벡터 검색
- 알림·푸시·스케줄러·캘린더 어댑터·모닝/이브닝 프롬프트
- 다중 프로필 / 비밀번호 잠금 / 마스킹 레이어
- LAN Ollama Provider / 외부 API Provider / BYOK 키 관리
- Confluence 내보내기 / 프로필 zip 내보내기·재가져오기
- 주간 회고 / 뱃지 / 온보딩 위자드
- 코드 서명·공증·자동 업데이트·배포 파이프라인
- Android 보조 앱
### 1.3 종료 조건
- Windows에서 저자 본인이 하루 5건 이상 실제 dogfood를 2주 연속 수행.
- AI 결과 체감 품질 검토: 한국어 제목·요약이 10건 중 7건 이상 사용자가 받아들일 수 있는 수준.
- 앱 재시작 후 데이터 무결성 유지, 수동 사용 기준 크래시 0회.
---
## 2. 아키텍처
### 2.1 프로세스 구조 (Electron 표준 main/renderer)
```
┌─ Renderer (React + TS) ──────────────────┐
│ - QuickCaptureWindow (frameless popup) │
│ - InboxWindow (main) │
│ - 상태 관리: Zustand │
└──────────────┬───────────────────────────┘
│ IPC (typed, contextBridge)
┌──────────────▼───────────────────────────┐
│ Main process (Node) │
│ ┌──────────────────┐ │
│ │ HotkeyService │ globalShortcut 등록│
│ ├──────────────────┤ │
│ │ CaptureService │ 입력 수신·저장 오케│
│ ├──────────────────┤ │
│ │ NoteRepository │ SQLite CRUD │
│ │ │ (better-sqlite3) │
│ ├──────────────────┤ │
│ │ MediaStore │ 이미지 → 파일 저장 │
│ ├──────────────────┤ │
│ │ AiWorker │ 비동기 큐, 재시도 │
│ │ └─ InferenceProvider ◄── LocalOllama │
│ ├──────────────────┤ │
│ │ StreakService │ SQL 집계 │
│ └──────────────────┘ │
└──────────────────────────────────────────┘
│ HTTP
┌──────────────▼───────────────────────────┐
│ Ollama (localhost:11434, gemma4:9b) │
└──────────────────────────────────────────┘
```
### 2.2 모듈 책임과 경계
- **HotkeyService** — `Ctrl/Cmd+Shift+J` 등록, 중복·충돌 감지, 이벤트를 `QuickCaptureWindow.show()`로 연결. OS별 분기는 이 모듈 안에 격리한다.
- **CaptureService** — QuickCapture에서 들어온 페이로드(`{text, images[]}`)를 받아 단일 트랜잭션으로 `NoteRepository.create` + `MediaStore.save`를 실행하고, 이어서 `AiWorker.enqueue(noteId)`를 호출한다. 저장 성공 시점에 창 닫힘 응답을 반환한다.
- **NoteRepository** — `notes`·`note_tags`·`tags` 테이블 접근을 전담. SQL 문자열이 이 모듈 밖으로 새지 않는다. `better-sqlite3`(동기 드라이버, 단일 프로세스에 적합)를 사용한다.
- **MediaStore** — 클립보드 PNG 바이트를 `{profileDir}/media/{noteId}/{uuid}.png` 형태로 파일 저장하고, 상대 경로만 DB에 기록한다.
- **AiWorker** — in-memory FIFO 큐 + SQLite `pending_jobs` 테이블(앱 재시작 시 재적재용). 동시 처리 1개. Ollama 호출 실패/타임아웃을 지수 백오프로 3회 재시도 후 노트를 `failed`로 전이. `pending` 상태의 작업만 재시작 시 자동 재적재되며, 이미 `failed`로 전이된 노트는 slice 범위에서는 수동·자동 어느 쪽으로도 다시 처리되지 않는다.
- **InferenceProvider 인터페이스** — §4 참조. 본 slice의 유일한 구현체는 `LocalOllamaProvider`.
- **StreakService** — `SELECT DISTINCT DATE(created_at, 'localtime') FROM notes` 결과로 연속 일수를 계산한다. 순수 읽기 작업.
### 2.3 IPC 계약 (typed preload)
```ts
captureApi: {
submit(payload: {text: string; images: ArrayBuffer[]}): Promise<{noteId: string}>;
hide(): void;
}
inboxApi: {
listNotes(opts: {limit: number; cursor?: string}): Promise<Note[]>;
updateAiFields(
noteId: string,
fields: {title?: string; summary?: string; tags?: string[]}
): Promise<void>;
deleteNote(noteId: string): Promise<void>;
getStreak(): Promise<{current: number; longest: number}>;
getPendingCount(): Promise<number>;
onNoteUpdated(cb: (note: Note) => void): () => void;
}
```
`onNoteUpdated`는 AiWorker가 노트 상태를 전이할 때마다 푸시한다. 낙관적 업데이트는 하지 않으며, pending 카드는 "처리 중…" 상태로 두었다가 완료 이벤트 수신 시 부분 리렌더로 교체한다.
### 2.4 데이터 디렉터리
- Windows: `%APPDATA%\Inkling\profiles\default\`
- macOS: `~/Library/Application Support/Inkling/profiles/default/`
Electron의 `app.getPath('userData')`를 기반으로 경로를 구성한다. 각 프로필 디렉터리는 `inkling.sqlite` 단일 DB 파일과 `media/` 하위 디렉터리를 포함한다.
---
## 3. 데이터 모델
### 3.1 SQLite 스키마 (v1)
```sql
-- 메모 본문
CREATE TABLE notes (
id TEXT PRIMARY KEY, -- UUID v7
raw_text TEXT NOT NULL, -- 원본. 불변 (기획서 §4.2)
ai_title TEXT, -- AI 생성, 사용자 편집 가능
ai_summary TEXT, -- AI 생성, 사용자 편집 가능
ai_status TEXT NOT NULL
CHECK (ai_status IN ('pending','done','failed')),
ai_error TEXT, -- 실패 사유 (디버그용)
ai_provider TEXT, -- 'local-ollama/gemma4:9b'
ai_generated_at TEXT, -- ISO8601
created_at TEXT NOT NULL, -- ISO8601 UTC
updated_at TEXT NOT NULL
);
CREATE INDEX idx_notes_created_at ON notes(created_at DESC);
CREATE INDEX idx_notes_ai_status ON notes(ai_status);
-- 태그 (정규화)
CREATE TABLE tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE COLLATE NOCASE
);
CREATE TABLE note_tags (
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id),
source TEXT NOT NULL CHECK (source IN ('ai','user')),
PRIMARY KEY (note_id, tag_id)
);
-- 미디어 (slice에선 이미지만)
CREATE TABLE media (
id TEXT PRIMARY KEY,
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('image')),
rel_path TEXT NOT NULL, -- media/{note_id}/{uuid}.png
mime TEXT NOT NULL,
bytes INTEGER NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX idx_media_note_id ON media(note_id);
-- AI 작업 큐 (재기동 시 재적재)
CREATE TABLE pending_jobs (
note_id TEXT PRIMARY KEY REFERENCES notes(id) ON DELETE CASCADE,
attempts INTEGER NOT NULL DEFAULT 0,
next_run_at TEXT NOT NULL,
last_error TEXT
);
-- 스키마 버전
CREATE TABLE schema_migrations (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL
);
```
### 3.2 파일시스템 레이아웃
```
{userData}/Inkling/profiles/default/
├── inkling.sqlite
├── inkling.sqlite-wal # WAL 모드 활성
├── inkling.sqlite-shm
└── media/
└── {note_id}/
└── {uuid}.png
```
### 3.3 핵심 불변 조건
- `raw_text`는 저장 이후 수정 쿼리가 존재하지 않는다. `NoteRepository`에 원문 업데이트 메서드를 만들지 않는 것으로 이 불변을 보장한다.
- `ai_status='pending'` 행은 반드시 `pending_jobs`에 대응 엔트리가 존재한다. `CaptureService.create`는 두 insert를 동일 트랜잭션 안에서 수행한다.
- 노트 삭제는 DB 트랜잭션 수행 후 미디어 디렉터리 제거 순서로 진행한다. 미디어 삭제 실패로 생긴 고아 파일은 기동 시 GC가 청소한다.
- 태그는 `source='ai'``source='user'`를 구분해 저장한다. 사용자가 AI 태그를 제거해도 `tags` 테이블에서 지우지 않고 `note_tags` 행만 삭제한다(태그 이름 재사용 가능).
### 3.4 마이그레이션
단방향 버전 번호를 사용한다. Slice 출시 시점은 v1. 앱 기동 시 `PRAGMA user_version`을 읽어 필요한 순차 마이그레이션을 실행한다. 다운그레이드는 지원하지 않는다.
### 3.5 쿼리 핫스팟
- Inbox 리스트: `SELECT ... FROM notes ORDER BY created_at DESC LIMIT 50 OFFSET ?` + 태그·미디어를 별도 fetch(N+1 허용, 페이지당 50행 기준 실용 문제 없음).
- 스트릭: `SELECT DISTINCT DATE(created_at, 'localtime') FROM notes`를 로드해 애플리케이션 레이어에서 연속 일수를 계산한다.
---
## 4. AI 파이프라인
### 4.1 Provider 계약
```ts
interface InferenceProvider {
readonly name: string; // 'local-ollama/gemma4:9b'
generate(input: GenerateInput): Promise<GenerateResult>;
healthCheck(): Promise<HealthResult>;
}
interface GenerateInput {
text: string; // 원문. slice에선 이미지 미포함.
}
interface GenerateResult {
title: string; // 한국어, ≤ 60자
summary: string; // 한국어, 3줄, 각 ≤ 120자, "\n" 구분
tags: string[]; // 0~3개, 영문 kebab-case
}
interface HealthResult {
ok: boolean;
model?: string;
reason?: string;
}
```
### 4.2 LocalOllamaProvider 구현
- 엔드포인트: `POST http://localhost:11434/api/generate`
- 요청 본문: `{model: "gemma4:9b", prompt: <§4.3>, format: "json", stream: false, options: {temperature: 0.2, num_predict: 512}}`
- 타임아웃: **120초** (AbortController).
- 응답 파싱: `response` 필드를 JSON.parse한 뒤 zod 스키마로 검증. 실패 시 상위에 throw(AiWorker가 재시도를 결정).
### 4.3 프롬프트
```
You organize raw personal notes into structured metadata.
Input note (raw text, may be fragmented, any language):
---
{raw_text}
---
Return a JSON object with EXACTLY these keys:
- "title": concise title in KOREAN (max 60 chars)
- "summary": 3-line summary in KOREAN. Each line max 120 chars. Lines separated by "\n".
- "tags": array of 0 to 3 tags in lowercase kebab-case (English letters and digits only,
e.g., "api-timeout", "weekly-retro"). Empty array if no clear tags.
Rules:
- title and summary MUST be written in Korean regardless of input language.
- tags MUST be English kebab-case (for consistency across notes; easier to search/group).
- Do NOT invent facts not present in the input.
- Do NOT include markdown code fences or preamble.
- Return ONLY the JSON object.
```
### 4.4 응답 검증 규칙
- `title`: 한국어 문자 1개 이상 포함(정규식 `/[가-힣]/`). 미포함이면 프롬프트 위반으로 간주하고 재시도 대상으로 삼는다.
- `summary`: 줄 수가 정확히 3줄이 아니면 빈 줄을 보정하거나 과잉 줄을 합쳐 3줄로 정규화한다. 0줄이면 실패 처리.
- `tags`: 각 태그가 `^[a-z0-9]+(-[a-z0-9]+)*$`를 만족하는지 검사. 위반 태그는 필터링하고, 남은 태그만 저장. 전체 실패는 아니다.
### 4.5 AiWorker 동작
- **시작 시점:** 앱 기동 직후 `pending_jobs` 전체를 큐에 적재.
- **동시성:** 1건(로컬 Ollama 자원 경합 회피).
- **재시도 백오프:** 즉시 / 30초 / 120초. 3회 실패 시 `notes.ai_status='failed'`로 전이하고 `pending_jobs`에서 삭제.
- **성공 트랜잭션:**
1. `UPDATE notes SET ai_title, ai_summary, ai_status='done', ai_provider, ai_generated_at=NOW(), updated_at=NOW() WHERE id=?`
2. `DELETE FROM note_tags WHERE note_id=? AND source='ai'` 후 새 태그 `INSERT` (멱등).
3. `DELETE FROM pending_jobs WHERE note_id=?`
- **완료 이벤트:** IPC `onNoteUpdated`로 갱신된 노트 푸시.
### 4.6 헬스 체크 (앱 기동 시 1회)
- `GET /api/tags` 호출로 `gemma4:9b`가 설치되어 있는지 확인.
- 모델 미설치 → Inbox 상단 배너: "`ollama pull gemma4:9b` 실행 후 앱을 재시작하세요."
- 엔드포인트 무응답 → 배너: "Ollama가 실행되지 않았습니다. `ollama serve`를 실행해주세요." Capture는 계속 허용되며 노트는 `pending` 상태로 누적된다.
- 자동 pull은 slice에서 제공하지 않는다.
### 4.7 배제 항목 (후속 spec에서 다룸)
- 이미지 멀티모달 입력 (Gemma 4는 텍스트 전용으로 가정; 비전 모델 도입은 별도 spec).
- 프롬프트 A/B, 사용자 피드백 기반 튜닝.
- 외부 API Provider, LAN Ollama Provider.
- 마스킹 레이어 (로컬 전용이므로 slice에서는 비활성; 기획서 §6.5 마스킹 참조).
---
## 5. UX 플로우
### 5.1 Quick Capture
```
[Any app/screen]
│ Ctrl/Cmd+Shift+J
[QuickCaptureWindow popup]
- frameless, always-on-top, 640x280
- 화면 중앙, 포커스 자동 획득
- 단일 textarea + 썸네일 스트립 (이미지 있을 때만)
- 하단 힌트: "Ctrl+Enter 저장 · Esc 취소 · 이미지 붙여넣기 가능"
├─ 사용자 타이핑 / Ctrl+V (이미지)
│ └─ 클립보드 이미지 감지 → 썸네일 추가 (메모리 버퍼)
├─ Ctrl+Enter → submit
│ - CaptureService.submit({text, images})
│ - 트랜잭션: notes insert + media 저장 + pending_jobs insert
│ - 성공 시 창 즉시 닫힘 (AI 완료 대기 없음)
│ - 실패 시 창 내 빨간 토스트, 내용 유지
└─ Esc → 취소 (5자 이상이면 "정말 버릴까요?" 1-step 확인)
```
**타이밍 목표**
- 단축키 → 창 표시: p95 < 100ms (숨겨진 창을 `show/hide` 토글, 매번 생성하지 않음).
- Submit → 창 닫힘: p95 < 300ms (DB 트랜잭션만 동기 대기, AI는 비동기).
### 5.2 Inbox
```
[InboxWindow] — 트레이 아이콘 클릭으로 열림, 상시 실행
┌─────────────────────────────────────────┐
│ Inkling 🔥 연속 4일 │ ← StreakBadge
├─────────────────────────────────────────┤
│ [🟡 Ollama 처리 중: 3건] │ ← pending count (0이면 숨김)
│ [Ollama 상태 경고가 있으면 여기 배너] │
├─────────────────────────────────────────┤
│ ┌ 2026-04-24 18:32 ─────────────────┐ │
│ │ [제목 AI] 회의 A프로젝트 이슈 정리 │ │ ← 인라인 편집 가능
│ │ [요약] 3줄... │ │
│ │ [태그] api-timeout meeting │ │ ← 클릭 제거
│ │ [썸네일] 🖼 🖼 │ │
│ │ ▸ 원문 보기 (토글, 기본 접힘) │ │
│ │ [🗑 삭제] │ │
│ └──────────────────────────────────────┘ │
│ ┌ 2026-04-24 17:10 ─────────────────┐ │
│ │ [제목] 처리 중… │ │ ← ai_status='pending'
│ │ (원문 자동 펼침) │ │
│ └──────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ [더 보기] (50개씩 페이지네이션) │
└─────────────────────────────────────────┘
```
**상태별 카드 렌더링**
- `pending` — 제목 자리에 스피너와 "처리 중…". 요약·태그 숨김, 원문 자동 펼침.
- `done` — 제목·요약·태그 전부 표시, 원문 접힘.
- `failed` — 제목 자리에 "AI 처리 실패" + 사유 hover tooltip. 원문 자동 펼침. 재시도 버튼은 slice 외.
**편집 동작**
- 제목·요약 필드는 클릭 시 `contentEditable`로 전환, blur 시점에 저장(`updateAiFields` IPC).
- 태그 클릭 시 제거(source 무관). 태그 추가 UI는 slice 외.
- 저장 실패 시 이전 값 복구 + 빨간 테두리 1회 플래시.
**삭제**
- 🗑 클릭 → 네이티브 confirm 다이얼로그 1회. 확인 시 `deleteNote` 호출 후 카드 제거 및 미디어 파일 삭제.
**실시간 업데이트**
- `onNoteUpdated` 수신 시 해당 카드만 부분 리렌더. 스크롤 위치 유지.
### 5.3 첫 실행
Slice는 온보딩 위자드를 제공하지 않는다.
1. 최초 기동 시 `{userData}/Inkling/profiles/default/`가 자동 생성된다.
2. Inbox 창이 열리고 빈 상태 메시지("`Ctrl+Shift+J`로 첫 메모를 남겨보세요.")가 노출된다.
3. Ollama 헬스 체크가 실행되고 결과에 따라 배너가 표시된다.
### 5.4 앱 종료·재시작
- 창 닫기(X)는 트레이로 숨기는 동작이며 앱은 계속 실행된다. 실제 종료는 트레이 메뉴의 "Quit" 항목으로 수행한다.
- 재시작 시 `pending_jobs`를 큐에 재적재하여 AiWorker 처리가 이어진다.
---
## 6. 오류 처리·관찰성·테스트
### 6.1 실패 모드 카탈로그
| # | 지점 | 실패 | 사용자 영향 | 복구 전략 |
|---|------|------|-------------|----------|
| 1 | 단축키 등록 | 다른 앱이 `Ctrl+Shift+J` 점유 | Capture 진입 불가 | 기동 로그 기록 + Inbox 배너 표시. 설정 UI는 slice 외이므로 FAQ로 대체 안내. |
| 2 | Quick Capture submit | DB write 실패(디스크 full, lock) | 메모 소실 위험 | 창 내 빨간 토스트 + 내용 유지. 재시도는 사용자가 Ctrl+Enter 재입력. |
| 3 | 미디어 저장 | 파일 쓰기 실패 | 이미지 누락 | 미디어를 먼저 쓰고 성공 후에만 노트 insert. 미디어 실패 시 노트 저장 자체를 중단. |
| 4 | Ollama 미실행/미설치 | `/api/tags` 실패 | 노트는 저장되지만 pending 누적 | Inbox 배너 + 큐가 지수 백오프로 계속 시도. |
| 5 | Ollama 타임아웃 (>120s) | AbortError | 3회 실패 시 노트 `failed` | `ai_error` 기록. 수동 재처리는 slice 외. |
| 6 | Ollama 응답 스키마 위반 | zod parse 실패 | 3회 실패 시 `failed` | 위반 응답 최대 500자를 `last_error`에 저장. |
| 7 | 앱 비정상 종료 | WAL 미체크포인트 | 재기동 시 자동 복구 | `PRAGMA journal_mode=WAL` + SQLite 자체 복구. |
| 8 | 미디어 고아 파일 | DB에는 기록 없으나 파일 잔존 | 디스크 낭비 | 앱 기동 시 GC가 `media/{note_id}/` 중 미등록 디렉터리 제거. |
| 9 | 마이그레이션 실패 | 스키마 충돌 | 앱 기동 불가 | DB 백업 복사본 자동 생성 후 기동 중단, 에러 다이얼로그 표시. |
### 6.2 로깅·진단
- 로그 경로: `{userData}/Inkling/logs/main-YYYY-MM-DD.log`. 일별 rotate, 7일 보관.
- 로그 레벨: `info` / `warn` / `error`. `debug`는 환경 변수 `INKLING_DEBUG=1`일 때만 활성.
- PII 보호: 로그에 `raw_text`·`ai_title`·`ai_summary` 본문을 기록하지 않는다. `noteId`, 본문 길이, 해시 prefix(첫 8자)만 기록한다.
- 진단 명령: 트레이 메뉴에 "Export logs" 항목. 최근 7일 로그를 zip으로 묶어 사용자가 선택한 폴더에 저장.
### 6.3 테스트 전략
**단위 테스트 (Vitest)**
- `NoteRepository`: 메모리 SQLite(`:memory:`)로 CRUD·트랜잭션 원자성·원문 불변 계약 검증.
- `MediaStore`: tmp 디렉터리 기반, 경로 충돌 및 바이너리 무결성.
- `AiWorker`: `InferenceProvider` 목으로 주입. 성공·타임아웃·파싱 실패·재시도 간격 시나리오.
- `LocalOllamaProvider`: HTTP 레이어는 `undici` mock agent로 고정 응답 주입.
- 응답 검증기(zod + 한국어 정규식): 영문 제목 반려, kebab-case 위반 태그 필터링.
**통합 테스트 (Vitest, Electron main 없이)**
- 실제 SQLite 파일 + 실제 `LocalOllamaProvider` + 로컬에서 실행 중인 Ollama.
- CI에서는 스킵(로컬 커맨드 `npm run test:integration`으로 수행).
- 골든 케이스 10종(한국어 회의 메모, 영문 스택트레이스, 혼합, 단편, 긴 로그 등): 제목·요약 한국어 여부, 태그 kebab-case 형식 검증.
**E2E 테스트 (Playwright + Electron)**
- Windows · macOS 각각 1회의 smoke run.
- 시나리오: (a) 단축키 → 텍스트 입력 → 저장 → Inbox 노출(pending), (b) AI 완료 대기 → done 상태로 전환 확인, (c) 제목 인라인 편집 후 저장, (d) 삭제 → 카드 제거, (e) 앱 재시작 후 데이터 유지.
**수동 체크리스트 (릴리스 전)**
- 클립보드 이미지 붙여넣기를 양 OS에서 수동 확인.
- Ollama 중단 상태에서 10건 저장 후 재기동 → 큐 재적재 및 처리 확인.
- 디스크 full 재현(tmpfs quota 등)으로 Failure #2 경로 확인.
### 6.4 성능 목표
| 항목 | p95 목표 |
|------|----------|
| 단축키 → Quick Capture 창 표시 | < 100ms |
| Submit → 창 닫힘 | < 300ms |
| AI 완료 (gemma4:9b, 로컬, 입력 ≤ 500자, RTX 3060 12GB 기준) | < 30s (초과해도 failed 아님) |
| Inbox 50건 렌더 | < 200ms |
---
## 7. 오픈 이슈 / 차기 spec으로 이관
- **단축키 충돌 시 변경 UI**: 설정 화면이 slice에 없으므로 FAQ/환경 변수로 임시 대응. 후속 spec에서 정식 설정 UI로 해결.
- **AI 결과 수동 재처리 버튼**: dogfood 중 프롬프트 튜닝 편의가 필요하면 개발자 전용 debug 메뉴로 먼저 도입 검토.
- **Gemma 4 모델 가용성**: 설계 시점(2026-04) 공식 체크포인트 상태를 별도 확인. 미출시면 `gemma3:9b` 등 동세대 대체 모델로 임시 운용하고 스펙을 업데이트.
- **Electron 패키징 옵션 결정**: `electron-builder` vs `electron-forge`. Slice 바이너리는 dev 빌드로 충분하므로 후속 배포 spec에서 결정.
---
*본 문서는 dogfood 결과와 후속 spec 작성 과정에서 조정될 수 있다.*