[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:
356
docs/design/218-gemma-real-integration/fn-gemma_llm_service.md
Normal file
356
docs/design/218-gemma-real-integration/fn-gemma_llm_service.md
Normal 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 1–3 초 (모델 파일 read + native init + tokenizer load). 첫 호출 만, 이후 캐시.
|
||||
- **공간**: peak RAM ≈ 1.5GB (Gemma 4 E2B Q4_0). QAT 1GB 변종 채택 시 ≈ 1GB.
|
||||
- **호출 빈도**: 사용자 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. 동작 / 알고리즘
|
||||
```
|
||||
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.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. `_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% 커버.
|
||||
Reference in New Issue
Block a user