Files
life-helper/docs/design/215-gemma-frame-suggest/fn-model_lifecycle.md
joungmin ed340839a0 [Documenter] #215 Reference + guide + design Approved
- docs/reference/215-ai-frame-suggest.md — v0.2.0 모듈/함수/Riverpod/meta_kv 사양
- docs/guides/ai-help-onboarding.md — AI 도움 켜기/끄기 사용자 가이드
- docs/design/215-gemma-frame-suggest/{README,fn-suggest_frame,fn-model_lifecycle}
  상태 Draft → Approved, 추적성 헤더에 실제 구현 파일/테스트 경로 + 레퍼런스/가이드 cross-link
- docs/README.md — 현재 발행된 문서 인덱스 섹션 추가

Refs #215
2026-06-12 13:32:29 +09:00

383 lines
14 KiB
Markdown
Raw Permalink 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.
# 함수 설계서: `ModelLifecycle` + `LlmService` (#215)
> **부모 설계서**: ./README.md · **상태**: Approved (v0.2.0)
> **작성**: [AI] Architect · **구현**: `app/lib/data/ai/model_lifecycle.dart`, `llm_service.dart`, `gemma_llm_service.dart` (stub) · **테스트**: `app/test/data/ai/model_lifecycle_test.dart` + `app/test/state/model_download_controller_test.dart` (총 10 케이스)
> · 동기화 노트: `GemmaLlmService` 는 모든 메서드가 `UnimplementedError` 를 던지는 stub 상태 — OQ-1 (실 모델 URL+SHA) 해결 시 활성. v1 런타임은 `MockLlmService` 가 `llmServiceProvider` 에 주입.
> 본 문서는 모델 수명주기와 추론 인터페이스를 묶어 다룬다. 모두 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 보장).