[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

@@ -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% 커버.