[Developer] #218 Real Gemma 4 E2B integration via flutter_gemma 0.16.5

Implements the OQ-1 follow-up to #215 v0.2.0: replace the placeholder
GemmaLlmService stub with a real flutter_gemma 0.16.5 backend driving
Gemma 4 E2B (litert-community/gemma-4-E2B-it-litert-lm, 2.41GB).

Highlights:
- GemmaLlmService.load → FlutterGemma.initialize + installModel.fromFile +
  getActiveModel; idempotent + FileSystemException on missing file.
- generateStructured uses Gemma 4 native function calling via
  createChat(tools: [Tool(...)], toolChoice: required). Stream parsed by
  collectFunctionCall — first FCR wins, ParallelFCR first-call wins,
  TextResponse/ThinkingResponse skipped, errors sanitized to prevent
  prompt leakage.
- main.dart wires _LazyLlmService adapter that resolves to GemmaLlmService
  when ModelLifecycle reports ready, MockLlmService otherwise.
- ai_providers.dart pins real model URL + SHA-256 (181938...39a63c).
- F2 hardening: ModelLifecycle.purge wraps each delete + meta remove in
  try/catch so a single OS-level flake cannot block opt-out.
- Android: INTERNET / FOREGROUND_SERVICE / POST_NOTIFICATIONS permissions
  + R8 proguard-rules.pro keeping MediaPipe / LiteRT / TFLite / protobuf
  JNI entry points (release builds otherwise crash on first inference).

Design-First: fn-gemma_llm_service.md updated to v2 — §C
(_appendSchemaInstruction) deprecated after reading flutter_gemma
0.16.5 source (Gemma 4 SDK injects tool declarations via template;
prompt-side append would double-wrap).

Tests:
- 10 new unit tests for collectFunctionCall covering all 8 fn-spec
  cases + 2 ParallelFunctionCallResponse paths.
- All 81 existing tests still pass.
- flutter analyze: 0 issues.

Refs #218

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 15:18:08 +09:00
parent a1f3c5f85d
commit 9a9eb2abd5
14 changed files with 646 additions and 175 deletions

View File

@@ -78,7 +78,7 @@ v0.2.0 (#215) 은 mock 환경에서 100% 동작하지만, 사용자가 "AI 도
- 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 QAT 모바일 모델** — HuggingFace `litert-community/gemma-4-E2B-it-litert-lm` repo`.task` 또는 `.litertlm` 자산. 4-bit QAT 로 ≈1.5GB peak RAM, 신규 모바일 양자화 포맷은 ≈1GB footprint (2026-06 update). **OQ-A** 확정 시점에 정확한 파일명 (예: `gemma-4-E2B-it-mobile.task`) + SHA-256 핀.
- **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 이므로 통과 가정.
@@ -190,14 +190,11 @@ app/
```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-mobile.task';
// ▲ 정확 파일명·SHA 는 OQ-A 에서 확정. 일시값으로 둔 후 Developer 가
// HF page 의 LFS pointer (sha256: ... 라인) 또는
// `huggingface-cli download --dry-run` 로 SHA 추출해 박는다.
// QAT 1GB 모바일 양자화 변종 출시 시 (2026-06+) 그쪽으로 swap.
'https://huggingface.co/litert-community/gemma-4-E2B-it-litert-lm/resolve/main/gemma-4-E2B-it.litertlm';
const _kModelSha256 =
'<HEX_64_FROM_HF_LFS_POINTER>';
const _kModelType = 'gemma4'; // flutter_gemma ModelType enum 매핑
'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']` 키도 의미는 동일 (값만 실체).
@@ -371,14 +368,14 @@ const _hfToken = String.fromEnvironment('HF_TOKEN', defaultValue: '');
## 12. 미해결 질문 (Open Questions)
| OQ | 질문 | 결정 시점 | 비고 |
|----|------|----------|------|
| **OQ-A** | Gemma 4 E2B QAT 모바일 HF repo 정확 경로 + 파일명 + SHA-256? `gemma-4-E2B-it-mobile.task` vs `model.litertlm` vs 2026-06 신규 QAT 1GB 변종? | Developer 단계 첫 step | `litert-community/gemma-4-E2B-it-litert-lm` 또는 `google/gemma-4-E2B-it-qat-mobile-transformers` 둘 중. HF page LFS pointer 의 sha256 라인 사본 |
| **OQ-B** | `huggingFaceToken` 빈 문자열 시 `FlutterGemma.initialize` throw 타입? | Developer 단계 | `try/catch (e)` 처리 |
| **OQ-C** | sampling 파라미터 (temperature 0.4, topK 40, topP 0.95) 를 model-level (`getActiveModel(...)`) 에서 받나, chat-level (`createChat(...)`) 에서 받나? 0.16.5 API 정확 위치 | Developer 단계 | pub.dev API doc 또는 plugin source |
| **OQ-D** | Android `proguard-rules.pro` `-keep` 룰 패턴? | QA 단계 release build 시 | `com.google.mediapipe.**` + flutter_gemma plugin native 클래스 |
| **OQ-E** | Gemma 4 ToU 동의 UI: #215 동의 다이얼로그에 추가? Settings 도움말 링크? | Developer 단계 | 변경 최소화 → Settings 도움말 한 줄 + 외부 링크 (https://ai.google.dev/gemma/terms) |
| **OQ-F** | Gemma 4 thinking mode 토글: v0.3 에서 비활성 유지하지만 0.16.5 API 의 정확한 off 스위치? | Developer 단계 | `createChat(enableThinking: false)` 형태로 추정, 확인 필요 |
| 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** 라 명시 안 해도 무방하나 명시적으로 박는다. |
---