[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** 라 명시 안 해도 무방하나 명시적으로 박는다. |
---

View File

@@ -1,8 +1,17 @@
# 함수 설계서: `GemmaLlmService.load` + `generateStructured` + 보조 (#218)
> **부모 설계서**: ./README.md · **상태**: Draft
> **부모 설계서**: ./README.md · **상태**: Draft (v2)
> **작성**: [AI] Architect (2026-06-12) · **구현 대상**: `app/lib/data/ai/gemma_llm_service.dart` · **테스트**: `app/test/data/ai/gemma_llm_service_test.dart`
## 변경 이력 (v2, 2026-06-12 Developer 검증 후)
flutter_gemma 0.16.5 의 `InferenceChat` 구현을 직접 읽어 확인한 결과:
- Gemma 4 (ModelType.gemma4) 의 function calling 은 **SDK 가 `createChat(tools: [Tool(...)])` 의 tools 목록에서 `<|tool>declaration:...<tool|>` 토큰을 직접 렌더**한다 (`lib/core/chat.dart:94`).
- 따라서 §C `_appendSchemaInstruction` 는 Gemma 4 에선 **double-wrap** 을 유발한다. v2 에선 **§C 제거**, §B 는 `Tool` 객체를 `createChat` 에 전달하는 방식으로 변경.
- §D `_collectFunctionCall` 는 변경 없음 — 여전히 `Stream<ModelResponse>` 에서 첫 `FunctionCallResponse` 만 추출.
남은 4 함수 (§A load / §B generateStructured / §C deprecated / §D collectFunctionCall) 중 코드 대상은 3 개.
이 문서는 `GemmaLlmService` 가 노출하는 2 개 public 메서드 + 2 개 file-private 헬퍼를 한 묶음으로 설계한다. 모두 flutter_gemma 0.16.5 의 native 경계를 다루므로 한 문서에서 다루는 게 응집도 측면에서 옳다.
| # | 함수 | 가시성 |
@@ -67,7 +76,7 @@ Future<void> load();
### 8. 복잡도 / 성능
- **시간**: cold start 13 초 (모델 파일 read + native init + tokenizer load). 첫 호출 만, 이후 캐시.
- **공간**: peak RAM ≈ 1.5GB (Gemma 4 E2B Q4_0). QAT 1GB 변종 채택 시 ≈ 1GB.
- **공간**: peak RAM ≈ 1.52GB (Gemma 4 E2B Q4 .litertlm, 가중치 ~1.3GB + KV cache + activation). disk ≈ 2.41GB.
- **호출 빈도**: 사용자 1 세션 당 01 회 (#219 F1 의 60s idle unload 가 들어오면 다회 가능).
### 9. 의존성
@@ -116,20 +125,27 @@ loaded 상태의 Gemma 4 모델에 prompt + JSON Schema 를 전달하여 단일
- 모델 latent state 변경 (다음 호출은 fresh chat).
- log: prompt length, latency, FCR 수신 여부 (prompt 본문 X — 프라이버시).
### 5. 동작 / 알고리즘
### 5. 동작 / 알고리즘 (v2)
```
1. if (!_loaded) throw StateError('LlmService not loaded');
2. final augmented = _appendSchemaInstruction(prompt, schema);
3. final chat = await _model!.createChat();
4. try {
5. await chat.addQueryChunk(Message.text(text: augmented, isUser: true));
6. final stream = chat.generateChatResponseAsync();
7. final fnName = schema['name'] as String;
8. final args = await _collectFunctionCall(stream, fnName);
9. return args;
10. } finally {
11. await chat.close(); // 항상 정리
12. }
2. final fnName = schema['name'] as String;
3. final fnDesc = (schema['description'] as String?) ?? '';
4. final fnParams = schema['parameters'] as Map<String, dynamic>;
5. final tool = Tool(name: fnName, description: fnDesc, parameters: fnParams);
6. final chat = await _model!.createChat(
modelType: ModelType.gemma4,
supportsFunctionCalls: true,
toolChoice: ToolChoice.required, // 강제 FCR
tools: [tool],
);
7. try {
8. await chat.addQueryChunk(Message.text(text: prompt, isUser: true));
9. final stream = chat.generateChatResponseAsync();
10. final args = await _collectFunctionCall(stream, fnName);
11. return args;
12. } finally {
13. await chat.close(); // 항상 정리
14. }
```
caller (#215 `suggestFrame`) 가 `.timeout(Duration(seconds: 10))` 적용 → timeout 시 본 함수의 `await` 가 throw 됨 → finally 의 `chat.close()` 가 실행되어 native session leak 방지.
@@ -173,7 +189,13 @@ caller (#215 `suggestFrame`) 가 `.timeout(Duration(seconds: 10))` 적용 → ti
---
## §C. `_appendSchemaInstruction(prompt, schema)`
## §C. (DEPRECATED — v2) `_appendSchemaInstruction(prompt, schema)`
> **v2 결정**: Gemma 4 SDK 가 `Tool` 객체에서 직접 declaration 토큰을 렌더하므로, prompt 측에서 schema 안내문을 덧붙이면 double-wrap 이 된다. **본 함수는 구현하지 않는다.**
>
> 아래 §C 본문은 v1 (gemmaIt fallback) 시나리오용 참고 자료로 보존하나, v2 코드 대상에서 제외한다. 단위 테스트도 작성하지 않는다.
원본 본문 (참고용):
### 1. 시그니처
```dart

View File

@@ -20,7 +20,7 @@ life-helper 는 사용자가 입력한 자유 문장(예: "술 끊고 싶어")
1. 하단 탭에서 **설정** 진입.
2. "AI 도움 켜기" 토글 탭.
3. 동의 다이얼로그가 뜹니다:
- **파일 크기 ≈ 1.5GB** (Gemma 4 E2B Q4_0 모델 — 단말에 한 번만 다운로드)
- **파일 크기 ≈ 2.4GB** (Gemma 4 E2B Q4_0 모델 — 단말에 한 번만 다운로드)
- **WiFi 연결 권장** (셀룰러 대역폭 절약)
- 모든 처리는 단말 — 입력 텍스트 외부 송출 없음
4. **"동의하고 다운로드"** 탭 → 백그라운드 다운로드 시작.
@@ -68,9 +68,9 @@ life-helper 는 사용자가 입력한 자유 문장(예: "술 끊고 싶어")
1. **설정** → "AI 도움 켜기" 토글 OFF.
2. 확인 다이얼로그:
- 모델 파일이 단말에서 **즉시 삭제** 됩니다.
-1.5GB 의 저장공간이 확보됩니다.
-2.4GB 의 저장공간이 확보됩니다.
- 다시 켜면 다시 다운로드해야 합니다.
3. **"끄고 삭제"** 탭 → "공간 확보됨 1500 MB" 토스트.
3. **"끄고 삭제"** 탭 → "공간 확보됨 2469 MB" 토스트.
진행 중인 다운로드가 있어도 깔끔히 중단되고, `.tmp` 임시 파일까지 함께 삭제됩니다.
@@ -86,7 +86,7 @@ A. 직접 입력란을 고쳐 쓰면 됩니다. AI 제안은 채우기 도우미
A. 언제든 끌 수 있고, 끄면 즉시 삭제됩니다. 다시 켜면 다시 받아야 한다는 점만 유의하세요.
**Q. v0.2.0 에서 다운로드가 항상 실패합니다.**
A. v0.2.0 은 모델 URL 이 미확정 (OQ-1) 인 상태로 출시되어, 실제 다운로드는 의도된 graceful 실패 경로로 안내됩니다. 실 모델 통합은 후속 버전 (v0.3.x) 에서 제공됩니다. 그동안 수동 입력 경로는 정상 동작합니다.
A. v0.2.0 은 모델 URL 이 미확정 (OQ-1) 인 상태로 출시되어, 실제 다운로드는 의도된 graceful 실패 경로로 안내됩니다. **v0.3.0 부터 실 Gemma 4 E2B 모델 (HuggingFace) 다운로드가 활성화되었습니다.** 그동안 수동 입력 경로는 정상 동작합니다.
## 관련 문서