Files
life-helper/docs/design/218-gemma-real-integration
joungmin f71d132fa3 [03-Developer] #218 Dev round 2 — AC-6 RAM 4GB gate + AC-10 docs cleanup
QA round 1 (commit 9a9eb2a) FAIL 시 누락된 두 AC 보강.

AC-6: device_info_plus 만으론 4GB 임계 측정 불가 (isLowRamDevice 는
~1GB 기준). MethodChannel `life_helper/device_caps` 신설 + MainActivity.kt
에서 ActivityManager.MemoryInfo.totalMem 노출. data/ai/device_capabilities.dart
는 DeviceCapabilities abstract + PlatformDeviceCapabilities + 4 GiB
임계. deviceMeetsAiRamProvider (FutureProvider<bool>, fail-closed).
SettingsScreen 토글 disabled + "RAM 부족" 안내 (RAM < 4GB).

AC-10: docs/reference/215-ai-frame-suggest.md 의 OQ-1/placeholder
6곳을 실 구현 표현으로 갱신. §8 알려진 제약 = AC-6 device gate +
AC-7 실 단말 E2E + F1 unload + #221 corpus 평가. §9 다음 단계 =
#219~#222 follow-up 목록. 신규 테스트 합계 41 / 전체 88 통과.

테스트: device_capabilities_test.dart 7 신규 (kAiMinRamBytes 동등,
null/0/3.9GB/4GB-1/4GB/8GB 경계). flutter analyze 무이슈, 전체 88 통과
(71 기존 + 10 gemma + 7 RAM gate).

Architect 설계서 §4 의 "RAM 4GB 차단 = AC-9 재활용" 문구는 사실 #215
미구현 사항이라 본 라운드에서 신규 추가했음을 README 에 명기.

Refs #218
2026-06-12 15:45:14 +09:00
..

