[Architect] #218 Real Gemma 4 + flutter_gemma 0.16.5 design spec

- docs/design/218-gemma-real-integration/README.md (Draft) — 12 섹션 + AC 10
- docs/design/218-gemma-real-integration/fn-gemma_llm_service.md (Draft) — load/generateStructured/_appendSchemaInstruction/_collectFunctionCall 4 함수 명세
- 모델: Gemma 4 E2B QAT 모바일 (HF litert-community)
- flutter_gemma 0.16.5 + ModelType.gemma4 native function calling 확인
- 신규 ADR 발행 안 함 (ADR-0003 결정 #3 유지)
- 변경 범위: gemma_llm_service.dart 본문 교체, _kModelUrl/Sha 상수 치환, main.dart 조건부 override, AndroidManifest + ProGuard
- out of scope: #219 F1 / #220 F2 광범위 / #221 AC10 / #222 keystore

Refs #218
This commit is contained in:
2026-06-12 14:54:28 +09:00
parent ed340839a0
commit a1f3c5f85d
3 changed files with 754 additions and 0 deletions

View File

@@ -68,6 +68,7 @@ docs/
- [204-flutter-bootstrap](./design/204-flutter-bootstrap/) — Phase 1 MVP Drift 21 테이블 + 도메인 함수 + UI 4 화면
- [215-gemma-frame-suggest](./design/215-gemma-frame-suggest/) — Phase 2-A on-device Gemma 4 프레임 자동 생성
- [218-gemma-real-integration](./design/218-gemma-real-integration/) — OQ-1 실 Gemma 4 E2B + flutter_gemma 0.16.5 통합 (placeholder → 실 구현)
### ADR (`adr/`)

View File

@@ -0,0 +1,397 @@
# 설계서: 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/0003-on-device-llm-gemma.md) (재확인, 신규 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` (`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 판정.
- [ ] **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 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 핀.
- **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 정책 재활용.
- **`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-mobile.task';
// ▲ 정확 파일명·SHA 는 OQ-A 에서 확정. 일시값으로 둔 후 Developer 가
// HF page 의 LFS pointer (sha256: ... 라인) 또는
// `huggingface-cli download --dry-run` 로 SHA 추출해 박는다.
// QAT 1GB 모바일 양자화 변종 출시 시 (2026-06+) 그쪽으로 swap.
const _kModelSha256 =
'<HEX_64_FROM_HF_LFS_POINTER>';
const _kModelType = 'gemma4'; // flutter_gemma ModelType enum 매핑
```
`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 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)` 형태로 추정, 확인 필요 |
---
## 부록: 자가 점검 (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.

View File

@@ -0,0 +1,356 @@
# 함수 설계서: `GemmaLlmService.load` + `generateStructured` + 보조 (#218)
> **부모 설계서**: ./README.md · **상태**: Draft
> **작성**: [AI] Architect (2026-06-12) · **구현 대상**: `app/lib/data/ai/gemma_llm_service.dart` · **테스트**: `app/test/data/ai/gemma_llm_service_test.dart`
이 문서는 `GemmaLlmService` 가 노출하는 2 개 public 메서드 + 2 개 file-private 헬퍼를 한 묶음으로 설계한다. 모두 flutter_gemma 0.16.5 의 native 경계를 다루므로 한 문서에서 다루는 게 응집도 측면에서 옳다.
| # | 함수 | 가시성 |
|---|------|-------|
| §A | `GemmaLlmService.load()` | public |
| §B | `GemmaLlmService.generateStructured(prompt, schema)` | public |
| §C | `_appendSchemaInstruction(prompt, schema)` | file-private (`@visibleForTesting`) |
| §D | `_collectFunctionCall(stream, expectedName)` | file-private (`@visibleForTesting`) |
`unload()` 는 단순 (`await _model?.close(); _loaded = false;`) 이므로 별도 섹션 없음.
---
## §A. `GemmaLlmService.load()`
### 1. 시그니처
```dart
@override
Future<void> load();
```
### 2. 책임 (단일 책임, 1줄)
디스크의 `modelPath` 모델 파일을 flutter_gemma native runtime 으로 메모리 적재하고 `_loaded = true` 로 표시한다.
### 3. 입력
| 파라미터 | 타입 | 제약/검증 | 설명 |
|----------|------|-----------|------|
| (instance field) `modelPath` | `String` | 절대 경로, `File(path).existsSync() == true` 가정 | 생성자에서 주입. `ModelLifecycle` 가 다운로드 + SHA 검증 완료 시점에만 유효 |
| (top-level const) `_hfToken` | `String` | `String.fromEnvironment('HF_TOKEN', defaultValue: '')`. 빈 문자열도 허용 (이미 다운로드 완료된 모델은 토큰 불필요할 수 있음) | 빌드 시 `--dart-define=HF_TOKEN=hf_xxx` 으로 주입 |
### 4. 출력
- **반환**: `Future<void>`.
- **부수효과**:
- `FlutterGemma.initialize(huggingFaceToken: _hfToken)` — top-level `_initialized` 가드로 1회만.
- `FlutterGemma.installModel(modelType: ModelType.gemma4).fromFile(modelPath).install()` — flutter_gemma 의 active model 슬롯에 모델 등록.
- 인스턴스 필드 `_model``FlutterGemma.getActiveModel(maxTokens: 2048)` 결과 저장.
- 인스턴스 필드 `_loaded = true`.
### 5. 동작 / 알고리즘
1. `if (_loaded) return;` — idempotent.
2. `if (!await File(modelPath).exists()) throw FileSystemException('model file missing', modelPath);`
3. top-level guard: `if (!_initialized) { await FlutterGemma.initialize(huggingFaceToken: _hfToken); _initialized = true; }`
4. `await FlutterGemma.installModel(modelType: ModelType.gemma4).fromFile(modelPath).install();`
5. `_model = await FlutterGemma.getActiveModel(maxTokens: 2048);`
6. `_loaded = true;`
7. (no `try/catch` here — 모든 예외 caller 에 그대로 전파. `suggestFrame` 의 outer catch 가 graceful 처리)
### 6. 에러 & 실패 모드
| 조건 | 처리 | 반환/예외 |
|------|------|-----------|
| `modelPath` 의 파일 부재 | early throw | `FileSystemException` |
| `_hfToken` 빈 문자열인데 flutter_gemma 가 토큰 요구 | flutter_gemma 의 throw 그대로 | `Exception` (OQ-B 에서 정확 타입 확정) |
| MediaPipe / LiteRT native OOM | native exception → Dart 변환 | `Exception` / `PlatformException` |
| `installModel` 중간에 disk 권한 에러 | flutter_gemma 의 throw 그대로 | `FileSystemException` |
| `getActiveModel``null` (모델 등록 실패) | guard → throw | `StateError('active model missing after install')` |
### 7. 엣지케이스
- **두 번째 호출**: `_loaded == true` → 즉시 return. 같은 `GemmaLlmService` 인스턴스에서 `unload()``load()` 재호출은 정상 동작 (top-level `_initialized` 는 유지, install 만 재실행).
- **다른 인스턴스에서 이미 active model 있음**: flutter_gemma 0.16.5 의 `installModel` 이 active slot 교체 — 우리는 단일 인스턴스 가정이라 무영향.
- **modelPath 가 .litertlm 인데 ModelType.gemma4 와 불일치**: 형식 자동 감지 (확장자 기반). 실패 시 throw.
- **앱 background → foreground 사이클**: `_model` 핸들 유지. native runtime 이 OS 에 의해 강제 종료된 경우 첫 inference 호출에서 에러 → caller 가 `unload()` + `load()` retry 결정 (v1 은 retry 없음, graceful 빈 리스트).
### 8. 복잡도 / 성능
- **시간**: cold start 13 초 (모델 파일 read + native init + tokenizer load). 첫 호출 만, 이후 캐시.
- **공간**: peak RAM ≈ 1.5GB (Gemma 4 E2B Q4_0). QAT 1GB 변종 채택 시 ≈ 1GB.
- **호출 빈도**: 사용자 1 세션 당 01 회 (#219 F1 의 60s idle unload 가 들어오면 다회 가능).
### 9. 의존성
- `package:flutter_gemma/flutter_gemma.dart` (^0.16.5)
- `dart:io` (`File`)
- `String.fromEnvironment('HF_TOKEN')` (build-time inject)
- `ModelLifecycle` (직접 import 안 함 — `_loaded` 보장 책임만 caller 에 위임)
### 10. 테스트 케이스
> flutter_gemma native 직접 호출은 단위 테스트에서 모킹 불가능 (final class 가능성). 본 함수는 **E2E (실 단말, AC-7)** 로만 검증. 단위 테스트는 §C / §D 에 집중.
- [E2E] `modelPath` 가 실 모델 → `_loaded == true` + 후속 `generateStructured` 1회 성공.
- [unit] `modelPath` 가 미존재 파일 → `FileSystemException` (`File.exists()` 만 검증, flutter_gemma 미진입).
- [unit] 두 번 호출 → 두 번째는 noop (counter 증가 X).
### 11. 추적성
- 인수조건: #218 AC-1 (build 성공) + AC-6 (cold start 3s 이내) + AC-9 (OOM graceful).
- 관련 ADR: ADR-0003 (on-device LLM Gemma, 결정 #2 — E2B 단일).
---
## §B. `GemmaLlmService.generateStructured(prompt, schema)`
### 1. 시그니처
```dart
@override
Future<Map<String, dynamic>> generateStructured(
String prompt,
Map<String, dynamic> schema,
);
```
### 2. 책임 (단일 책임, 1줄)
loaded 상태의 Gemma 4 모델에 prompt + JSON Schema 를 전달하여 단일 function call 응답 (`args: Map`) 을 받아 반환한다.
### 3. 입력
| 파라미터 | 타입 | 제약/검증 | 설명 |
|----------|------|-----------|------|
| `prompt` | `String` | non-empty. caller 가 `.length ≤ 4096` 보장 (#215 buildFewShotPrompt). | 시스템 prompt + few-shot + 사용자 raw text |
| `schema` | `Map<String, dynamic>` | `schema['name']: String`, `schema['parameters']: Map` 필수. 본 설계에선 항상 `kFrameCandidatesSchema` 고정. | function calling JSON Schema (#215 §6) |
### 4. 출력
- **반환**: `Future<Map<String, dynamic>>``FunctionCallResponse.args` 그대로. `kFrameCandidatesSchema` 기준이면 `{ "candidates": [...] }` 구조.
- **부수효과**:
- flutter_gemma chat session 1개 생성 후 `chat.close()` 으로 정리.
- 모델 latent state 변경 (다음 호출은 fresh chat).
- log: prompt length, latency, FCR 수신 여부 (prompt 본문 X — 프라이버시).
### 5. 동작 / 알고리즘
```
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. }
```
caller (#215 `suggestFrame`) 가 `.timeout(Duration(seconds: 10))` 적용 → timeout 시 본 함수의 `await` 가 throw 됨 → finally 의 `chat.close()` 가 실행되어 native session leak 방지.
### 6. 에러 & 실패 모드
| 조건 | 처리 | 반환/예외 |
|------|------|-----------|
| `_loaded == false` | early throw | `StateError` |
| `schema['name']` 또는 `schema['parameters']` 누락 | `_appendSchemaInstruction` 가 throw | `ArgumentError` |
| stream 이 `FunctionCallResponse` emit 안 함 (Text 만, 또는 empty) | `_collectFunctionCall` 가 throw | `FormatException('no function call emitted')` |
| 다른 name 의 FCR | `_collectFunctionCall` 가 throw | `FormatException('unexpected function: ${actualName}')` |
| stream 자체 error event | catch → throw (본문은 log 안 함, name 만) | `FormatException('stream error')` |
| caller timeout | finally 에서 close, exception 전파 | (caller 의 `TimeoutException`) |
| native runtime crash | flutter_gemma 가 PlatformException | 그대로 전파 |
### 7. 엣지케이스
- **첫 token 이 Thinking → Text → FCR 순서**: §D 가 첫 FCR 만 채택, 나머지 skip.
- **FCR 두 번 emit**: 첫 번째 채택 후 break — stream 미소진 채 `chat.close()` 호출. flutter_gemma 가 graceful 처리 가정 (OQ).
- **`args``null`**: §D 에서 검사, throw `FormatException('null args')`.
- **`args['candidates']` 가 Map 으로 옴 (List 아님)**: 본 함수 책임 밖. caller 의 `parseFrameCandidates` (#215) 가 `FormatException` 으로 처리.
- **prompt UTF-8 길이 vs token 길이 불일치**: caller 책임. 본 함수는 prompt 길이 검증 X.
### 8. 복잡도 / 성능
- **시간**: warm 0.52초 / cold (load 직후) 추가 13초. function calling 1턴이라 stream 길이 짧음 (~200 token).
- **공간**: chat 인스턴스 ~ 수십 MB (KV cache). close 시 회수.
- **호출 빈도**: habit 생성 화면 진입 시 사용자 trigger. throttle 5회/세션 (#215).
### 9. 의존성
- `flutter_gemma`: `FlutterGemma.getActiveModel` 결과의 `createChat` / `Message.text` / `ModelResponse`.
- `_appendSchemaInstruction` (§C)
- `_collectFunctionCall` (§D)
### 10. 테스트 케이스
- [unit] `_loaded = false``StateError`. (직접 검증)
- [E2E] AC-7 — 실 단말에서 prompt + `kFrameCandidatesSchema``args['candidates']` 3개 반환.
- [unit] caller timeout 시 finally close 호출 확인 — 간접 (`_collectFunctionCall` 가 await never-completing future 일 때 외부 timeout → exception 후 chat.close mock 카운터).
### 11. 추적성
- 인수조건: #218 AC-6 (latency), AC-7 (E2E candidates), AC-9 (graceful).
- 관련 ADR: ADR-0003 결정 #3 (function calling).
---
## §C. `_appendSchemaInstruction(prompt, schema)`
### 1. 시그니처
```dart
@visibleForTesting
String appendSchemaInstruction(String prompt, Map<String, dynamic> schema);
```
> 파일 내부에선 `_appendSchemaInstruction` 으로 호출, 테스트는 public `appendSchemaInstruction` 으로 re-export.
### 2. 책임 (단일 책임, 1줄)
prompt 본문 끝에 Gemma 4 chat template 이 인식할 function call 안내 (name + JSON Schema) 를 append 한다.
### 3. 입력
| 파라미터 | 타입 | 제약/검증 | 설명 |
|----------|------|-----------|------|
| `prompt` | `String` | non-empty | 시스템 + few-shot + 사용자 입력 |
| `schema` | `Map<String, dynamic>` | `schema['name']: String`, `schema['parameters']: Map` 필수 | function calling schema |
### 4. 출력
- **반환**: `String``prompt + '\n\n' + 안내문` 형태.
- **부수효과**: **순수 함수**.
### 5. 동작 / 알고리즘
```
1. final name = schema['name'];
2. if (name is! String || name.isEmpty) throw ArgumentError('schema.name missing');
3. final params = schema['parameters'];
4. if (params is! Map) throw ArgumentError('schema.parameters missing');
5. final description = schema['description'] as String? ?? '';
6. final paramsJson = const JsonEncoder().convert(params);
7. final block = [
'',
'',
'## Function call instruction',
'You MUST respond by calling the function `$name`.',
if (description.isNotEmpty) description,
'Arguments must conform to this JSON Schema:',
'```json',
paramsJson,
'```',
].join('\n');
8. return prompt + block;
```
순수 함수라 deterministic. 같은 입력에 대해 항상 같은 출력.
### 6. 에러 & 실패 모드
| 조건 | 처리 | 반환/예외 |
|------|------|-----------|
| `schema['name']` 누락/빈 문자열 | throw | `ArgumentError('schema.name missing')` |
| `schema['parameters']` 가 Map 아님 | throw | `ArgumentError('schema.parameters missing')` |
| `prompt` 가 빈 문자열 | 허용 (append 만) | OK |
### 7. 엣지케이스
- `params` 가 빈 Map → `{}` JSON 으로 직렬화. caller 가 의도한 경우면 OK (본 설계엔 발생 안 함).
- `description` 누락 → 해당 라인 생략.
- prompt 끝에 이미 `\n\n` 있음 → 결과 `\n\n\n\n`. Gemma 4 tokenizer 가 무시.
### 8. 복잡도 / 성능
- O(N) — `JsonEncoder` 가 schema 깊이에 비례. `kFrameCandidatesSchema` 는 작아서 < 1ms.
### 9. 의존성
- `dart:convert` (`JsonEncoder`).
- `package:flutter/foundation.dart` (`@visibleForTesting`).
### 10. 테스트 케이스
- [unit] `kFrameCandidatesSchema` 입력 → 반환 string 에 `'emit_frame_candidates'``'\"L2\"' / '\"L3\"'` 포함.
- [unit] `schema['name']` 없음 → `ArgumentError`.
- [unit] `schema['parameters']``List``ArgumentError`.
- [unit] 같은 입력 2회 호출 → 동일 string (순수성 검증).
- [unit] `prompt` 끝 trim 없이 그대로 append 되는지 — exact string compare.
### 11. 추적성
- 인수조건: #218 AC-7 (모델이 FCR 로 응답하려면 안내문이 필요).
- 관련 ADR: ADR-0003 결정 #3.
---
## §D. `_collectFunctionCall(stream, expectedName)`
### 1. 시그니처
```dart
@visibleForTesting
Future<Map<String, dynamic>> collectFunctionCall(
Stream<ModelResponse> stream,
String expectedName,
);
```
### 2. 책임 (단일 책임, 1줄)
`Stream<ModelResponse>` 에서 **첫 `FunctionCallResponse(name == expectedName)`**`args` 만 추출한다. `TextResponse`/`ThinkingResponse` 는 skip, 잘못된 name 은 throw.
### 3. 입력
| 파라미터 | 타입 | 제약/검증 | 설명 |
|----------|------|-----------|------|
| `stream` | `Stream<ModelResponse>` | flutter_gemma 의 union 타입. `TextResponse \| FunctionCallResponse \| ThinkingResponse` 가정 | `chat.generateChatResponseAsync()` 결과 |
| `expectedName` | `String` | non-empty | 매칭할 function name (e.g. `'emit_frame_candidates'`) |
### 4. 출력
- **반환**: `Future<Map<String, dynamic>>` — 첫 매칭 FCR 의 `args`.
- **부수효과**: 없음 — stream 의 첫 매칭 이벤트 후 `await for` 빠져나옴 (cancelSubscription 자동).
### 5. 동작 / 알고리즘
```
1. Map<String, dynamic>? result;
2. String? wrongName;
3. try {
4. await for (final event in stream) {
5. if (event is FunctionCallResponse) {
6. if (event.name == expectedName) {
7. result = Map<String, dynamic>.from(event.args ?? const {});
8. break;
9. } else {
10. wrongName = event.name;
11. break; // 잘못된 함수 — 빠른 실패
12. }
13. }
14. // TextResponse / ThinkingResponse 는 무시 (continue)
15. }
16. } catch (e) {
17. throw FormatException('stream error'); // e.toString() 폐기 (prompt 누설 방지)
18. }
19. if (wrongName != null) {
20. throw FormatException('unexpected function: $wrongName');
21. }
22. if (result == null) {
23. throw FormatException('no function call emitted');
24. }
25. return result;
```
`event.args``null` 이면 빈 Map 으로 대체 → caller 의 `parseFrameCandidates` 가 빈 `candidates` 로 처리하여 빈 리스트 반환.
### 6. 에러 & 실패 모드
| 조건 | 처리 | 반환/예외 |
|------|------|-----------|
| stream done 까지 FCR 없음 | check after loop | `FormatException('no function call emitted')` |
| 다른 name 의 FCR | break + check | `FormatException('unexpected function: ...')` |
| stream error event (native crash 등) | catch | `FormatException('stream error')` (원본 e 폐기 — 본문 누설 X) |
| `event.args == null` | 빈 Map 으로 대체 후 return | (no throw) |
### 7. 엣지케이스
- **첫 이벤트가 곧바로 FCR**: 정상. Text/Thinking 없이 바로 break.
- **Text + Text + FCR + FCR (두 번째 FCR 이 정답 name)**: 첫 FCR 의 name 검증으로 break — `wrongName` 으로 throw. v1 정책: 첫 FCR 만 신뢰. (Gemma 4 가 다중 FCR 보내는 경우 거의 없음. 발생 시 prompt 개선 신호.)
- **Thinking → FCR 순서**: Thinking skip 후 FCR 채택. OK.
- **stream 이 무한 (timeout 없음)**: caller 의 `.timeout(10s)` 에 의존. 본 함수는 자체 timeout X.
- **event 가 `null`** (Dart stream 에 null event): `await for` 에서 false-match → skip. (실제로는 발생 안 함, 방어 안 함.)
### 8. 복잡도 / 성능
- O(N) — N = stream 이벤트 수. function calling 응답은 보통 ≤ 10 events. ~수십 ms.
### 9. 의존성
- `package:flutter_gemma/flutter_gemma.dart``ModelResponse` / `FunctionCallResponse` / `TextResponse` / `ThinkingResponse` 타입.
### 10. 테스트 케이스
> 핵심 단위 테스트 슬롯. flutter_gemma response 클래스를 `Stream.fromIterable([...])` 로 fake 주입 가능.
- [unit] `[FunctionCallResponse('emit_frame_candidates', {'candidates': [...3개...]})]``args` 반환.
- [unit] `[TextResponse('hello'), FunctionCallResponse('emit_frame_candidates', {...})]` → Text skip 후 args 반환.
- [unit] `[ThinkingResponse('...'), TextResponse('...'), FunctionCallResponse('emit_frame_candidates', {})]` → 빈 args Map 반환 (no throw).
- [unit] `[FunctionCallResponse('wrong_name', {})]``FormatException('unexpected function: wrong_name')`.
- [unit] `[TextResponse('only text')]` (FCR 없이 done) → `FormatException('no function call emitted')`.
- [unit] `Stream.error(...)` event → `FormatException('stream error')` (원본 메시지 미포함 검증).
- [unit] `[FunctionCallResponse('emit_frame_candidates', null)]` → 빈 Map 반환 (`{}`), no throw.
- [unit] `[]` 빈 stream → `FormatException('no function call emitted')`.
### 11. 추적성
- 인수조건: #218 AC-7 (FCR 수집 성공), AC-9 (graceful — `FormatException` 이 caller 의 빈 리스트 반환으로 전환).
- 관련 ADR: ADR-0003 결정 #3.
---
## 부록: 자가 점검
- [x] §A~§D 4 함수 모두 시그니처 + 책임 + 에러 + 테스트 케이스 채움.
- [x] §D 테스트 케이스 8개 — flutter_gemma 의 ModelResponse 변종 (Text/Thinking/FCR/error/empty) 모두 커버.
- [x] §C 순수성 강조 — `_appendSchemaInstruction` 은 외부 I/O 0, deterministic.
- [x] 프라이버시: §B 와 §D 모두 catch 시 `e.toString()` 폐기 (prompt 본문 누설 방지).
- [x] timeout 책임 분리: 본 모듈은 timeout 미구현, caller 의 `.timeout(10s)` 에 의존. finally close 로 native session leak 방지.
- [x] `@visibleForTesting` 으로 file-private 함수도 단위 테스트 가능.
- [x] AC-7 의 E2E 부분은 §A `load` + §B `generateStructured` 에서만 검증, §C/§D 는 단위 테스트로 100% 커버.