# 설계서: Phase 2-A — Gemma 4 on-device 프레임 자동 생성 (#215) > **상태**: Draft > **작성**: [AI] Architect · **최종수정**: 2026-06-12 > **추적성** — Redmine: #215 · 관련 ADR: [ADR-0003 on-device LLM Gemma](../../adr/0003-on-device-llm-gemma.md) > · 구현 파일: `app/lib/data/ai/`, `app/lib/domain/ai/`, `app/lib/state/ai_providers.dart` (TBD by Developer) > · 테스트: `app/test/domain/ai/`, `app/test/data/ai/` (TBD by Developer) > **하위 문서**: > - [fn-suggest_frame.md](./fn-suggest_frame.md) — Gemma 4 호출 + few-shot 조립 + 응답 파싱 > - [fn-model_lifecycle.md](./fn-model_lifecycle.md) — 모델 다운로드 / 로드 / 언로드 / 삭제 + LlmService 인터페이스 --- ## 1. 목적 (Why) > Planner 목표 (#215): "사용자가 입력한 L0/L1 raw text를 Gemma 4 E2B Q4_0이 L2/L3 프레임 후보로 변환해주는 vertical-slice 1개를 #204 위에 얹는다." #204 에서 만든 `FramePattern` 30개 정적 카탈로그는 `keyword` 정확 매치 기반이라 한국어 구어체·동의어·신조어를 흡수하지 못한다. 결과적으로 "술 끊고 싶어" 같은 L0 입력에서 사용자가 직접 L2/L3 문장을 손으로 작성해야 한다 — Phase 1 의 최대 마찰점. 본 Phase 2-A 의 단일 과제는 **"on-device Gemma 4 E2B 가 사용자 raw text 를 입력받아, `FramePattern` 카탈로그를 few-shot 으로 참고하면서 L2/L3 후보 3개를 반환하는 단일 vertical slice 를 #204 위에 얹는 것"** 이다. 이 위에서 #2~#6 시나리오 (앵커 추출, dose variants 분해, if-then, lapse 구조화, 주간 요약) 는 동일한 `LlmService` 인터페이스에 함수만 추가하는 형태로 확장된다. ## 2. 범위 (Scope) ### 포함 - `flutter_gemma` (Gemma 4 E2B Q4_0, LiteRT-LM Android backend) 통합. - `LlmService` 추상 인터페이스 + `GemmaLlmService` 구현 (DI 친화). - `ModelLifecycle` — 모델 파일 가용성 확인 / 백그라운드 다운로드 (일시정지/재개 + 진행률) / 로드 / 언로드 / opt-out 시 즉시 삭제. - 단일 도메인 함수 `suggestFrame(rawText, habitType, anchorHint?) → List`. - `buildFewShotPrompt` — `FramePattern` 카탈로그에서 habit type + raw text 키워드 매칭으로 few-shot 예시 N개 동적 추출. - `parseFrameCandidates` — function calling JSON → `FrameCandidate` 도메인 모델 변환 + `validateFrameLevel` 으로 L0/L1 자동 거부. - Riverpod providers: `aiSettingsProvider` (opt-in 토글), `modelAvailabilityProvider` (다운로드 상태), `frameSuggestionsProvider` (async). - UI: `HabitCreateScreen` 의 framed_text 입력란 옆 "AI 제안" 버튼 → `FrameSuggestionDialog` (후보 3개 중 1개 선택 → framed_text 채움). - `SettingsScreen` 의 "AI 도움" 섹션 (opt-in 토글 + 다운로드 진행률 + 사용 통계). - 한국어 prompt 프로토타입 평가 corpus 30 케이스 (test fixture). - AI 미가용 (opt-out / 다운로드 미완료 / 저사양 기기 / 모델 로드 실패) 시 graceful degradation — 기존 수동 framed_text 입력 경로 100% 유지. ### 제외 (out of scope) - **시나리오 #2~#6** (앵커 추출, dose variants 분해, if-then, lapse 구조화, 주간 요약) — Phase 2-B+. 본 Phase 는 시나리오 #1 단일. - **iOS 빌드** — Phase 1 과 동일하게 Android 우선. `flutter_gemma` 의 iOS support 는 구조만 남기고 비활성. - **E4B 모델** — Phase 2-C 토글 (5GB RAM 요구, high-end 한정). v1 비활성 (ADR-0003 결정 #2). - **클라우드 LLM fallback** — 어떠한 형태로도 두지 않음 (ADR-0003 결정). - **파인튜닝 / LoRA** — v2+ 검토. - **멀티모달 (음성/이미지 입력)** — Phase 3+. - **모델 버전 교체 UI** (E2B v4.1 → v4.2 swap) — Phase 2-B+ 검토. v1 은 단일 모델 URL 하드코딩. - **시드 SoT 본문 (huberman protocols) 을 RAG 로 prompt 에 주입** — 본 Phase 는 `FramePattern` few-shot 만. RAG 는 Phase 2-C+. - **AI 사용 통계의 Drift 영속화** — 본 Phase 는 메모리 카운터만 (재시작 시 리셋). ## 3. 인수조건 (Acceptance Criteria) > Planner 가 확정한 AC 10개. QA 가 이걸로 판정. - [ ] **AC-1**: `flutter pub add flutter_gemma` 가 통과하고 `flutter analyze` 0 issue, `flutter build apk --debug` 성공. - [ ] **AC-2**: Settings 화면의 "AI 도움" 토글 기본 OFF. ON 으로 변경 시 동의 다이얼로그 (저장 공간 ≈ 1.5GB + WiFi 권장) 표시 후 백그라운드 다운로드 시작. - [ ] **AC-3**: 다운로드 중 일시정지 / 재개 / 취소 가능. 앱 강제 종료 후 재시작 시 다운로드 상태가 복원된다 (resume 지원). - [ ] **AC-4**: 다운로드 완료 후 `meta_kv['ai_model_path']` 에 절대 경로 저장, 무결성 체크 (SHA-256) 통과. - [ ] **AC-5**: AI 토글 ON + 모델 다운로드 완료 상태에서 `HabitCreateScreen` 의 framed_text 입력란 옆에 "AI 제안" 버튼이 표시된다. AI 토글 OFF 또는 모델 미로드 시 버튼 미표시 (기존 수동 입력 경로만). - [ ] **AC-6**: "술 끊고 싶어" (L0 입력) → "AI 제안" 탭 → 3 초 이내 (cold start) / 2 초 이내 (warm) 에 L2/L3 후보 3개가 다이얼로그에 표시된다. 1개 선택 시 framed_text 입력란이 자동으로 채워진다. - [ ] **AC-7**: 응답이 function calling JSON 으로 받아져 `FrameCandidate` 도메인 모델로 파싱된다. `validateFrameLevel` 통과한 후보만 노출 (L0/L1 응답은 자동 폐기 + 폐기 카운터 +1). - [ ] **AC-8**: opt-out 으로 AI 토글을 OFF 시 모델 파일이 즉시 삭제되고 (`File.delete`), `meta_kv['ai_model_path']` 가 비워진다. 사용자에게 "공간 확보됨 X.X GB" 토스트. - [ ] **AC-9**: 모델 로드 실패 / 저사양 기기 (RAM < 4GB 감지) / function calling timeout (10 초) 시 모든 경로가 graceful degradation — 사용자에게 "AI 제안 사용 불가, 직접 입력해주세요" 메시지 + 수동 입력 경로 차단 없음. - [ ] **AC-10**: 한국어 평가 corpus 30 케이스에서 ≥ 70% 가 (a) L2/L3 등급 + (b) `validateFrameLevel` 통과 + (c) 사용자 원본 의도와 의미적 일치 (수동 평가). ## 4. 컨텍스트 & 제약 ### 의존성 - **#204 Phase 1 MVP** 완료 상태 위 (Drift 21 테이블, 6 도메인 함수, 4 UI 화면, `FramePattern` 카탈로그 30 시드, `validateFrameLevel` 함수 모두 존재 가정). - **`flutter_gemma`** — pub.dev 패키지. Gemma 4 + function calling + LiteRT-LM Android backend 지원. v1 은 latest 버전 고정 후 pubspec.lock 동결. - **Gemma 4 E2B Q4_0** — 단일 모델. 다운로드 URL 은 Google 공식 호스팅 (Kaggle / HuggingFace 의 `google/gemma-4-e2b-it-q4_0`). 정확한 URL 은 Developer 단계에서 `flutter_gemma` 문서 확인 후 확정. - **`path_provider`** — 모델 파일 저장 위치 (`getApplicationSupportDirectory()`). - **`dio` 또는 `http`** — 재개 가능 다운로드. `flutter_gemma` 가 자체 제공하면 그것 사용. - **`crypto`** — SHA-256 무결성 검증. - **NO 네트워크 의존 (추론)** — 다운로드 외 모든 추론은 단말 로컬. ### 제약 - **R8 (≤ 60초 체크인)** — habit 생성 흐름은 체크인이 아니지만, AI 제안도 사용자 인내 한계 ≤ 5초. cold start 3초 / warm 2초 / function calling timeout 10초 hard cap. - **메모리** — Gemma 4 E2B Q4_0 ≈ 1.5GB RAM 상주. 모델 로드는 사용자가 "AI 제안" 탭한 시점 lazy load. UI 종료 후 60초 idle → 언로드 (메모리 회수). - **저장 공간** — 모델 파일 ≈ 1.5GB. opt-out 시 즉시 삭제 보장 (사용자 신뢰 자산). - **배터리** — 추론 1회 당 0.5–1% 추정. throttle 정책: 같은 habit 생성 세션 내 "AI 제안" ≤ 5회 (5회 초과 시 "잠시 후 다시" 메시지). - **프라이버시** — 사용자 raw text 는 어떠한 형태로도 단말 밖으로 나가지 않음 (ADR-0003). 로그도 prompt 본문을 기록하지 않고 (length, latency, success/fail) 메타만. - **단일 모델 가정** — 멀티 모델 슬롯 없음. v1.1 에서 E4B 토글 추가 시 ADR-0004. ### 가정 - Android API 26+ (Phase 1 과 동일). - 다운로드 시점 WiFi 권장 (cellular 도 허용하되 경고 다이얼로그). - 사용자가 의식적으로 "AI 도움" 토글을 ON 했다 — 정보 (저장 공간, 배터리) 는 동의 다이얼로그에서 전달 완료 가정. - `FramePattern` 카탈로그 30개로 few-shot 매칭이 충분 (한국어 prototype 평가에서 검증). ## 5. 아키텍처 개요 ### 디렉토리 구조 (추가분만) ``` app/ ├── lib/ │ ├── data/ │ │ └── ai/ # 신규 │ │ ├── llm_service.dart # abstract LlmService │ │ ├── gemma_llm_service.dart # flutter_gemma 구현체 │ │ └── model_lifecycle.dart # 다운로드/로드/언로드/삭제 │ ├── domain/ │ │ └── ai/ # 신규 │ │ ├── frame_candidate.dart # FrameCandidate 모델 │ │ ├── suggest_frame.dart # 핵심 도메인 함수 │ │ ├── few_shot_builder.dart # FramePattern → few-shot prompt │ │ └── parse_response.dart # function calling JSON → FrameCandidate[] │ ├── state/ │ │ └── ai_providers.dart # Riverpod (Ai 가용성·설정·로드 상태) │ └── ui/ │ ├── screens/ │ │ ├── habit_create_screen.dart # 수정: "AI 제안" 버튼 추가 │ │ └── settings_screen.dart # 신규: AI 토글 + 다운로드 진행률 │ └── widgets/ │ └── frame_suggestion_dialog.dart # 신규: 후보 3개 선택 └── test/ ├── data/ai/ │ ├── gemma_llm_service_test.dart # mock LlmService 동작 │ └── model_lifecycle_test.dart ├── domain/ai/ │ ├── suggest_frame_test.dart # mock LlmService 주입 │ ├── few_shot_builder_test.dart # FramePattern 시드 30개 입력 │ └── parse_response_test.dart # malformed JSON 케이스 └── fixtures/ai/ └── prompt_eval_corpus.json # 30 케이스 한국어 평가 corpus ``` ### 데이터 흐름 ``` [App start] │ ▼ [main.dart] ──► [AppDatabase 초기화 (기존)] │ └─► [aiSettingsProvider 초기화 (meta_kv['ai_opt_in'])] │ ├─ opt-in 이면: │ └─► [modelAvailabilityProvider] │ │ │ ├─ 모델 파일 존재 + 무결성 OK → Ready │ ├─ 다운로드 중 → resume │ └─ 미시작 → Idle (사용자 trigger 대기) │ └─ opt-out 이면: Disabled (모델 미로드) │ ▼ [사용자가 HabitCreateScreen 진입] │ ▼ framed_text 입력란 옆 "AI 제안" 버튼 노출 여부 = aiAvailable && modelReady │ ▼ (사용자가 raw text "술 끊고 싶어" 입력 + AI 제안 탭) │ ▼ [FrameSuggestionDialog] │ ▼ [frameSuggestionsProvider(raw, habitType, anchorHint)] │ ├─ if !modelLoaded: [LlmService.load()] (cold start 1–3초) │ ▼ [suggestFrame(raw, habitType, anchorHint)] │ ├─► [buildFewShotPrompt(raw, habitType)] │ └─ FramePattern 30 시드에서 raw text keyword 매칭 N개 추출 │ └─ habit type + level 가이드라인 + few-shot 예시 + 출력 스키마 │ ├─► [LlmService.generateStructured(prompt, schema)] │ └─ flutter_gemma function calling → JSON │ ├─► [parseFrameCandidates(json)] │ └─ FrameCandidate[] 변환 │ └─ 각 후보에 validateFrameLevel 적용 │ └─ L0/L1 응답 자동 폐기 + 카운터 +1 │ ▼ [FrameSuggestionDialog 후보 3개 표시] │ ▼ (사용자가 1개 탭) │ ▼ [HabitCreateScreen.framedTextController.text = 선택값] │ ▼ (기존 #204 흐름 합류: 사용자가 저장 탭) │ ▼ [validateFrameLevel] (기존 R3/R7 검사) ──► [HabitDao.insertWithVariants] (기존) ``` ### I/O ↔ 순수 로직 경계 - **I/O 계층** (`lib/data/ai/`): `flutter_gemma` 호출, 파일 다운로드, 디스크 read/write, SHA-256 검증. - **순수 도메인 로직** (`lib/domain/ai/`): `FramePattern[]` + raw text → prompt string (순수), JSON → `FrameCandidate[]` (순수), `validateFrameLevel` 적용 (이미 순수). **`flutter_gemma` import 금지**. - **DI 경계**: `LlmService` 는 추상. domain 함수는 `LlmService` 만 의존 → 테스트가 `MockLlmService` 로 가능 (네이티브 inference 없이). - **UI** (`lib/ui/`): Riverpod provider 통해서만 AI 상태/결과 받음. 비즈니스 결정 (어떤 후보를 추천? L0/L1 폐기?) 은 모두 domain 함수가 결정. ## 6. 데이터 모델 ### 입력 (`SuggestFrameInput`) ```dart class SuggestFrameInput { final String rawText; // 사용자 자유 입력 (1~200자) final HabitType habitType; // build | break final String? anchorHint; // optional "아침 양치 후" 등 } ``` 경계 검증: - `rawText.trim().length in [1, 200]`. 초과 시 reject (UI 단에서 truncate + 경고). - `rawText` NFC normalize. - `anchorHint` null OK. ### 출력 (`FrameCandidate`) ```dart class FrameCandidate { final FrameLevel level; // L2 | L3 (L0/L1 자동 폐기) final String framedText; // 모델이 생성한 한국어 텍스트 final double confidence; // 0.0~1.0 (모델이 반환한 score, 없으면 0.5) final String? sourcePatternId; // few-shot 매칭에 쓰인 FramePattern.id (refer-back) } ``` ### Function calling 스키마 (모델에 전달) ```json { "name": "emit_frame_candidates", "description": "Return 3 framed habit goal candidates at L2 or L3 level.", "parameters": { "type": "object", "properties": { "candidates": { "type": "array", "minItems": 3, "maxItems": 3, "items": { "type": "object", "properties": { "level": { "type": "string", "enum": ["L2", "L3"] }, "framed_text": { "type": "string", "maxLength": 120 }, "confidence": { "type": "number", "minimum": 0, "maximum": 1 } }, "required": ["level", "framed_text"] } } }, "required": ["candidates"] } } ``` ### 저장 (`meta_kv` 키 추가, schema 변경 없음) | key | value 타입 | 의미 | |-----|-----------|------| | `ai_opt_in` | `'true'` / `'false'` | AI 토글 상태 | | `ai_model_path` | 절대 경로 string | 모델 파일 위치 (다운로드 완료 시) | | `ai_model_sha256` | hex string | 다운로드 무결성 검증 결과 | | `ai_download_state` | `'idle'` / `'paused'` / `'downloading'` / `'completed'` | 다운로드 상태 | | `ai_download_bytes` | int as string | 진행률 복원용 | > `meta_kv` 는 Phase 1 에서 이미 존재. schema 변경 0건 → migration 불필요. ## 7. 함수 명세 (Function Specs) > 단순 = 본 표만으로 충분 / **복잡** = `fn-*.md` 별도 작성. | 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? | |------|-----------|----------------|------|------|-----------|-------| | `LlmService.generateStructured` | function calling JSON 응답 1회 받기 | `Future> generateStructured(String prompt, Map schema)` | prompt, schema | parsed JSON | timeout 10s → TimeoutException; 모델 미로드 → StateError | **복잡** → [fn-model_lifecycle.md](./fn-model_lifecycle.md) §LlmService | | `GemmaLlmService.load` | 모델 파일 → 메모리 로드 | `Future load()` | (none, path from lifecycle) | void | 파일 없음 → FileSystemException; OOM → 사용자 메시지 | **복잡** → [fn-model_lifecycle.md](./fn-model_lifecycle.md) | | `GemmaLlmService.unload` | 메모리에서 모델 해제 | `Future unload()` | none | void | idempotent | 단순 | | `ModelLifecycle.checkAvailability` | 디스크 + 무결성 확인 | `Future checkAvailability()` | none | enum {ready, missing, corrupt, downloading} | I/O 실패 → corrupt 취급 | **복잡** → [fn-model_lifecycle.md](./fn-model_lifecycle.md) | | `ModelLifecycle.download` | 백그라운드 다운로드 + 일시정지/재개 + SHA-256 검증 | `Stream download()` | none | progress stream | 네트워크 실패 → resume, 무결성 실패 → 파일 삭제 후 재다운로드 | **복잡** → [fn-model_lifecycle.md](./fn-model_lifecycle.md) | | `ModelLifecycle.purge` | opt-out 시 모델 파일 + meta_kv 키 모두 삭제 | `Future purge()` | none | 해제된 바이트 수 | 파일 미존재 → 0 반환 (idempotent) | 단순 | | `suggestFrame` | raw text → FrameCandidate[] (메인) | `Future> suggestFrame(SuggestFrameInput)` | input | candidates (≤ 3) | LlmService 실패 시 빈 리스트 반환 + 호출자에 메시지 위임 | **복잡** → [fn-suggest_frame.md](./fn-suggest_frame.md) | | `buildFewShotPrompt` | FramePattern[] + raw text → prompt string | `String buildFewShotPrompt(SuggestFrameInput, List)` | input, patterns | prompt | patterns 비어있음 → fallback 시스템 prompt | **복잡** → [fn-suggest_frame.md](./fn-suggest_frame.md) §buildFewShotPrompt | | `parseFrameCandidates` | JSON → FrameCandidate[] + L0/L1 폐기 | `List parseFrameCandidates(Map json)` | json | candidates | malformed → throw FormatException | **복잡** → [fn-suggest_frame.md](./fn-suggest_frame.md) §parseFrameCandidates | | `validateFrameLevel` | (기존 #204) R3 + R7 검사 | (기존) | FrameInput | result | (기존) | 기존 함수 재사용 | | `aiSettingsProvider` | Riverpod: ai_opt_in 토글 상태 | `StateNotifierProvider` | none | settings | meta_kv read 실패 → opt_in=false fallback | 단순 | | `modelAvailabilityProvider` | Riverpod: 모델 가용성 | `FutureProvider` | none | enum | I/O 실패 → corrupt | 단순 | | `frameSuggestionsProvider` | Riverpod: suggestFrame async wrap | `FutureProvider.family, SuggestFrameInput>` | input | candidates | suggestFrame 의 예외 그대로 전파 | 단순 | | `FrameSuggestionDialog` | UI: 후보 3개 선택 다이얼로그 | `Widget` | input, onSelect cb | Widget | 로딩 중 spinner, 빈 리스트 시 "다시 시도" 버튼 | 단순 | | `AiSettingsSection` | UI: Settings 화면의 AI 섹션 | `Widget` | none | Widget | 다운로드 진행률 listen | 단순 | > **복잡 함수 6개**: `LlmService.generateStructured` / `GemmaLlmService.load` / `ModelLifecycle.checkAvailability` / `ModelLifecycle.download` 는 [fn-model_lifecycle.md](./fn-model_lifecycle.md) 한 문서에서 묶어서 다룬다 (모두 모델 수명주기 + flutter_gemma I/O 경계의 한 덩어리). `suggestFrame` / `buildFewShotPrompt` / `parseFrameCandidates` 는 [fn-suggest_frame.md](./fn-suggest_frame.md) 에서 묶는다 (도메인 핵심 알고리즘). ## 8. 흐름 / 알고리즘 ### 시나리오 A: AI 토글 ON + 첫 다운로드 1. 사용자가 `SettingsScreen` 의 "AI 도움" 토글 OFF → ON 으로 전환. 2. UI: 동의 다이얼로그 — "Gemma 4 E2B 모델 ≈ 1.5GB 를 다운로드합니다. WiFi 권장. 진행률은 Settings 에서 확인할 수 있습니다. 끄면 즉시 삭제됩니다." [동의] / [취소]. 3. [동의] 탭 → `aiSettingsProvider` 가 `meta_kv['ai_opt_in'] = 'true'` 저장. 4. `ModelLifecycle.download()` Stream 시작. 진행률 broadcast. 5. UI: Settings 의 AI 섹션에 진행률 bar + 일시정지/재개/취소 버튼. 6. 다운로드 완료 → SHA-256 검증 → `meta_kv['ai_model_path'] = `, `meta_kv['ai_model_sha256'] = `, `meta_kv['ai_download_state'] = 'completed'`. 7. `modelAvailabilityProvider` 가 `ready` 로 전환 → `HabitCreateScreen` 의 "AI 제안" 버튼 활성. ### 시나리오 B: 사용자가 habit 생성 중 "AI 제안" 탭 1. `HabitCreateScreen` 에서 사용자가 raw text "술 끊고 싶어" 입력. 2. "AI 제안" 버튼 탭 → `FrameSuggestionDialog` 표시 (spinner). 3. dialog 가 `frameSuggestionsProvider(input)` 구독. 4. domain: `suggestFrame(input)` 호출. - 4-1. `LlmService` 가 미로드 상태면 `GemmaLlmService.load()` 트리거 (cold start 1–3초). - 4-2. `buildFewShotPrompt(input, framePatterns)`: - habit type = break + rawText keywords (`술`, `끊`, `금주`) 로 `FramePattern` 30개에서 매칭 ≥ 1 인 항목 상위 N=5 추출. - 시스템 prompt = "당신은 Huberman 프로토콜의 한국어 코치입니다. ..." (정적). - few-shot = 각 매칭 패턴의 `level_l0_example` / `level_l2_example` / `level_l3_example` 쌍을 예시로. - 사용자 입력 + 출력 스키마 안내. - 4-3. `LlmService.generateStructured(prompt, schema)` → function calling JSON. - 4-4. `parseFrameCandidates(json)`: - `candidates[]` length 검사. - 각 후보에 `validateFrameLevel({level, framed_text: framedText, original_text: rawText})` 적용. - `ok` 또는 `warn` 만 통과. `err` (L0/L1 또는 회피 키워드 hard) 자동 폐기. - 통과 ≥ 1 → 결과 반환. 모두 폐기 → 빈 리스트 + dev log 경고. 5. `FrameSuggestionDialog` 가 후보 3개 (또는 그 이하) 카드로 표시. 빈 리스트면 "다시 시도" 버튼. 6. 사용자가 카드 1개 탭 → `onSelect(candidate.framedText)` 콜백 → `HabitCreateScreen.framedTextController.text` 자동 채움. 7. dialog 닫힘. 사용자는 기존 #204 흐름 (저장 버튼 → `validateFrameLevel` 재검증 → `HabitDao.insertWithVariants`) 으로 합류. 8. dialog 닫힌 후 60초 idle 추적 → `GemmaLlmService.unload()` (메모리 회수). ### 시나리오 C: AI 토글 OFF (opt-out) 1. 사용자가 Settings 의 "AI 도움" 토글 ON → OFF. 2. 확인 다이얼로그 — "모델 파일 ≈ 1.5GB 가 즉시 삭제됩니다. 다시 켜면 다시 다운로드됩니다." [확인] / [취소]. 3. [확인] 탭 → `ModelLifecycle.purge()` 호출. 4. `unload()` → 파일 `delete()` → `meta_kv` 의 모든 `ai_*` 키 (opt_in 제외) clear. 5. `meta_kv['ai_opt_in'] = 'false'`. 6. 토스트: "공간 확보됨 X.X GB". 7. `HabitCreateScreen` 의 "AI 제안" 버튼 숨김. ### 시나리오 D: 다운로드 중 앱 종료 / 재시작 1. 사용자가 다운로드 진행 중 앱 강제 종료. 2. `meta_kv['ai_download_state'] = 'paused'`, `meta_kv['ai_download_bytes'] = ` 저장 상태. 3. 앱 재시작 → `modelAvailabilityProvider` 초기화 시 `checkAvailability()` 호출. 4. 상태 = `downloading` (paused) → 사용자에게 Settings 진입 시 "다운로드 재개" 버튼 표시. 5. [재개] 탭 → `download()` Stream 이 HTTP `Range: bytes=-` 로 resume. ## 9. 엣지케이스 & 에러 처리 | 상황 | 처리 | 비고 | |------|------|------| | 모델 다운로드 중 네트워크 끊김 | 자동 retry 3회 (exponential backoff) → 실패 시 `paused` 상태로 보존 | 사용자 수동 재개 | | 다운로드 완료 후 SHA-256 mismatch | 파일 삭제 + 사용자 메시지 "다운로드 손상, 재시도" + state 초기화 | rare, 대역폭 낭비 | | 모델 파일이 다른 앱/사용자에 의해 삭제 (외부) | `checkAvailability` 가 `missing` 반환 → "AI 제안" 버튼 숨김 + Settings 에 "재다운로드" 노출 | scoped storage 로 사실상 불가능하나 sd카드 외장 케이스 | | `flutter_gemma` 추론 timeout (10초 초과) | TimeoutException → 빈 리스트 반환 + dialog 에 "응답이 늦어집니다. 다시 시도" | UX 영향 큼, throttle 카운터 +1 | | function calling 응답이 malformed JSON | `FormatException` → 빈 리스트 반환 + dev log + 폐기 카운터 +1 | 모델 품질 문제 | | function calling 응답의 모든 후보가 L0/L1 | `parseFrameCandidates` 가 빈 리스트 반환 → dialog "더 구체적으로 입력해주세요" 메시지 | prompt 개선 필요 신호 | | 사용자 raw text > 200자 | UI 단에서 입력 차단 + 경고 | prompt 비용 + 한국어 token 폭증 방지 | | 모델 로드 실패 (OOM) | StateError → graceful: AI 비활성 + 사용자에게 "기기 메모리 부족으로 AI 사용 불가" 토스트 | Pixel 5 이하 / RAM 4GB 이하 | | 사용자가 dialog 닫고 1초 안에 다시 탭 | warm 상태 (모델 로드됨) → 0.5–2초 응답 | cache 없음, 매번 새 추론 | | 같은 세션 "AI 제안" ≥ 5회 | throttle → "잠시 후 다시 시도" | 배터리 보호 | | 추론 도중 앱이 background 전환 | flutter_gemma 가 native 측에서 isolated → 결과는 완성되면 broadcast | UX: spinner 유지 | ### 안전한 기본값 - AI 토글 기본 OFF — 사용자가 의식적 opt-in 했을 때만 동작. - 모든 AI 경로가 실패 시 빈 리스트 반환 + 호출자가 UI 메시지 결정. domain 함수는 throw 하지 않음 (graceful). - L0/L1 응답 자동 폐기 — 데이터 등급 보장. - 60초 idle 시 자동 unload — 메모리 회수 자동. ## 10. 테스트 계획 ### 단위 테스트 (AC 매핑) | AC | 테스트 | 위치 | 모킹 | |----|--------|------|------| | AC-1 | `flutter analyze` 통과 (CI) | scripts/ci | — | | AC-2 | `ai_settings_test.dart` — opt_in 토글 + meta_kv 영속 | test/state | mock meta_kv DAO | | AC-3 | `model_lifecycle_test.dart` — 다운로드 일시정지/재개/취소 + 앱 재시작 복원 | test/data/ai | mock HTTP server (range request) | | AC-4 | `model_lifecycle_test.dart` — SHA-256 검증 | test/data/ai | fixture file | | AC-5 | `habit_create_widget_test.dart` — 토글 ON/OFF 에 따른 버튼 노출 | test/ui | mock providers | | AC-6 | `suggest_frame_test.dart` — latency assert (mock LlmService 가 0ms 응답) | test/domain/ai | MockLlmService | | AC-7 | `parse_response_test.dart` — function calling JSON → FrameCandidate + L0/L1 폐기 | test/domain/ai | static JSON fixtures | | AC-8 | `model_lifecycle_test.dart` — purge() idempotent + meta_kv clear | test/data/ai | tmp file | | AC-9 | `suggest_frame_test.dart` — LlmService throw 시 빈 리스트 + 비차단 | test/domain/ai | MockLlmService throwing | | AC-10 | `prompt_eval_corpus.json` 30 케이스 평가 (manual or scripted scorer) | test/fixtures/ai + scripts/eval | real Gemma 4 (E2E, optional CI) | ### 모킹 / 드라이런 - **`LlmService` 추상화** — 모든 domain 테스트는 `MockLlmService` 주입. flutter_gemma 네이티브 미호출 → CI 에서 빠른 실행. - **HTTP mock** — 다운로드 테스트는 `httpServerTest` 로 local server + Range header 검증. - **fixture 모델 파일** — 1KB dummy + 알려진 SHA-256 으로 무결성 로직 검증 (실제 1.5GB 다운로드 X). - **한국어 평가 corpus** — `test/fixtures/ai/prompt_eval_corpus.json` 30 케이스: `{raw, habit_type, expected_keywords[], expected_levels[]}`. AC-10 의 ≥ 70% 통과 기준. - **Goldens** — UI 다이얼로그는 golden test 1개 (3 후보 카드 레이아웃). ## 11. 리스크 & 대안 검토 ### 핵심 결정: on-device LLM 도입 자체 → [ADR-0003 on-device LLM Gemma](../../adr/0003-on-device-llm-gemma.md) 에 전면 기록. 5가지 대안 (A 클라우드, C 정적 카탈로그 확장, D Llama/Phi, E E4B, F APK 번들) 모두 기각 사유 명시. ### 본 설계서 내 결정 | 결정 | 채택 | 대안 | 근거 | |------|------|------|------| | `LlmService` 추상화 | ✓ | flutter_gemma 직접 호출 | 테스트 가능성 + 향후 모델 swap 여지 | | Function calling | ✓ | 자유 텍스트 + 정규표현식 파싱 | 응답 신뢰도 + 코드 단순 (ADR-0003) | | few-shot 동적 추출 | ✓ | 정적 prompt 하드코딩 | SoT (`FramePattern`) 변경 시 동기화 부담 0 (ADR-0003) | | 60초 idle 시 자동 unload | ✓ | 항상 로드 유지 | 메모리 1.5GB 부담 — RAM 6GB 기기 멀티태스킹 | | throttle 5회/세션 | ✓ | 무제한 | 배터리 보호 (1회당 0.5–1%) | | AC-10 ≥ 70% threshold | ✓ | ≥ 90% | v1 현실적 baseline. v1.1 에서 prompt 개선으로 상승 목표 | | 단일 모델 (E2B) | ✓ | E2B/E4B 듀얼 | hardware fragmentation 단순화 (ADR-0003 결정 #2) | ### 되돌리기 어려운 결정 → ADR 후보 - on-device LLM 자체 = **ADR-0003 발행 완료**. - E4B 토글 추가 = **ADR-0004 후보** (Phase 2-C). - 시나리오 #2~#6 함수 확장 = **ADR-0005 후보** (Phase 2-B+). ## 12. 미해결 질문 (Open Questions) | OQ | 질문 | 결정 시점 | 비고 | |----|------|----------|------| | **OQ-1** | Gemma 4 E2B Q4_0 의 정확한 다운로드 URL? | Developer 단계 (flutter_gemma 문서 확인) | pubspec.lock 동결 시 함께 고정 | | **OQ-2** | 한국어 평가 corpus 30 케이스의 expected_levels 라벨링 기준? | QA 단계 직전 | Architect 가 sample 5개 작성 후 사용자 (joungmin) 확정 | | **OQ-3** | 다운로드 시 모바일 데이터 허용 — 기본 OFF + 사용자 토글? 아니면 매번 confirm? | Developer 단계 (UX 결정) | Settings 의 "WiFi 전용" 토글 추가 검토 | | **OQ-4** | function calling timeout 10초가 적절? | QA 단계 (실측) | 저사양 기기에서 cold start 5초+ 가능성 | | **OQ-5** | 폐기 카운터 (`ai_l0l1_discard_count` 등) 를 `meta_kv` 에 영속? | v1.1 검토 | v1 은 메모리만 | | **OQ-6** | 사용자가 후보 3개 모두 마음에 안 들 때 "다시 생성" 버튼 — 같은 prompt 로 재요청 vs prompt 변형? | Developer 단계 | Temperature 변경 옵션? Gemma 4 의 sampling 파라미터 노출 여부 확인 필요 | --- ## 부록: 자가 점검 (Architect 가 작업 종료 시 검증) - [x] `_TEMPLATE.md` 12 개 섹션 모두 비어있지 않음 - [x] ADR-0003 cross-link 완료 (§5 §11 §부록) - [x] 복잡 함수 7개 → 2개 fn-*.md 로 묶어서 다룸 (suggest_frame 3개 + model_lifecycle 4개) - [x] §7 함수 명세 표에 모든 함수 등재 (단순 8 + 복잡 7) - [x] AC §3 — Planner 가 정한 10개 AC 모두 검증 가능한 형태로 §10 테스트와 1:1 매핑 - [x] graceful degradation 명시: 모든 AI 경로 실패 시 수동 입력 차단 X (AC-9, §9 안전한 기본값) - [x] 프라이버시 원칙 명시: raw text 단말 밖 송출 0 (§4 제약, §9 log 메타만) - [x] §12 OQ 6개 명시 + 결정 시점 + 책임자 - [x] #204 와의 통합점 명확: `FramePattern` 30 시드 + `validateFrameLevel` 재사용 (§5 §8 시나리오 B)