Files
life-helper/docs/design/218-gemma-real-integration/fn-gemma_llm_service.md
joungmin a1f3c5f85d [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
2026-06-12 14:54:28 +09:00

357 lines
18 KiB
Markdown
Raw 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.
# 함수 설계서: `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% 커버.