- 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
14 KiB
함수 설계서: 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. 시그니처
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→ throwStateError("not loaded").generateStructured응답은 schema 의parameters와 일치 — 위반 시 throwFormatException.unload는 idempotent.!isLoaded상태에서 호출해도 noop.- timeout 은 호출자가
.timeout(...)로 부여. 본 인터페이스는 별도 타임아웃 처리 X.
6. 추적성
- 인수조건: #215 AC-6 (응답), AC-9 (graceful — throw 종류 명세).
- 관련 ADR: ADR-0003.
§B. GemmaLlmService (flutter_gemma 구현체)
1. 시그니처
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 = trueflag 로 unload 지연.
8. 복잡도 / 성능
load: cold 1–3초 (디스크 → RAM). 1.5GB read.generateStructured: cold (모델 첫 호출) 1–2초, warm 0.5–2초.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. 시그니처
Future<ModelAvailability> checkAvailability();
enum ModelAvailability { ready, missing, corrupt, downloading }
2. 책임 (1줄)
디스크의 모델 파일 존재 + SHA-256 무결성 + meta_kv 상태를 종합해 가용성을 결정한다.
3. 입력 / 출력
- 입력: none (내부에서
MetaKvDao+path_provider사용). - 출력:
ModelAvailabilityenum.
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 → 5–15초. 앱 시작 시 백그라운드 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. 시그니처
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 검증: 5–15초.
- 메모리: 스트림 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 보장).