[Architect] #215 ADR-0003 + design spec for Gemma frame suggest

- ADR-0003: on-device LLM Gemma 4 E2B Q4_0 + flutter_gemma 도입 결정.
  5개 대안(클라우드/정적확장/Llama/E4B/APK번들) 기각 사유 명시.
- docs/design/215-gemma-frame-suggest/: 설계서 게이트 통과 산출물.
  README.md (12 섹션 전부 + AC10 + OQ6 + 함수 15개) +
  fn-suggest_frame.md (suggestFrame/buildFewShotPrompt/parseFrameCandidates) +
  fn-model_lifecycle.md (LlmService/GemmaLlmService/ModelLifecycle).
- graceful degradation 전면: AI 실패 시 throw 없이 빈 리스트 + 수동 입력 유지.
- LlmService 추상화로 도메인 ↔ flutter_gemma 경계 분리 (테스트 가능성).

Refs #215
This commit is contained in:
2026-06-12 11:16:15 +09:00
parent 8fe6a8f378
commit d31b17f3e8
4 changed files with 1264 additions and 0 deletions

View File

@@ -0,0 +1,381 @@
# 함수 설계서: `ModelLifecycle` + `LlmService` (#215)
> **부모 설계서**: ./README.md · **상태**: Draft
> **작성**: [AI] Architect · **구현**: `app/lib/data/ai/model_lifecycle.dart`, `llm_service.dart`, `gemma_llm_service.dart` (TBD) · **테스트**: `app/test/data/ai/{model_lifecycle,gemma_llm_service}_test.dart` (TBD)
> 본 문서는 모델 수명주기와 추론 인터페이스를 묶어 다룬다. 모두 I/O 경계 — flutter_gemma + 파일시스템 + 네트워크 + 네이티브 메모리.
---
## §A. `LlmService` (추상 인터페이스)
### 1. 시그니처
```dart
abstract class LlmService {
Future<void> load();
Future<void> unload();
bool get isLoaded;
Future<Map<String, dynamic>> generateStructured(
String prompt,
Map<String, dynamic> schema,
);
}
```
### 2. 책임 (1줄)
LLM 백엔드를 도메인에 노출하는 단일 인터페이스. 구현체는 `GemmaLlmService` (flutter_gemma) 와 `MockLlmService` (테스트).
### 3. 입력 / 출력
| 메서드 | 입력 | 출력 |
|--------|------|------|
| `load()` | (구현체 내부에서 path 결정) | void. 모델 메모리 상주 |
| `unload()` | none | void. 메모리 회수 |
| `isLoaded` | none | bool |
| `generateStructured(prompt, schema)` | prompt string, function calling JSON schema | parsed JSON map |
### 4. 부수효과
- `load`: 모델 파일 read + 네이티브 메모리 할당.
- `unload`: 네이티브 메모리 해제.
- `generateStructured`: 네이티브 추론 호출. prompt 본문은 로그 X (privacy).
### 5. 인터페이스 계약 (구현체가 반드시 지켜야 할 것)
- `load` 가 throw 하지 않고 정상 종료하면 `isLoaded == true`.
- `generateStructured` 호출 시 `!isLoaded` → throw `StateError("not loaded")`.
- `generateStructured` 응답은 schema 의 `parameters` 와 일치 — 위반 시 throw `FormatException`.
- `unload` 는 idempotent. `!isLoaded` 상태에서 호출해도 noop.
- timeout 은 호출자가 `.timeout(...)` 로 부여. 본 인터페이스는 별도 타임아웃 처리 X.
### 6. 추적성
- 인수조건: #215 AC-6 (응답), AC-9 (graceful — throw 종류 명세).
- 관련 ADR: ADR-0003.
---
## §B. `GemmaLlmService` (flutter_gemma 구현체)
### 1. 시그니처
```dart
class GemmaLlmService implements LlmService {
GemmaLlmService({
required this.modelPath,
FlutterGemma? gemma,
});
// ...
}
```
### 2. 책임 (1줄)
`flutter_gemma` 를 호출해 Gemma 4 E2B Q4_0 모델을 디스크에서 로드하고 function calling 응답을 받는다.
### 3. 입력
| 파라미터 | 타입 | 제약/검증 | 설명 |
|----------|------|-----------|------|
| `modelPath` | String | 절대 경로, 파일 존재 | `ModelLifecycle.checkAvailability` 가 ready 일 때만 전달 |
| `gemma` | `FlutterGemma?` | nullable (DI 용) | 테스트 시 mock 주입 가능 |
### 4. 출력
- `load`: void. 내부 `_loaded = true`.
- `generateStructured`: parsed JSON (function calling 의 arguments).
- `unload`: void. 내부 `_loaded = false`.
### 5. 동작 / 알고리즘
```
load:
1. if _loaded: return (idempotent)
2. gemma = FlutterGemma()
3. await gemma.init(modelPath: modelPath, backend: 'litert_lm_android')
- flutter_gemma 의 정확한 API 는 패키지 문서 확정 후 (OQ-1)
4. _loaded = true
catch:
- OOM → throw StateError("OOM during load")
- File not found → throw FileSystemException
- 기타 → rethrow
generateStructured(prompt, schema):
1. if !_loaded: throw StateError("not loaded")
2. response = await gemma.generateWithFunctionCalling(
prompt: prompt,
functions: [
{"name": "emit_frame_candidates", "parameters": schema},
],
)
3. if response.functionCall == null:
throw FormatException("no function call")
4. return response.functionCall.arguments // already parsed Map
catch:
- FormatException → rethrow (호출자가 결정)
unload:
1. if !_loaded: return
2. await gemma.close()
3. _loaded = false
```
### 6. 에러 & 실패 모드
| 조건 | 처리 | 반환/예외 |
|------|------|-----------|
| 모델 파일 없음 | rethrow | FileSystemException |
| OOM | catch + 변환 | StateError("OOM") |
| init crash (native) | rethrow | Exception (구체 클래스는 flutter_gemma 정의) |
| function call 없는 응답 | throw | FormatException |
| schema 위반 응답 | flutter_gemma 가 거부 + retry → 최종 실패 시 FormatException | (패키지가 처리) |
### 7. 엣지케이스
- 동시에 두 번 `load()` 호출 → idempotent. 두 번째는 noop.
- `load()` 진행 중 `unload()` 호출 → 첫 load 완료 후 unload (Lock 사용 권장).
- 추론 도중 앱 background → flutter_gemma 가 native 측 isolate 로 계속. UI 가 result 받음.
- 추론 도중 unload 호출 → race condition. v1 은 `_inferring = true` flag 로 unload 지연.
### 8. 복잡도 / 성능
- `load`: cold 13초 (디스크 → RAM). 1.5GB read.
- `generateStructured`: cold (모델 첫 호출) 12초, warm 0.52초.
- `unload`: < 100ms.
- 메모리: 모델 상주 ≈ 1.5GB.
### 9. 의존성
- `flutter_gemma` 패키지 (latest, pubspec.lock 동결).
- `dart:async`.
### 10. 테스트 케이스
- [ ] **정상 load → generate → unload**: dummy 1KB fixture 모델 + mock FlutterGemma 주입.
- [ ] **!loaded 상태 generateStructured**: throw StateError.
- [ ] **load idempotent**: 두 번 호출 → 두 번째 noop, `isLoaded == true`.
- [ ] **unload idempotent**: 두 번 호출 → 두 번째 noop.
- [ ] **OOM 시뮬레이션**: mock 이 OOM throw → StateError 변환.
- [ ] **function call 없는 응답**: mock 응답 → FormatException.
- [ ] **schema 일치 응답**: mock 응답 → arguments map 반환.
### 11. 추적성
- 인수조건: #215 AC-6, AC-7, AC-9.
- 관련 ADR: ADR-0003 (flutter_gemma + LiteRT-LM Android).
- Open Question: OQ-1 (모델 URL), OQ-4 (timeout).
---
## §C. `ModelLifecycle.checkAvailability`
### 1. 시그니처
```dart
Future<ModelAvailability> checkAvailability();
enum ModelAvailability { ready, missing, corrupt, downloading }
```
### 2. 책임 (1줄)
디스크의 모델 파일 존재 + SHA-256 무결성 + meta_kv 상태를 종합해 가용성을 결정한다.
### 3. 입력 / 출력
- 입력: none (내부에서 `MetaKvDao` + `path_provider` 사용).
- 출력: `ModelAvailability` enum.
### 4. 동작 / 알고리즘
```
1. opt_in = await metaKv.get('ai_opt_in')
- 'false' or null → return missing (사용자가 끔)
2. state = await metaKv.get('ai_download_state')
- 'downloading' or 'paused' → return downloading
3. path = await metaKv.get('ai_model_path')
- null → return missing
4. file = File(path)
- !exists → return missing (외부 삭제 시나리오)
5. expectedSha = await metaKv.get('ai_model_sha256')
- null → return corrupt
6. actualSha = sha256.convert(await file.readAsBytes()).toString()
- != expectedSha → return corrupt
7. return ready
```
> 실제 1.5GB 파일 SHA-256 매번 계산은 비용 큼. v1 은 **앱 시작 시 1회만** 검증 + 결과 캐시. 매번 호출 시 캐시 hit. v1.1 에서 마지막 검증 시각 + invalidation 정책 검토.
### 5. 에러 & 실패 모드
| 조건 | 처리 | 반환 |
|------|------|------|
| meta_kv I/O 실패 | catch | missing |
| file read 실패 (권한) | catch | corrupt |
| SHA 계산 도중 OOM (희박) | catch | corrupt |
### 6. 복잡도 / 성능
- 첫 호출 (해시 계산): 1.5GB read + SHA-256 → 515초. **앱 시작 시 백그라운드 isolate 에서 수행**.
- 이후 캐시: < 5ms.
### 7. 의존성
- `MetaKvDao` (#204), `path_provider`, `crypto` (SHA-256), `dart:io`.
### 8. 테스트 케이스
- [ ] **ready**: 올바른 path + 일치 SHA → ready.
- [ ] **missing (opt_out)**: ai_opt_in = false → missing.
- [ ] **missing (path null)**: ai_model_path = null → missing.
- [ ] **missing (file 없음)**: path 는 있으나 file 부재 → missing.
- [ ] **downloading**: state = paused → downloading.
- [ ] **corrupt**: SHA mismatch → corrupt.
### 9. 추적성
- 인수조건: #215 AC-4 (무결성), AC-9 (graceful).
---
## §D. `ModelLifecycle.download`
### 1. 시그니처
```dart
Stream<DownloadProgress> download({String? modelUrl});
class DownloadProgress {
final int bytesReceived;
final int totalBytes; // -1 if unknown
final DownloadState state; // downloading | paused | completed | failed
final String? errorMessage;
}
```
### 2. 책임 (1줄)
Gemma 4 E2B Q4_0 모델 파일을 HTTP Range 로 백그라운드 다운로드 + 일시정지/재개 + SHA-256 검증.
### 3. 입력
| 파라미터 | 타입 | 제약/검증 | 설명 |
|----------|------|-----------|------|
| `modelUrl` | String? | 기본값 = config 상수 (OQ-1) | 테스트 / 추후 swap 시 override |
### 4. 출력
- `Stream<DownloadProgress>` — 진행률 broadcast.
- 부수효과: 디스크 파일 write, meta_kv 4개 키 update.
### 5. 동작 / 알고리즘
```
1. tempPath = await pathProvider.getApplicationSupportDirectory() + '/gemma4_e2b_q4.bin.tmp'
2. finalPath = without '.tmp'
3. existingBytes = File(tempPath).existsSync() ? File(tempPath).lengthSync() : 0
4. await metaKv.set('ai_download_state', 'downloading')
5. await metaKv.set('ai_download_bytes', existingBytes.toString())
6. yield DownloadProgress(existingBytes, -1, downloading)
7. response = await http.get(modelUrl, headers: {'Range': 'bytes=$existingBytes-'})
- 200 (full) or 206 (partial)
8. totalBytes = existingBytes + response.contentLength
9. stream = response.stream
10. for each chunk in stream:
- if pauseRequested: break (state='paused', save bytes)
- if cancelRequested: delete tempPath, state='failed', break
- file.writeAsBytes(chunk, mode: append)
- existingBytes += chunk.length
- await metaKv.set('ai_download_bytes', existingBytes.toString())
- yield DownloadProgress(existingBytes, totalBytes, downloading)
11. if completed:
- rename tempPath → finalPath
- actualSha = sha256(finalPath)
- if actualSha != EXPECTED_SHA:
- delete finalPath, state='failed', yield failed
- return
- await metaKv.set('ai_model_path', finalPath)
- await metaKv.set('ai_model_sha256', actualSha)
- await metaKv.set('ai_download_state', 'completed')
- yield DownloadProgress(totalBytes, totalBytes, completed)
12. catch network error:
- retry 3회 exponential backoff (1s, 2s, 4s)
- 최종 실패 → state='paused' (자동 재개 가능), yield failed with message
```
> `EXPECTED_SHA` 는 모델 파일의 알려진 hash. OQ-1 에서 URL + SHA 함께 확정.
### 6. 에러 & 실패 모드
| 조건 | 처리 | 반환 (stream emit) |
|------|------|---------------------|
| 네트워크 끊김 | retry 3회 → paused | `failed` (자동 재개 가능 표시) |
| 디스크 공간 부족 | catch IOException | `failed` ("저장 공간 부족") |
| SHA mismatch | 파일 삭제 + state failed | `failed` ("다운로드 손상") |
| HTTP 416 (Range 위반) | 0 부터 재시작 | `downloading` from 0 |
| 사용자 취소 | tempPath 삭제 + meta_kv clear | `failed` ("취소됨") |
| 사용자 일시정지 | tempPath 보존 + state=paused | `paused` |
### 7. 엣지케이스
- 다운로드 완료 직전 앱 종료 → tempPath 만 존재 (rename 미실행). 재시작 시 `checkAvailability` = downloading 상태로 인식 → 재개 시 nearly complete.
- 두 번 동시 호출 → v1 은 lock 으로 단일 인스턴스 보장.
- WiFi → cellular 전환 — 본 함수는 신경 X. Settings 의 "WiFi only" 토글이 별도 차단 (OQ-3).
### 8. 복잡도 / 성능
- 시간: 1.5GB @ 50Mbps = 약 4분. WiFi 200Mbps = 약 1분.
- SHA 검증: 515초.
- 메모리: 스트림 chunk 만 (≤ 64KB).
### 9. 의존성
- `http` 또는 `dio` (range request 지원).
- `path_provider`, `crypto`, `dart:io`.
- `MetaKvDao`.
### 10. 테스트 케이스
- [ ] **fresh download (small fixture)**: mock HTTP server 가 1KB 응답 → completed, file 존재, SHA 매치.
- [ ] **resume**: 512B 다운로드 후 pause → 재개 시 Range=512- 요청 → completed.
- [ ] **SHA mismatch**: mock 이 잘못된 byte 전송 → failed + file 삭제.
- [ ] **network error → retry → success**: mock 이 첫 2번 throw, 3번째 OK → completed.
- [ ] **cancel**: 진행 중 cancel → tempPath 삭제 + meta_kv clear.
- [ ] **pause + 앱 재시작 시뮬레이션**: bytes 보존 → 다시 download() 호출 시 Range header.
- [ ] **HTTP 416**: mock 이 416 → 0부터 재시작.
### 11. 추적성
- 인수조건: #215 AC-3 (일시정지/재개/취소), AC-4 (무결성).
- Open Question: OQ-1 (URL), OQ-3 (cellular).
---
## §E. 통합 점검
- `LlmService` 인터페이스만 도메인에 노출. 도메인 (`suggest_frame.dart`) 은 `flutter_gemma` 를 import 하지 않는다 (I/O ↔ 순수 경계).
- `ModelLifecycle.purge` (단순 함수, fn 분리 안 함):
```
1. await unload() (혹시 로드 중)
2. path = meta_kv.get('ai_model_path')
3. if path != null: File(path).delete() // idempotent
4. meta_kv.delete(['ai_model_path', 'ai_model_sha256', 'ai_download_state', 'ai_download_bytes'])
5. return freedBytes
```
- 모든 메서드는 graceful — `suggestFrame` 호출 흐름에서 모델 로드 실패 / 다운로드 실패 시 throw 하지 않고 호출자가 `[]` 빈 결과를 반환받도록 한다. UI 가 사용자 메시지 결정 (AC-9 graceful degradation 보장).