- 설계서 218-gemma-real-integration/README.md → Approved + AC 체크박스 채움 + 실제 구현/테스트 파일 경로 추적성 갱신
- fn-gemma_llm_service.md → Approved (v2)
- reference/215-ai-frame-suggest.md → v0.3.0 (commit da60dd1 핀)
- guides/ai-help-onboarding.md → 적용 버전 v0.3.0 + RAM 4GB 요구사항 명시
- docs/README.md 인덱스 v0.3.0 표기
AC-7 (실 단말 E2E) 만 DEFER — 사용자 실기 검증 결과로 별도 갱신.
Refs #218
379 lines
20 KiB
Markdown
379 lines
20 KiB
Markdown
# 함수 설계서: `GemmaLlmService.load` + `generateStructured` + 보조 (#218)
|
||
|
||
> **부모 설계서**: ./README.md · **상태**: Approved (v2, 2026-06-12 — Reviewer 1b90f58, Release v0.3.0)
|
||
> **작성**: [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 경계를 다루므로 한 문서에서 다루는 게 응집도 측면에서 옳다.
|
||
|
||
| # | 함수 | 가시성 |
|
||
|---|------|-------|
|
||
| §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 1–3 초 (모델 파일 read + native init + tokenizer load). 첫 호출 만, 이후 캐시.
|
||
- **공간**: peak RAM ≈ 1.5–2GB (Gemma 4 E2B Q4 .litertlm, 가중치 ~1.3GB + KV cache + activation). disk ≈ 2.41GB.
|
||
- **호출 빈도**: 사용자 1 세션 당 0–1 회 (#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. 동작 / 알고리즘 (v2)
|
||
```
|
||
1. if (!_loaded) throw StateError('LlmService not loaded');
|
||
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 방지.
|
||
|
||
### 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.5–2초 / cold (load 직후) 추가 1–3초. 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. (DEPRECATED — v2) `_appendSchemaInstruction(prompt, schema)`
|
||
|
||
> **v2 결정**: Gemma 4 SDK 가 `Tool` 객체에서 직접 declaration 토큰을 렌더하므로, prompt 측에서 schema 안내문을 덧붙이면 double-wrap 이 된다. **본 함수는 구현하지 않는다.**
|
||
>
|
||
> 아래 §C 본문은 v1 (gemmaIt fallback) 시나리오용 참고 자료로 보존하나, v2 코드 대상에서 제외한다. 단위 테스트도 작성하지 않는다.
|
||
|
||
원본 본문 (참고용):
|
||
|
||
### 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% 커버.
|