Files
life-helper/docs/design/215-gemma-frame-suggest
joungmin d31b17f3e8 [Architect] #215 ADR-0003 + design spec for Gemma frame suggest
- ADR-0003: on-device LLM Gemma 4 E2B Q4_0 + flutter_gemma 도입 결정.
  5개 대안(클라우드/정적확장/Llama/E4B/APK번들) 기각 사유 명시.
- docs/design/215-gemma-frame-suggest/: 설계서 게이트 통과 산출물.
  README.md (12 섹션 전부 + AC10 + OQ6 + 함수 15개) +
  fn-suggest_frame.md (suggestFrame/buildFewShotPrompt/parseFrameCandidates) +
  fn-model_lifecycle.md (LlmService/GemmaLlmService/ModelLifecycle).
- graceful degradation 전면: AI 실패 시 throw 없이 빈 리스트 + 수동 입력 유지.
- LlmService 추상화로 도메인 ↔ flutter_gemma 경계 분리 (테스트 가능성).

Refs #215
2026-06-12 11:16:15 +09:00
..

설계서: Phase 2-A — Gemma 4 on-device 프레임 자동 생성 (#215)

상태: Draft 작성: [AI] Architect · 최종수정: 2026-06-12 추적성 — Redmine: #215 · 관련 ADR: ADR-0003 on-device LLM Gemma · 구현 파일: 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) 하위 문서:


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>.
  • buildFewShotPromptFramePattern 카탈로그에서 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.51% 추정. 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 13초)
    │
    ▼
[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)

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)

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.downloadfn-model_lifecycle.md 한 문서에서 묶어서 다룬다 (모두 모델 수명주기 + flutter_gemma I/O 경계의 한 덩어리). suggestFrame / buildFewShotPrompt / parseFrameCandidatesfn-suggest_frame.md 에서 묶는다 (도메인 핵심 알고리즘).

8. 흐름 / 알고리즘

시나리오 A: AI 토글 ON + 첫 다운로드

  1. 사용자가 SettingsScreen 의 "AI 도움" 토글 OFF → ON 으로 전환.
  2. UI: 동의 다이얼로그 — "Gemma 4 E2B 모델 ≈ 1.5GB 를 다운로드합니다. WiFi 권장. 진행률은 Settings 에서 확인할 수 있습니다. 끄면 즉시 삭제됩니다." [동의] / [취소].
  3. [동의] 탭 → aiSettingsProvidermeta_kv['ai_opt_in'] = 'true' 저장.
  4. ModelLifecycle.download() Stream 시작. 진행률 broadcast.
  5. UI: Settings 의 AI 섹션에 진행률 bar + 일시정지/재개/취소 버튼.
  6. 다운로드 완료 → SHA-256 검증 → meta_kv['ai_model_path'] = <path>, meta_kv['ai_model_sha256'] = <hex>, meta_kv['ai_download_state'] = 'completed'.
  7. modelAvailabilityProviderready 로 전환 → 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 13초).
    • 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'] = <bytes> 저장 상태.
  3. 앱 재시작 → modelAvailabilityProvider 초기화 시 checkAvailability() 호출.
  4. 상태 = downloading (paused) → 사용자에게 Settings 진입 시 "다운로드 재개" 버튼 표시.
  5. [재개] 탭 → download() Stream 이 HTTP Range: bytes=<bytes>- 로 resume.

9. 엣지케이스 & 에러 처리

상황 처리 비고
모델 다운로드 중 네트워크 끊김 자동 retry 3회 (exponential backoff) → 실패 시 paused 상태로 보존 사용자 수동 재개
다운로드 완료 후 SHA-256 mismatch 파일 삭제 + 사용자 메시지 "다운로드 손상, 재시도" + state 초기화 rare, 대역폭 낭비
모델 파일이 다른 앱/사용자에 의해 삭제 (외부) checkAvailabilitymissing 반환 → "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.52초 응답 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).
  • 한국어 평가 corpustest/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 에 전면 기록. 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.51%)
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.md 12 개 섹션 모두 비어있지 않음
  • 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 와의 통합점 명확: FramePattern 30 시드 + validateFrameLevel 재사용 (§5 §8 시나리오 B)