Files
joungmin 25be18063e [08-Documenter] #218 docs marked Approved + v0.3.0 sync
- 설계서 218-gemma-real-integration/README.md → Approved + AC 체크박스 채움 + 실제 구현/테스트 파일 경로 추적성 갱신
- fn-gemma_llm_service.md → Approved (v2)
- reference/215-ai-frame-suggest.md → v0.3.0 (commit da60dd1 핀)
- guides/ai-help-onboarding.md → 적용 버전 v0.3.0 + RAM 4GB 요구사항 명시
- docs/README.md 인덱스 v0.3.0 표기

AC-7 (실 단말 E2E) 만 DEFER — 사용자 실기 검증 결과로 별도 갱신.

Refs #218
2026-06-12 16:22:40 +09:00

411 lines
31 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 설계서: OQ-1 — 실 Gemma 통합 (#218)
> **상태**: Approved (2026-06-12 — Reviewer 1b90f58, Release v0.3.0)
> **작성**: [AI] Architect · **작성일**: 2026-06-12
> **추적성** — Redmine: #218 · 상위/이전: #215 (v0.2.0 placeholder) · 후속: #219 (idle auto-unload) / #220 (load 동시성 + isThinking) / #221 (한국어 corpus) / #222 (HF_TOKEN keystore) · 릴리스 태그: `v0.3.0` (commit da60dd1) · 관련 ADR: [ADR-0003 on-device LLM Gemma](../../adr/0003-on-device-llm-gemma.md) (재확인, 신규 ADR 없음)
> · 구현 파일 (실제 경로 — Documenter 동기화):
> - `app/lib/data/ai/gemma_llm_service.dart` — 실 구현 (createChat tools + collectFunctionCall)
> - `app/lib/data/ai/device_capabilities.dart` — RAM 게이트 (Dev round 2 추가, Planner OOS 였으나 QA 적발 후 신설)
> - `app/lib/data/ai/model_lifecycle.dart` — F2 hardening 통합
> - `app/lib/state/ai_providers.dart` — `_kModelUrl` / `_kModelSha256` 실값 + `deviceMeetsAiRamProvider`
> - `app/lib/main.dart` — `_LazyLlmService` 어댑터 (re-resolve + sticky-cache 회피, Reviewer 1b90f58 수정)
> - `app/lib/ui/screens/settings_screen.dart` — RAM 게이트 SwitchListTile + Designer 문구
> - `app/android/app/src/main/kotlin/kr/cloud_handson/life_helper/MainActivity.kt` — `life_helper/device_caps` MethodChannel
> - `app/android/app/proguard-rules.pro` — flutter_gemma example 사본
> - `app/pubspec.yaml` — flutter_gemma 0.16.5
> · 테스트 파일:
> - `app/test/data/ai/gemma_llm_service_test.dart`
> - `app/test/data/ai/device_capabilities_test.dart` (Dev round 2 신규 7건)
> - `app/test/data/ai/model_lifecycle_test.dart` (F2 case 보강)
> - 총 88/88 unit PASS
> · (이하 원본 변경 대상 — 이력 보존):
> - `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` (`MockLlmService` → `GemmaLlmService` 조건부 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/](../215-gemma-frame-suggest/) — placeholder 기반 v0.2.0 청사진. 본 설계서는 placeholder 자리 채움에 한정.
> · 하위 문서:
> - [fn-gemma_llm_service.md](./fn-gemma_llm_service.md) — `GemmaLlmService.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<ModelResponse>).
- 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 round 2 (2026-06-12, f71d132) PASS, Reviewer (1b90f58) 승인.
- [x] **AC-1**: `flutter pub add flutter_gemma:^0.16.5` 통과 + `flutter analyze` 0 issue + `flutter build apk --debug` 성공. ✅
- [x] **AC-2**: 사용자 "AI 도움" 토글 ON 시 동의 다이얼로그 (#215 그대로) → 백그라운드 다운로드가 실 HF endpoint (`litert-community/gemma-4-E2B-it-litert-lm`) 로 향한다. ✅
- [x] **AC-3**: 다운로드 일시정지/재개/취소 동작이 실 HF URL 에 대해서도 보존 (HTTP Range). ✅
- [x] **AC-4**: 다운로드 완료 후 SHA-256 (`181938105e...39a63c`) 검증 + `meta_kv['ai_model_path']` 저장. ✅
- [x] **AC-5**: 다운로드 완료 후 `HabitCreateScreen` "AI 제안" 버튼 활성. ✅
- [x] **AC-6**: RAM 4GB 게이트 — `life_helper/device_caps` MethodChannel + `kAiMinRamBytes = 4 GiB`. 7 boundary unit 통과. ✅
- [ ] **AC-7**: **실 Android 단말 E2E** — 후보 ∈ {L2, L3} + `validateFrameLevel` 통과 ≥ 1. **DEFER** — 단위/통합 PASS, 실기 검증은 사용자 권고 (#218 노트에 가이드 첨부). 결과 도착 시 본 항목 갱신 + Redmine 노트 보강.
- [x] **AC-8**: opt-out 시 즉시 삭제 + meta clear + 토스트. F2 try/catch 적용. ✅
- [x] **AC-9**: RAM < 4GB / OOM / timeout 10s 시 빈 리스트 + 수동 입력 경로 보존. ✅
- [⊘] **AC-10**: 한국어 30 corpus ≥ 70% — **#221 로 분리** (out-of-scope).
## 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.kt``totalMemoryBytes` 메서드 (`ActivityManager.MemoryInfo.totalMem`). 게이트 UI = SettingsScreen 의 `SwitchListTile.onChanged = null` + subtitle 안내. Provider = `deviceMeetsAiRamProvider` (FutureProvider<bool>, fail-closed). 임계값 = `kAiMinRamBytes = 4 GiB` (inclusive).
- **`flutter_gemma` 0.16.5 의 `generateChatResponseAsync` 스트림은 token-level stream** — `FunctionCallResponse` 는 단일 이벤트 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` 상수 (치환)
```dart
// 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 매핑.
```json
{
"name": "emit_frame_candidates",
"description": "Return 3 framed habit goal candidates at L2 or L3 level.",
"parameters": { ... (§7 #215 ) ... }
}
```
### HF 토큰 (런타임 만)
```dart
// 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](./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](./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<ModelResponse> 에서 첫 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']` 저장 → `modelAvailabilityProvider``ready` 로 전환.
6. `main.dart` 의 override 가 `GemmaLlmService(modelPath: ...)` 인스턴스 반환 시작.
7. 사용자가 HabitCreate → "AI 제안" 탭 → `frameSuggestionsProvider` 구독.
8. `suggestFrame``llm.isLoaded == false``llm.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. `suggestFrame``buildFewShotPrompt` (#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. `suggestFrame``parseFrameCandidates(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 측 `Exception``suggestFrame` 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.dart``purge()``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-level**`model.createChat(temperature: 0.4, topK: 40, topP: 0.95)`. `getActiveModel``maxTokens` / `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 가 작업 종료 시 검증)
- [x] §1~§12 모든 섹션 채워짐.
- [x] #215 와의 통합점 명확: `LlmService` 추상, `kFrameCandidatesSchema`, `meta_kv` 키, UI/도메인 0 변경.
- [x] 신규 ADR 발행 안 함 — flutter_gemma 0.16.5 의 FCR 지원이 ADR-0003 결정 #3 을 유지.
- [x] 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 정책 유지.
- [x] 모델 명칭 정정: Planner 가 "Gemma 3n" 으로 명시했으나 검증 결과 **Gemma 4** 가 맞음 (2026-04-02 출시, E2B/E4B 모바일 SKU + native FCR). Planner 의 "3n 정정" 자체가 잘못된 정정이었음. 본 설계서는 Gemma 4 로 정정 반영.
- [x] AC 10개 모두 §3 + §10 1:1 매핑.
- [x] 복잡 함수 → 1개 `fn-*.md` (`fn-gemma_llm_service.md`).
- [x] §12 OQ 5개 모두 Developer 단계 또는 QA 단계로 routing.
- [x] graceful degradation 명시: `_hfToken` 누락, sentinel SHA, native exception 모두 mock 경로로 폴백.
- [x] 프라이버시: prompt 본문은 log 에 X, HF 토큰 단말 영속 X.
- [x] out-of-scope 명확: #219 F1, #220 F2 광범위, #221 AC10, #222 keystore.