- docs/reference/215-ai-frame-suggest.md — v0.2.0 모듈/함수/Riverpod/meta_kv 사양
- docs/guides/ai-help-onboarding.md — AI 도움 켜기/끄기 사용자 가이드
- docs/design/215-gemma-frame-suggest/{README,fn-suggest_frame,fn-model_lifecycle}
상태 Draft → Approved, 추적성 헤더에 실제 구현 파일/테스트 경로 + 레퍼런스/가이드 cross-link
- docs/README.md — 현재 발행된 문서 인덱스 섹션 추가
Refs #215
설계서: Phase 2-A — Gemma 4 on-device 프레임 자동 생성 (#215)
상태: Approved (v0.2.0, 커밋
0d1db2d) 작성: [AI] Architect · 최종수정: 2026-06-12 (Documenter) 추적성 — Redmine: #215 · 관련 ADR: ADR-0003 on-device LLM Gemma · 릴리스 태그:v0.2.0· 구현 파일:
app/lib/data/ai/llm_service.dart(abstract + MockLlmService)app/lib/data/ai/gemma_llm_service.dart(stub — OQ-1 후 활성)app/lib/data/ai/model_lifecycle.dart(download/verify/purge)app/lib/domain/ai/frame_candidate.dartapp/lib/domain/ai/suggest_frame.dartapp/lib/domain/ai/few_shot_builder.dartapp/lib/domain/ai/parse_response.dartapp/lib/state/ai_providers.dart(Riverpod providers + ModelDownloadController)app/lib/ui/screens/settings_screen.dartapp/lib/ui/widgets/frame_suggestion_dialog.dartapp/lib/ui/screens/habit_create_screen.dart(_AiSuggestButton) · 테스트:app/test/domain/ai/{suggest_frame,few_shot_builder,parse_response}_test.dartapp/test/data/ai/model_lifecycle_test.dartapp/test/state/model_download_controller_test.dartapp/test/ui/ai_suggest_button_visibility_test.dart· 레퍼런스: docs/reference/215-ai-frame-suggest.md · 사용 가이드: docs/guides/ai-help-onboarding.md알려진 follow-up (Reviewer F1/F2 + OQ-1):
- OQ-1: 실제 Gemma 4 E2B Q4_0 모델 URL + SHA-256 — 현재 placeholder (
example.invalid).- F1: 60초 idle auto-unload 미구현 — stub 상태라 무의미. OQ-1 해결 시 추가.
- F2:
ModelLifecycle.purge()의File.delete()try/catch 미감쌈 — placeholder URL 라 도달 불가. 하위 문서:- fn-suggest_frame.md — Gemma 4 호출 + few-shot 조립 + 응답 파싱
- 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<FrameCandidate>. 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 는
FramePatternfew-shot 만. RAG 는 Phase 2-C+. - AI 사용 통계의 Drift 영속화 — 본 Phase 는 메모리 카운터만 (재시작 시 리셋).
3. 인수조건 (Acceptance Criteria)
Planner 가 확정한 AC 10개. QA 가 이걸로 판정.
- AC-1:
flutter pub add flutter_gemma가 통과하고flutter analyze0 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_gemmaimport 금지. - DI 경계:
LlmService는 추상. domain 함수는LlmService만 의존 → 테스트가MockLlmService로 가능 (네이티브 inference 없이). - UI (
lib/ui/): Riverpod provider 통해서만 AI 상태/결과 받음. 비즈니스 결정 (어떤 후보를 추천? L0/L1 폐기?) 은 모두 domain 함수가 결정.
6. 데이터 모델
입력 (SuggestFrameInput)
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 + 경고).rawTextNFC normalize.anchorHintnull OK.
출력 (FrameCandidate)
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 스키마 (모델에 전달)
{
"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<Map<String,dynamic>> generateStructured(String prompt, Map schema) |
prompt, schema | parsed JSON | timeout 10s → TimeoutException; 모델 미로드 → StateError | 복잡 → fn-model_lifecycle.md §LlmService |
GemmaLlmService.load |
모델 파일 → 메모리 로드 | Future<void> load() |
(none, path from lifecycle) | void | 파일 없음 → FileSystemException; OOM → 사용자 메시지 | 복잡 → fn-model_lifecycle.md |
GemmaLlmService.unload |
메모리에서 모델 해제 | Future<void> unload() |
none | void | idempotent | 단순 |
ModelLifecycle.checkAvailability |
디스크 + 무결성 확인 | Future<ModelAvailability> checkAvailability() |
none | enum {ready, missing, corrupt, downloading} | I/O 실패 → corrupt 취급 | 복잡 → fn-model_lifecycle.md |
ModelLifecycle.download |
백그라운드 다운로드 + 일시정지/재개 + SHA-256 검증 | Stream<DownloadProgress> download() |
none | progress stream | 네트워크 실패 → resume, 무결성 실패 → 파일 삭제 후 재다운로드 | 복잡 → fn-model_lifecycle.md |
ModelLifecycle.purge |
opt-out 시 모델 파일 + meta_kv 키 모두 삭제 | Future<int> purge() |
none | 해제된 바이트 수 | 파일 미존재 → 0 반환 (idempotent) | 단순 |
suggestFrame |
raw text → FrameCandidate[] (메인) | Future<List<FrameCandidate>> suggestFrame(SuggestFrameInput) |
input | candidates (≤ 3) | LlmService 실패 시 빈 리스트 반환 + 호출자에 메시지 위임 | 복잡 → fn-suggest_frame.md |
buildFewShotPrompt |
FramePattern[] + raw text → prompt string | String buildFewShotPrompt(SuggestFrameInput, List<FramePattern>) |
input, patterns | prompt | patterns 비어있음 → fallback 시스템 prompt | 복잡 → fn-suggest_frame.md §buildFewShotPrompt |
parseFrameCandidates |
JSON → FrameCandidate[] + L0/L1 폐기 | List<FrameCandidate> parseFrameCandidates(Map json) |
json | candidates | malformed → throw FormatException | 복잡 → fn-suggest_frame.md §parseFrameCandidates |
validateFrameLevel |
(기존 #204) R3 + R7 검사 | (기존) | FrameInput | result | (기존) | 기존 함수 재사용 |
aiSettingsProvider |
Riverpod: ai_opt_in 토글 상태 | StateNotifierProvider<AiSettingsNotifier, AiSettings> |
none | settings | meta_kv read 실패 → opt_in=false fallback | 단순 |
modelAvailabilityProvider |
Riverpod: 모델 가용성 | FutureProvider<ModelAvailability> |
none | enum | I/O 실패 → corrupt | 단순 |
frameSuggestionsProvider |
Riverpod: suggestFrame async wrap | FutureProvider.family<List<FrameCandidate>, 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 한 문서에서 묶어서 다룬다 (모두 모델 수명주기 + flutter_gemma I/O 경계의 한 덩어리).suggestFrame/buildFewShotPrompt/parseFrameCandidates는 fn-suggest_frame.md 에서 묶는다 (도메인 핵심 알고리즘).
8. 흐름 / 알고리즘
시나리오 A: AI 토글 ON + 첫 다운로드
- 사용자가
SettingsScreen의 "AI 도움" 토글 OFF → ON 으로 전환. - UI: 동의 다이얼로그 — "Gemma 4 E2B 모델 ≈ 1.5GB 를 다운로드합니다. WiFi 권장. 진행률은 Settings 에서 확인할 수 있습니다. 끄면 즉시 삭제됩니다." [동의] / [취소].
- [동의] 탭 →
aiSettingsProvider가meta_kv['ai_opt_in'] = 'true'저장. ModelLifecycle.download()Stream 시작. 진행률 broadcast.- UI: Settings 의 AI 섹션에 진행률 bar + 일시정지/재개/취소 버튼.
- 다운로드 완료 → SHA-256 검증 →
meta_kv['ai_model_path'] = <path>,meta_kv['ai_model_sha256'] = <hex>,meta_kv['ai_download_state'] = 'completed'. modelAvailabilityProvider가ready로 전환 →HabitCreateScreen의 "AI 제안" 버튼 활성.
시나리오 B: 사용자가 habit 생성 중 "AI 제안" 탭
HabitCreateScreen에서 사용자가 raw text "술 끊고 싶어" 입력.- "AI 제안" 버튼 탭 →
FrameSuggestionDialog표시 (spinner). - dialog 가
frameSuggestionsProvider(input)구독. - domain:
suggestFrame(input)호출.- 4-1.
LlmService가 미로드 상태면GemmaLlmService.load()트리거 (cold start 1–3초). - 4-2.
buildFewShotPrompt(input, framePatterns):- habit type = break + rawText keywords (
술,끊,금주) 로FramePattern30개에서 매칭 ≥ 1 인 항목 상위 N=5 추출. - 시스템 prompt = "당신은 Huberman 프로토콜의 한국어 코치입니다. ..." (정적).
- few-shot = 각 매칭 패턴의
level_l0_example/level_l2_example/level_l3_example쌍을 예시로. - 사용자 입력 + 출력 스키마 안내.
- habit type = break + rawText keywords (
- 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 경고.
- 4-1.
FrameSuggestionDialog가 후보 3개 (또는 그 이하) 카드로 표시. 빈 리스트면 "다시 시도" 버튼.- 사용자가 카드 1개 탭 →
onSelect(candidate.framedText)콜백 →HabitCreateScreen.framedTextController.text자동 채움. - dialog 닫힘. 사용자는 기존 #204 흐름 (저장 버튼 →
validateFrameLevel재검증 →HabitDao.insertWithVariants) 으로 합류. - dialog 닫힌 후 60초 idle 추적 →
GemmaLlmService.unload()(메모리 회수).
시나리오 C: AI 토글 OFF (opt-out)
- 사용자가 Settings 의 "AI 도움" 토글 ON → OFF.
- 확인 다이얼로그 — "모델 파일 ≈ 1.5GB 가 즉시 삭제됩니다. 다시 켜면 다시 다운로드됩니다." [확인] / [취소].
- [확인] 탭 →
ModelLifecycle.purge()호출. unload()→ 파일delete()→meta_kv의 모든ai_*키 (opt_in 제외) clear.meta_kv['ai_opt_in'] = 'false'.- 토스트: "공간 확보됨 X.X GB".
HabitCreateScreen의 "AI 제안" 버튼 숨김.
시나리오 D: 다운로드 중 앱 종료 / 재시작
- 사용자가 다운로드 진행 중 앱 강제 종료.
meta_kv['ai_download_state'] = 'paused',meta_kv['ai_download_bytes'] = <bytes>저장 상태.- 앱 재시작 →
modelAvailabilityProvider초기화 시checkAvailability()호출. - 상태 =
downloading(paused) → 사용자에게 Settings 진입 시 "다운로드 재개" 버튼 표시. - [재개] 탭 →
download()Stream 이 HTTPRange: bytes=<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.json30 케이스:{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 에 전면 기록. 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 가 작업 종료 시 검증)
_TEMPLATE.md12 개 섹션 모두 비어있지 않음- ADR-0003 cross-link 완료 (§5 §11 §부록)
- 복잡 함수 7개 → 2개 fn-*.md 로 묶어서 다룸 (suggest_frame 3개 + model_lifecycle 4개)
- §7 함수 명세 표에 모든 함수 등재 (단순 8 + 복잡 7)
- AC §3 — Planner 가 정한 10개 AC 모두 검증 가능한 형태로 §10 테스트와 1:1 매핑
- graceful degradation 명시: 모든 AI 경로 실패 시 수동 입력 차단 X (AC-9, §9 안전한 기본값)
- 프라이버시 원칙 명시: raw text 단말 밖 송출 0 (§4 제약, §9 log 메타만)
- §12 OQ 6개 명시 + 결정 시점 + 책임자
- #204 와의 통합점 명확:
FramePattern30 시드 +validateFrameLevel재사용 (§5 §8 시나리오 B)