설계서: OQ-1 — 실 Gemma 통합 (#218)

상태: Draft 작성: [AI] Architect · 작성일: 2026-06-12 추적성 — Redmine: #218 · 상위/이전: #215 (v0.2.0 placeholder) · 관련 ADR: ADR-0003 on-device LLM Gemma (재확인, 신규 ADR 없음) · 변경 대상 구현 파일:

  • app/lib/data/ai/gemma_llm_service.dart (현재 UnimplementedError stub → 실 구현)
  • app/lib/data/ai/model_lifecycle.dart (ModelLifecycle.purge F2 hardening + load path 연결)
  • app/lib/state/ai_providers.dart (_kModelUrlPlaceholder / _kModelShaPlaceholder → 실값, llmServiceProvider 의 production override 활성화 path)
  • app/lib/main.dart (MockLlmServiceGemmaLlmService 조건부 override)
  • app/pubspec.yaml (flutter_gemma: 0.16.5 추가)
  • app/android/app/build.gradle (minSdkVersion 24+ 확인), app/android/app/src/main/AndroidManifest.xml (OpenGL ES feature, MediaPipe ProGuard rules) · 추가 테스트:
  • app/test/data/ai/gemma_llm_service_test.dart (schema → Tool 변환, FunctionCallResponse 수집 단위, error mapping)
  • app/test/data/ai/model_lifecycle_test.dart (기존 + F2 case 추가)
  • E2E: AC-7 실 단말 수동 (Android 8GB+) · 선행 설계서 (변경 없음): docs/design/215-gemma-frame-suggest/ — placeholder 기반 v0.2.0 청사진. 본 설계서는 placeholder 자리 채움에 한정. · 하위 문서:
  • fn-gemma_llm_service.mdGemmaLlmService.load / generateStructured 의 schema→Tool 변환 + 스트림 응답 수집 + 에러 매핑 알고리즘

1. 목적 (Why)

v0.2.0 (#215) 은 mock 환경에서 100% 동작하지만, 사용자가 "AI 도움" 토글을 ON 하면 _kModelUrlPlaceholder = 'https://example.invalid/...' 로 인해 graceful 실패 다이얼로그만 본다. 실 사용자 가치는 0. 본 작업의 단일 과제는 "#215 가 정의한 LlmService 추상화의 뒤편을 실 flutter_gemma 0.16.5 + 실 Gemma 4 E2B QAT 모바일 모델로 교체하여, mock 경로와 동일한 사용자 흐름이 실제로 후보 문장을 반환하게 만드는 것" 이다.

청사진(#215)·UI·도메인 로직은 모두 그대로 둔다. 본 설계서는 placeholder 3 지점 (_kModelUrlPlaceholder, _kModelShaPlaceholder, GemmaLlmService 본문) 만 다룬다.

2. 범위 (Scope)

포함

  • flutter_gemma 0.16.5 pubspec 추가 + pubspec.lock 동결.
  • Gemma 4 E2B QAT 모바일 모델 URL 확정 (HuggingFace litert-community/gemma-4-E2B-it-litert-lm.task 또는 .litertlm 자산, 또는 google/gemma-4-E2B-it-qat-mobile-transformers) + SHA-256 핀 고정.
  • GemmaLlmService.load/generateStructured/unload 실 구현 (flutter_gemma 0.16.5 의 FlutterGemma.initialize + chat session + Stream).
  • function calling 스키마 (kFrameCandidatesSchema JSON Schema) → flutter_gemma Tool 객체 변환 어댑터.
  • FunctionCallResponse(name, args) 스트림 이벤트를 수집하여 args: Map<String, dynamic> 반환.
  • Android 빌드 설정: minSdkVersion 24+ 확인, OpenGL ES feature 선언, MediaPipe ProGuard rules.
  • 단말 게이트: RAM 4GB 미만 차단 (AC-9, #215 §9 재활용 — 새 메서드 없음).
  • HuggingFace access token 핸들링: 빌드 시점 --dart-define=HF_TOKEN=... 주입 (사용자 단말에 평문 저장 X, 모델 다운로드 1회만 사용).
  • _kModelUrlPlaceholder / _kModelShaPlaceholder 상수 → 실값으로 치환 + 상수명에서 Placeholder 제거.
  • main.dart 의 production override: aiSettingsProvider == true && modelAvailability == ready 일 때만 GemmaLlmService(modelPath: ...) 으로 override, 그 외엔 MockLlmService 유지 (graceful).
  • F2 hardening 통합: ModelLifecycle.purge()File.delete() 를 try/catch 로 감쌈 (실파일이라 도달 가능).

제외 (out of scope)

  • #219 F1: 60초 idle auto-unload — 별도 이슈. 본 설계는 즉시 load + 명시적 unload 만.
  • #220 F2 broader purge hardening: 위 단일 try/catch 외 광범위 hardening (예: 부분 다운로드 .tmp 정리 순서, 동시성) 은 #220 으로.
  • #221 AC-10: 한국어 corpus 30 케이스 평가 자동화 — 별도 이슈. 본 설계는 AC-7 실 단말 E2E 수동 검증만.
  • #222 production keystore: 릴리스 서명 키 + Play Store 검토 별도.
  • E4B 모델: ADR-0003 결정 #2 유지 — v1 은 E2B 단일.
  • iOS 빌드: Phase 1 과 동일 Android-only.
  • 시나리오 #2~#6 (앵커, dose variants, if-then, lapse, 주간 요약): Phase 2-B+.
  • 모델 교체 UI (E2B v1 → v2 swap): v2+.
  • HF 토큰 사용자 UX: v1 은 빌드 임베드 (joungmin 토큰). v2 에서 사용자 본인 토큰 입력 화면 검토.

3. 인수조건 (Acceptance Criteria)

Planner 가 정한 10개 그대로. QA 판정.

  • AC-1: flutter pub add flutter_gemma:^0.16.5 통과 + flutter analyze 0 issue + flutter build apk --debug 성공.
  • AC-2: 사용자 "AI 도움" 토글 ON 시 동의 다이얼로그 (#215 그대로) → 백그라운드 다운로드가 example.invalid 가 아닌 실 HF endpoint 로 향한다.
  • AC-3: 다운로드 일시정지/재개/취소 동작이 실 HF URL 에 대해서도 보존 (HTTP Range 응답 검증). 강제 종료 후 resume 정상.
  • AC-4: 다운로드 완료 후 SHA-256 검증이 실 모델 파일에 대해 통과 + meta_kv['ai_model_path'] 에 절대 경로 저장.
  • AC-5: 다운로드 완료 후 HabitCreateScreen 의 "AI 제안" 버튼이 활성 (#215 UI 그대로).
  • AC-6: 실 단말 (RAM ≥ 8GB) 에서 "술 끊고 싶어" → 후보 3개가 5초 이내 (cold start) / 2초 이내 (warm) 표시.
  • AC-7: 실 Android 단말 E2E — 후보가 FrameCandidate.level ∈ {L2, L3} 이고 validateFrameLevel 통과 ≥ 1.
  • AC-8: opt-out 시 모델 파일 즉시 삭제 (File.delete) + meta_kv clear + "공간 확보됨" 토스트. F2 hardening 으로 File.delete 예외도 graceful.
  • AC-9: RAM < 4GB 단말 또는 모델 로드 OOM 또는 generateStructured timeout 10s 시 빈 리스트 반환 + 수동 입력 경로 차단 없음.
  • AC-10: 한국어 30 corpus ≥ 70% L2/L3 통과 — #221 로 분리. 본 이슈는 AC-7 만으로 close.

4. 컨텍스트 & 제약

의존성

  • #215 v0.2.0 완료 상태 (커밋 0d1db2d). 모든 도메인/UI/Riverpod 골격 + MockLlmService 100% 통과 전제.
  • flutter_gemma 0.16.5 (pub.dev 확정, 2026-06-12 기준 latest stable, 약 40시간 전 publish).
    • Public API: FlutterGemma.initialize(huggingFaceToken: String), FlutterGemma.installModel(modelType: ModelType.gemma4).fromNetwork(url).install(), FlutterGemma.getActiveModel(maxTokens: 2048), model.createChat(), chat.addQueryChunk(Message.text(text, isUser)), chat.generateChatResponseAsync()Stream<ModelResponse> (TextResponse | FunctionCallResponse | ThinkingResponse).
    • Function calling: Gemma 4 native function calling — 별도 Tool 객체 주입 없이 ModelType.gemma4 의 chat template 이 자동 라우팅. 모델이 호출 결정 시 스트림에 FunctionCallResponse(name, args) 1건 emit. (Gemma 4 / Gemma 3n / Phi-4 등 지원 명시)
    • Schema 전달 경로: prompt 본문에 JSON Schema 를 자연어로 명시 (Gemma 4 의 chat template 이 인식). 별도 tools: [...] 파라미터는 0.16.5 의 createChat 인터페이스 기준 옵션이지 필수 아님 — OQ-C 에서 확정.
  • Gemma 4 E2B 모델 — HuggingFace litert-community/gemma-4-E2B-it-litert-lm repo. OQ-A 확정 (2026-06-12): 정확 파일 = gemma-4-E2B-it.litertlm (2,588,147,712 bytes ≈ 2.41GB disk), SHA-256 = 181938105e0eefd105961417e8da75903eacda102c4fce9ce90f50b97139a63c. 모바일 1GB QAT 변종은 현시점 미공개 (Google 6월 blog 발표 자산 아직 HF 미게시). peak RAM 추정 ≈ 1.52GB (가중치 ≈ 1.3GB + KV cache + activation).
  • HF access token — joungmin 본인 계정의 read-only token. 빌드 시점 --dart-define=HF_TOKEN=hf_xxx 으로 주입, 런타임에 String.fromEnvironment('HF_TOKEN') 으로 읽어 FlutterGemma.initialize 에 전달. 토큰을 단말 영속 저장 금지.
  • crypto (기존), path_provider (기존), http (기존) — 모두 #215 에서 이미 사용 중.
  • Android: minSdkVersion 24 (MediaPipe LLM Inference 요구사항). 기존 #204 가 26 이므로 통과 가정.

제약

  • HF 토큰 비밀 유지: 토큰은 .env 만, git ignore, CI 에서 --dart-define 으로 주입. APK 내 평문 문자열로 들어가긴 하지만 read-only 권한 + 모델 다운로드 1회용이라 노출 영향 한정.
  • 모델 라이선스: Gemma Terms of Use (https://ai.google.dev/gemma/terms) 사용자 수락 필요. #215 의 동의 다이얼로그에 한 줄 추가 검토 (UI 변경 최소화 위해 Settings 도움말 링크로 처리).
  • 단말 RAM: E2B Q4_0 ≈ 1.5GB peak. RAM < 4GB 차단 (Android ActivityManager.getMemoryInfo()totalMem). 기존 AC-9 정책 재활용.
    • Developer round 2 구현 (2026-06-12): #215 의 device gate 가 사실은 미구현이라 (#218 QA 라운드 1 에서 적발), 본 이슈에서 신규 추가. 모듈 = data/ai/device_capabilities.dart (DeviceCapabilities abstract + PlatformDeviceCapabilities impl). 네이티브 호출 = life_helper/device_caps MethodChannel + MainActivity.kttotalMemoryBytes 메서드 (ActivityManager.MemoryInfo.totalMem). 게이트 UI = SettingsScreen 의 SwitchListTile.onChanged = null + subtitle 안내. Provider = deviceMeetsAiRamProvider (FutureProvider, fail-closed). 임계값 = kAiMinRamBytes = 4 GiB (inclusive).
  • flutter_gemma 0.16.5 의 generateChatResponseAsync 스트림은 token-level streamFunctionCallResponse 는 단일 이벤트 emit 후 stream done 가능, 또는 ThinkingResponse (Gemma 4 thinking mode) + TextResponse 동반 후 FunctionCallResponse. → 우리는 첫 FunctionCallResponse 만 채택, 나머지 폐기. thinking mode 는 본 v0.3 에서 비활성 (latency 영향).
  • timeout: generateStructured 호출자가 .timeout(Duration(seconds: 10)) 적용 (#215 시그니처 계약). flutter_gemma 자체는 timeout API 없음 → Dart Future.timeout 으로 감싸고 timeout 발생 시 session.close() 까지 호출.
  • 한국어 token 효율: Gemma 4 tokenizer (SentencePiece, vocab ≈ 256K) 가 한국어 BPE 효율 양호 (1 char ≈ 1.2 token, Gemma 3 대비 개선). prompt 가 너무 길어지면 latency 폭증 → few-shot 카탈로그를 5개로 제한 (#215 §8 그대로).

가정

  • joungmin 보유 Android 단말 1대 이상 (RAM ≥ 8GB, Android 13+) — AC-7 검증 필수.
  • HF account 1개 (joungmin) + Gemma 라이선스 수락 완료.
  • flutter_gemma 가 Android 측에서 자체적으로 OpenGL ES 백엔드 사용 (GPU). CPU fallback 은 0.16.5 가 자동 처리.
  • pub.dev 의 flutter_gemma 0.16.5 가 향후 6개월 내 breaking change 없음 (semver patch 만 갱신 허용).

5. 아키텍처 개요

변경 범위 (added/changed 만)

app/
├── lib/
│   ├── data/
│   │   └── ai/
│   │       ├── gemma_llm_service.dart    ★ 본문 교체 (stub → 실 구현)
│   │       └── model_lifecycle.dart      △ purge() F2 try/catch 추가
│   ├── state/
│   │   └── ai_providers.dart             △ _kModelUrl / _kModelSha 상수 치환
│   │                                       (이름에서 Placeholder 제거)
│   └── main.dart                         △ Mock → Gemma 조건부 override
├── android/app/
│   ├── build.gradle                      △ minSdkVersion 24 확인
│   └── src/main/AndroidManifest.xml      △ uses-feature OpenGL ES 3.0
│                                            + ProGuard rules (proguard-rules.pro)
├── pubspec.yaml                          △ flutter_gemma: ^0.16.5
└── test/
    └── data/ai/
        └── gemma_llm_service_test.dart   ★ 신규

설계서 #215 의 lib/domain/ai/, lib/ui/, frame_candidate.dart, suggest_frame.dart 등은 변경 0건. 단위 테스트도 기존 31개 전부 유지.

데이터 흐름 (변경된 노드만 빨간색 마킹)

[main.dart]
   │
   ▼
   ProviderScope.overrides = [
       appDatabaseProvider,
       llmServiceProvider.overrideWith((ref) {
         // ▼ 본 설계서 변경 지점
         final settings = ref.watch(aiSettingsProvider).value ?? false;
         final avail = ref.watch(modelAvailabilityProvider).value;
         final path = avail?.modelPath;
         if (settings && path != null) {
           return GemmaLlmService(modelPath: path);   // ★ 실 구현
         }
         return MockLlmService();                     // graceful fallback
       }),
   ]
   │
   ▼ (사용자가 #215 흐름 그대로 진입)
   │
   ▼
[suggestFrame] (#215, 변경 없음)
   │
   ▼
[LlmService.generateStructured(prompt, schema)]      (#215 abstract, 변경 없음)
   │
   ▼
[GemmaLlmService.generateStructured]                 ★ 본 설계서 §7 + fn-*.md
   │
   ├─► model = await FlutterGemma.getActiveModel(maxTokens: 2048)
   ├─► chat = await model.createChat()
   ├─► schemaPrompt = _appendSchemaInstruction(prompt, schema)
   │        // Gemma 4 native function calling 은 prompt 본문에
   │        // function name + JSON schema 안내가 들어가면 자동 라우팅
   ├─► await chat.addQueryChunk(Message.text(text: schemaPrompt, isUser: true))
   ├─► stream = chat.generateChatResponseAsync()
   ├─► await for (event in stream) {
   │       if (event is FunctionCallResponse && event.name == 'emit_frame_candidates') {
   │         result = event.args;
   │         break;     // 첫 FCR 만 채택
   │       }
   │     }
   ├─► await chat.close()                           // 세션 정리
   └─► return result;

I/O ↔ 순수 로직 경계

  • lib/data/ai/gemma_llm_service.dart = I/O 경계 (flutter_gemma native call + Dart Future timeout).
  • lib/domain/ai/ = 변경 0 (순수 유지).
  • _appendSchemaInstruction(prompt, schema) 어댑터는 gemma_llm_service.dart 의 file-private top-level 순수 함수. 단위 테스트 가능 (입력 prompt + schema → 기대 string 비교).
  • _collectFunctionCall(stream, name) 도 file-private. fake Stream<ModelResponse> 로 단위 테스트.

6. 데이터 모델

본 설계서는 신규 도메인 모델 0건. #215 의 FrameCandidate, SuggestFrameInput, ModelAvailability, DownloadProgress 전부 재사용.

_kModelUrl / _kModelSha256 상수 (치환)

// app/lib/state/ai_providers.dart
const _kModelUrl =
    'https://huggingface.co/litert-community/gemma-4-E2B-it-litert-lm/resolve/main/gemma-4-E2B-it.litertlm';
const _kModelSha256 =
    '181938105e0eefd105961417e8da75903eacda102c4fce9ce90f50b97139a63c';
const _kModelTotalBytes = 2588147712;  // 2.41 GiB — UI 표시 용
// 향후 QAT 모바일 1GB 변종이 HF 에 게시되면 swap. v1 은 위 base .litertlm.

Placeholder 접미사 제거. meta_kv['ai_model_path'], meta_kv['ai_model_sha256'] 키도 의미는 동일 (값만 실체).

Function calling 스키마 (변경 없음 — kFrameCandidatesSchema)

#215 의 JSON Schema 를 그대로 사용. flutter_gemma Tool.parameters 가 JSON Schema 호환이므로 1:1 매핑.

{
  "name": "emit_frame_candidates",
  "description": "Return 3 framed habit goal candidates at L2 or L3 level.",
  "parameters": { ... (§7 #215 그대로) ... }
}

HF 토큰 (런타임 만)

// lib/data/ai/gemma_llm_service.dart 의 top-level
const _hfToken = String.fromEnvironment('HF_TOKEN', defaultValue: '');

빈 문자열이면 FlutterGemma.initialize 호출 시 throw → graceful 경로로 MockLlmService 유지.

7. 함수 명세 (Function Specs)

본 설계서가 새로 손대는 함수만. 그 외는 #215 §7 표 그대로.

함수 책임(1줄) 시그니처 입력 출력 에러/실패 복잡?
GemmaLlmService.load flutter_gemma 모델 파일 → 메모리 로드 Future<void> load() (modelPath 필드) void FileSystemException / MissingHFToken / OOM → 그대로 throw 복잡fn-gemma_llm_service.md
GemmaLlmService.generateStructured schema → Tool, FunctionCallResponse 수집 Future<Map<String,dynamic>> generateStructured(String, Map) prompt, schema parsed JSON args StateError(미로드), FormatException(빈 응답), TimeoutException(외부) 복잡fn-gemma_llm_service.md
GemmaLlmService.unload session + model close, _loaded=false Future<void> unload() none void idempotent — 미로드 상태에도 safe 단순
_appendSchemaInstruction (file-private) prompt 본문에 function schema 안내 문자열 append String _appendSchemaInstruction(String prompt, Map<String,dynamic> schema) prompt, schema augmented prompt schema 의 name/parameters 누락 시 ArgumentError 단순 (string concat + JSON serialize)
_collectFunctionCall (file-private) Stream 에서 첫 FCR 추출 Future<Map<String,dynamic>> _collectFunctionCall(Stream<ModelResponse>, String) stream, expectedName args 다른 name FCR → throw FormatException; stream done 전 FCR 없음 → FormatException 단순 (state machine 1단)
ModelLifecycle.purge (수정) F2 hardening — File.delete try/catch (시그니처 동일) none int 파일 미존재/권한 → log warn + 카운트 0, throw 안 함 단순 (try/catch 1개 추가만)

8. 흐름 / 알고리즘

시나리오 A: 첫 실 다운로드 + 첫 추론

  1. 사용자가 v0.3.0 APK 설치 (HF 토큰 빌드 임베드 상태).
  2. AI 토글 ON → 동의 다이얼로그 → 다운로드 시작.
  3. ModelLifecycle.download()_kModelUrl (실 HF endpoint) 으로 HTTP GET (HF 가 LFS redirect 처리, http 패키지 follow redirect).
  4. 다운로드 완료 → SHA-256 검증 (_kModelSha256 와 비교).
  5. meta_kv['ai_model_path'] 저장 → modelAvailabilityProviderready 로 전환.
  6. main.dart 의 override 가 GemmaLlmService(modelPath: ...) 인스턴스 반환 시작.
  7. 사용자가 HabitCreate → "AI 제안" 탭 → frameSuggestionsProvider 구독.
  8. suggestFramellm.isLoaded == falsellm.load() 호출.
  9. GemmaLlmService.load:
    • FlutterGemma.initialize(huggingFaceToken: _hfToken) 1회 (top-level _initialized guard).
    • await FlutterGemma.installModel(modelType: ModelType.gemma4).fromFile(modelPath).install()ModelLifecycle 가 이미 파일을 받아둔 상태이므로 fromNetwork 대신 fromFile (또는 fromAsset) 경로.
    • _model = await FlutterGemma.getActiveModel(maxTokens: 2048).
    • _loaded = true.
    • cold start ≈ 13 초.
  10. suggestFramebuildFewShotPrompt (#215 그대로) → llm.generateStructured(prompt, kFrameCandidatesSchema).
  11. GemmaLlmService.generateStructured:
    • _loaded 검사. false → StateError.
    • augmented = _appendSchemaInstruction(prompt, schema) — schema 의 name/parameters 를 prompt 끝에 JSON 형태로 append. Gemma 4 native chat template 이 FCR 로 자동 변환.
    • chat = await _model.createChat(). (sampling 파라미터 temperature/topK/topP 는 0.16.5 의 model-level 또는 chat-level 설정 — OQ-C 에서 확정)
    • await chat.addQueryChunk(Message.text(text: augmented, isUser: true)).
    • stream = chat.generateChatResponseAsync().
    • result = await _collectFunctionCall(stream, 'emit_frame_candidates'):
      • await for (event in stream) { ... } 로 첫 FunctionCallResponse 만 채택.
      • TextResponse / ThinkingResponse 는 skip.
      • 다른 name → FormatException('unexpected function: ${event.name}').
      • stream done 까지 FCR 없음 → FormatException('no function call emitted').
    • await chat.close() (finally 블록).
    • return result.
  12. suggestFrameparseFrameCandidates(result) (#215 그대로) → L0/L1 폐기 후 후보 3개 반환.
  13. UI 표시.

시나리오 B: opt-out (F2 hardening 검증)

  1. 사용자가 AI 토글 OFF.
  2. AiSettingsController.setOptIn(false)
    • ref.read(modelDownloadControllerProvider.notifier).cancel().
    • await ref.read(modelLifecycleProvider).purge():
      • 기존 코드: await File(path).delete() (no try/catch — F2).
      • 본 설계: try/catch 로 감쌈. 실패 시 (권한, 외부 삭제) log + 0 반환.
  3. meta_kv clear, opt_in='false'.
  4. 토스트.

시나리오 C: HF 토큰 누락 (debug 빌드)

  1. 개발자가 --dart-define=HF_TOKEN 없이 빌드.
  2. _hfToken == ''.
  3. 사용자 토글 ON 시도 → 다운로드 시작 (HF endpoint 가 토큰 없으면 401).
  4. ModelLifecycle.download 가 HTTP 401 emit → 기존 friendly_error 로 "다운로드 실패, 다시 시도" 표시.
  5. graceful 유지.

시나리오 D: 모델 응답이 함수 호출 없이 plain text

  1. chat.generateChatResponseAsync()TextResponse 만 stream.
  2. _collectFunctionCall 이 stream done 까지 FCR 없으면 FormatException.
  3. suggestFrame (#215) 의 catch 가 빈 리스트 반환 → 다이얼로그 "다시 시도".
  4. dev log 에 "FCR not emitted, model returned plain text" 기록 (prompt 본문은 X).

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

상황 처리 비고
HF endpoint 가 LFS 미디어 URL 로 302 redirect 기존 http 패키지 follow redirect 옵션 ON 으로 처리 flutter_gemma 가 자체 download 메서드 갖고 있어도 우리는 ModelLifecycle.download 유지 (resume + SHA 통일)
FlutterGemma.initialize 가 두 번째 호출에 throw top-level bool _hfInitialized = false 가드 0.16.5 idempotent 여부 미확정 시 보호
_appendSchemaInstruction 호출에서 schema 가 #215 형식과 다름 ArgumentError 본 설계에선 발생 불가 (kFrameCandidatesSchema 고정)
_collectFunctionCall 도중 stream error event try/catch 으로 FormatException 변환 error.toString() 폐기 (prompt 누설 방지)
chat.close() 가 throw unawaited + log warn, 호출자에 전파 X 다음 호출에 영향 없음 보장
unload() 호출 시 _model == null early return idempotent
Android RAM 4GB 미만 단말 기존 #215 §9 device gate 동작 (모델 다운로드 자체 차단) flutter_gemma load 이전 단계에서 거름
flutter_gemma OOM (Q4_0 모델인데도) native exception → Dart 측 ExceptionsuggestFrame catch → 빈 리스트 사용자에겐 #215 의 "응답 없음" 메시지
Stream done event 가 옴 그러나 FCR 또한 옴 break 으로 빠진 후 close — 정상 첫 FCR 가 진실, 이후 이벤트는 폐기
ProGuard 가 MediaPipe 클래스 strip release 빌드 시 crash proguard-rules.pro-keep class com.google.mediapipe.** { *; } 추가

안전한 기본값

  • _hfToken 누락 → mock 경로 유지 (override 안 함).
  • _kModelUrl / _kModelSha256 가 빈 문자열 또는 <HEX_64_FROM_HF_LFS_POINTER> 같은 sentinel 이면 다운로드 시작 안 함 → graceful.
  • 모든 native exception 은 suggestFrame 에서 catch → 빈 리스트 (#215 계약 유지).

10. 테스트 계획

단위 테스트 (신규/수정)

AC 테스트 위치 모킹
AC-1 flutter analyze + APK debug build CI scripts/ci
AC-3 model_lifecycle_test.dart Range header 테스트 — 기존 + 실 URL host header 검증 test/data/ai mock HTTP
AC-4 model_lifecycle_test.dart_kModelSha256 가 sentinel 일 때 skip 분기 test/data/ai tmp file
AC-7 (단위 부분) gemma_llm_service_test.dart_appendSchemaInstruction 변환 + _collectFunctionCall 의 4 케이스 (FCR 만 / Text+FCR / Thinking+FCR / Text 만) test/data/ai mock Stream<ModelResponse> (flutter_gemma 의 response 타입 fake)
AC-7 (E2E) 수동: APK 실 단말 설치 → 토글 ON → 다운로드 → "술 끊고 싶어" → 후보 ≥ 1 + 모두 L2/L3 QA 수동 실 Gemma
AC-8 (F2) model_lifecycle_test.dartpurge()File.delete throw 해도 정상 return test/data/ai mock FileSystem (MemoryFileSystem 가능 시) 또는 read-only tmp
AC-9 gemma_llm_service_test.dart_loaded=false 인 채 generateStructured 호출 시 StateError test/data/ai direct

Mock 전략

  • flutter_gemma 직접 mock 불가 (final class 가능성) → LlmService 추상화는 그대로 두고, GemmaLlmService 내부의 _schemaToTool / _collectFunctionCall 만 단위 테스트.
  • _collectFunctionCall(stream, name)Stream<ModelResponse> 만 받으므로 Stream.fromIterable([...]) 로 fake event 주입 가능.
  • 단위 테스트가 flutter_gemma SDK 의 ModelResponse 타입을 import 해야 함 → flutter_gemma: ^0.16.5 의존성을 dev_dependencies 가 아닌 dependencies 로.

E2E (수동, QA 단계)

  • 단말: joungmin Android 8GB+ 1대.
  • 시나리오: 시나리오 A 전체 (다운로드 → cold inference → 후보 1개 탭 → habit 저장).
  • 측정: cold latency, warm latency, 메모리 peak (Android Studio profiler 1회).

11. 리스크 & 대안 검토

본 설계서 내 결정

결정 채택 대안 근거
Gemma 4 E2B (3n 또는 3 27B 아님) Gemma 3n E2B / Gemma 3 27B Gemma 4 = 2026-04-02 출시, E2B 가 모바일 전용 SKU, function calling 네이티브 지원, QAT 모바일 양자화 (≈1GB) 까지 출시 (2026-06) — ADR-0003 결정 #2 유지
flutter_gemma 0.16.5 pin 0.16.x range / latest 2026-06-12 기준 latest stable (40h 전 publish), Gemma 4 + FCR + .task/.litertlm 모두 지원
function calling (FCR) 자유 텍스트 + 정규식 fallback (ADR-0004 후보) flutter_gemma 0.16.5 가 Gemma 4 native FCR 공식 지원 — Planner 핵심 리스크 해소, ADR-0004 불필요
HF 토큰 빌드 임베드 사용자 본인 토큰 입력 UI v1 은 joungmin 1인 — UX 0 비용, 보안 영향 한정 (read-only)
첫 FCR 만 채택 (Thinking 등 skip) 모든 이벤트 누적 후 last FCR 명확한 종료 시점 + close() 호출 가능. Gemma 4 thinking mode 는 latency 영향 커서 v1 비활성
_collectFunctionCall 파일-private top-level / 별도 파일 캡슐화 (테스트는 @visibleForTesting)
Mock fallback 유지 옵션 강제 Gemma graceful — F2/F1 작업 없이도 release 가능

핵심 리스크

  • HF 모델 URL 변경: Google 이 HF repo path 변경 시 _kModelUrl 깨짐. → meta_kv 에 마지막 성공 URL 캐싱, 재시도 시 두 후보 (configured + cached) 비교 검토는 v2.
  • flutter_gemma breaking change: 0.17.x 가 FCR API 깨면 우리만 묶임. → pubspec.lock 동결 + 분기마다 release note 모니터링.
  • HF account quota: joungmin token 의 다운로드 제한. → 단일 사용자라 영향 0. 다인 배포 시 v2 토큰 UI.

되돌리기 어려운 결정 → ADR 후보

  • 본 설계서는 신규 ADR 발행 안 함. ADR-0003 결정 #3 (function calling) 이 그대로 유지됨이 research 로 확정.
  • E4B 지원 추가 = ADR-0004 후보 (Phase 2-C, 별 이슈).

12. 미해결 질문 (Open Questions)

OQ 질문 상태 결정
OQ-A Gemma 4 E2B 모바일 HF 파일명 + SHA-256? 해결 (Developer 2026-06-12) litert-community/gemma-4-E2B-it-litert-lm repo 의 gemma-4-E2B-it.litertlm (2,588,147,712 B). SHA256 = 181938105e0eefd105961417e8da75903eacda102c4fce9ce90f50b97139a63c. QAT 1GB 변종은 미공개.
OQ-B huggingFaceToken 빈 문자열 시 throw? 해결 즉시 throw 안 함 — 다운로드 시점까지 deferred. 빈 토큰은 public model 만 허용. 우리는 null 이 더 정확하지만 빈 문자열도 안전.
OQ-C sampling 파라미터 위치? 해결 chat-levelmodel.createChat(temperature: 0.4, topK: 40, topP: 0.95). getActiveModelmaxTokens / preferredBackend / maxConcurrentSessions 만. (caveat: NPU backend 는 sampling 무시.)
OQ-D Android ProGuard rules? 해결 flutter_gemma example app 의 27-line 사본 적용: io.flutter.*, play.core.*, mediapipe.*, protobuf.*, kotlinx.coroutines.*.
OQ-E Gemma ToU 동의 UI? 해결 Google 표준 템플릿 없음. Settings AI 섹션 하단에 "Gemma 이용약관(https://ai.google.dev/gemma/terms)에 동의합니다" 한 줄. 다이얼로그 본문 변경 없음 (변경 최소화).
OQ-F thinking mode off 스위치? 해결 model.createChat(isThinking: false)default 가 false 라 명시 안 해도 무방하나 명시적으로 박는다.

부록: 자가 점검 (Architect 가 작업 종료 시 검증)

  • §1~§12 모든 섹션 채워짐.
  • #215 와의 통합점 명확: LlmService 추상, kFrameCandidatesSchema, meta_kv 키, UI/도메인 0 변경.
  • 신규 ADR 발행 안 함 — flutter_gemma 0.16.5 의 FCR 지원이 ADR-0003 결정 #3 을 유지.
  • 5개 의사결정 (Planner 인계) 모두 반영: ① Gemma 4 E2B 단일 ② Google 호스팅 (HF litert-community/gemma-4-E2B-it-litert-lm) ③ flutter_gemma 0.16.5 + Gemma 4 native FCR ④ RAM 4GB 게이트 ⑤ graceful 정책 유지.
  • 모델 명칭 정정: Planner 가 "Gemma 3n" 으로 명시했으나 검증 결과 Gemma 4 가 맞음 (2026-04-02 출시, E2B/E4B 모바일 SKU + native FCR). Planner 의 "3n 정정" 자체가 잘못된 정정이었음. 본 설계서는 Gemma 4 로 정정 반영.
  • AC 10개 모두 §3 + §10 1:1 매핑.
  • 복잡 함수 → 1개 fn-*.md (fn-gemma_llm_service.md).
  • §12 OQ 5개 모두 Developer 단계 또는 QA 단계로 routing.
  • graceful degradation 명시: _hfToken 누락, sentinel SHA, native exception 모두 mock 경로로 폴백.
  • 프라이버시: prompt 본문은 log 에 X, HF 토큰 단말 영속 X.
  • out-of-scope 명확: #219 F1, #220 F2 광범위, #221 AC10, #222 keystore.