diff --git a/docs/design/311-llm-warmup/README.md b/docs/design/311-llm-warmup/README.md index 0bf3bfb..e994be8 100644 --- a/docs/design/311-llm-warmup/README.md +++ b/docs/design/311-llm-warmup/README.md @@ -1,8 +1,8 @@ # 설계서: ChatScreen LLM warm-up (#311) -> **상태**: Draft -> **작성**: [AI] Architect · **최종수정**: 2026-06-15 -> **추적성** — Redmine: #311 · 관련 ADR: 없음 (Backward-compatible 추가) +> **상태**: Approved (v0.4.1, 2026-06-15) +> **작성**: [AI] Architect · **최종수정**: 2026-06-15 (08-Documenter 마감) +> **추적성** — Redmine: #311 · 관련 ADR: 없음 (Backward-compatible 추가) · Release: v0.4.1 (`121108f`) > · 구현 파일: `app/lib/state/chat_warmup_provider.dart` (신규) · `app/lib/data/ai/llm_service.dart` (수정) · `app/lib/data/ai/model_lifecycle.dart` (`quickCheck` 추가) · `app/lib/data/ai/gemma_llm_service.dart` (concurrent load guard) · `app/lib/ui/screens/chat_screen.dart` (warmup binding) > · 테스트: `app/test/state/chat_warmup_test.dart` (신규) · `app/test/data/ai/model_lifecycle_test.dart` (quickCheck 케이스 추가) · `app/test/ui/chat_screen_test.dart` (warmup 라벨/disabled 케이스 추가) diff --git a/docs/design/311-llm-warmup/fn-chat_warmup_controller.md b/docs/design/311-llm-warmup/fn-chat_warmup_controller.md index 1431426..1446410 100644 --- a/docs/design/311-llm-warmup/fn-chat_warmup_controller.md +++ b/docs/design/311-llm-warmup/fn-chat_warmup_controller.md @@ -1,6 +1,6 @@ # 함수 설계서: `ChatWarmupController.start` (#311) -> **부모 설계서**: ./README.md · **상태**: Draft +> **부모 설계서**: ./README.md · **상태**: Approved (v0.4.1, 2026-06-15) > **작성**: [AI] Architect · **구현**: `app/lib/state/chat_warmup_provider.dart:start` · **테스트**: `app/test/state/chat_warmup_test.dart` ## 1. 시그니처 diff --git a/docs/design/311-llm-warmup/fn-concurrent_load_guard.md b/docs/design/311-llm-warmup/fn-concurrent_load_guard.md index 32fb720..fbbdb8c 100644 --- a/docs/design/311-llm-warmup/fn-concurrent_load_guard.md +++ b/docs/design/311-llm-warmup/fn-concurrent_load_guard.md @@ -1,6 +1,6 @@ # 함수 설계서: `GemmaLlmService.load` concurrent guard (#311) -> **부모 설계서**: ./README.md · **상태**: Draft +> **부모 설계서**: ./README.md · **상태**: Approved (v0.4.1, 2026-06-15) > **작성**: [AI] Architect · **구현**: `app/lib/data/ai/gemma_llm_service.dart:load` (수정) · **테스트**: `app/test/data/ai/gemma_llm_service_test.dart` (concurrent 케이스 추가) / `chat_warmup_test.dart` (시뮬) ## 1. 시그니처 diff --git a/docs/guides/ai-chat-using.md b/docs/guides/ai-chat-using.md index 37690d3..5ba4f02 100644 --- a/docs/guides/ai-chat-using.md +++ b/docs/guides/ai-chat-using.md @@ -1,6 +1,6 @@ # AI 코치와 대화하기 (사용자 가이드) -> 적용 버전: **v0.4.0 이상** · Redmine #260 · 관련 레퍼런스: [docs/reference/260-in-app-tool-calling.md](../reference/260-in-app-tool-calling.md) +> 적용 버전: **v0.4.0 이상** (warm-up 은 **v0.4.1+**) · Redmine #260 / #311 · 관련 레퍼런스: [도구 호출](../reference/260-in-app-tool-calling.md) · [Warm-up](../reference/311-llm-warmup.md) > > 본 가이드는 v0.3.0 의 [AI 도움 켜기·끄기](ai-help-onboarding.md) 가 끝났다는 전제에서 시작합니다. 모델이 단말에 준비되어 있어야 합니다. @@ -52,6 +52,12 @@ A. 4 회를 넘기면 자동으로 멈추고 "도구 호출 루프가 너무 길 **Q. 응답이 너무 느린 것 같아요.** A. 단말 모델이라 1턴 평균 2~5초가 정상입니다. AI 가 도구를 호출하면 round trip 이 한 번 더 들어가 6~10초가 될 수 있어요. +**Q. ChatScreen 진입 시 입력창에 "AI 준비 중…" 이 떠요.** +A. v0.4.1 부터 모델을 백그라운드로 미리 시동합니다 (warm-up). 첫 진입 시 한 번만 보이고 평균 2~8초 안에 사라집니다. 그동안 메시지를 미리 타이핑해도 되고, send 버튼만 비활성 상태로 기다립니다. 다시 진입하면 즉시 사용 가능합니다. + +**Q. "AI 모델 파일을 찾을 수 없어요." / "AI 를 시작하지 못했어요." 가 떠요.** +A. 파일을 못 찾는 경우는 [설정으로 가기] 로 이동해 재다운로드, 일시적 시작 실패는 [다시 시도] 로 회복합니다. 설정에서 다운로드를 완료하고 돌아오면 자동으로 다시 시도합니다. + **Q. 도구 결과가 잘렸어요.** A. 모델 컨텍스트 보호를 위해 도구 결과는 2KB 로 제한됩니다. 잘림이 발생하면 AI 가 자동으로 단건 조회 (`프로토콜 상세`) 도구를 다시 부르도록 안내됩니다. diff --git a/docs/reference/311-llm-warmup.md b/docs/reference/311-llm-warmup.md new file mode 100644 index 0000000..0408f38 --- /dev/null +++ b/docs/reference/311-llm-warmup.md @@ -0,0 +1,103 @@ +# LLM warm-up (#311) + +> 적용 버전: **v0.4.1 이상** · Redmine #311 · 설계서: [docs/design/311-llm-warmup/](../design/311-llm-warmup/) + +`ChatScreen` 진입 시 LLM 모델을 백그라운드로 warm-up 하여 첫 send 의 cold native-init 비용 (수 초) 을 제거한다. + +## 상태 머신 + +`app/lib/state/chat_warmup_provider.dart` — `sealed class ChatWarmupState`. + +| 상태 | 진입 조건 | UI 동작 | +|------|-----------|---------| +| `Idle` | 초기값 | 평상 | +| `Loading` | `quickCheck = ready` + `llm.isLoaded = false` → `llm.load()` in-flight | hintText 교체, send 자리 spinner | +| `Ready` | `load()` 성공 또는 `llm.isLoaded = true` fast path | 평상, send 즉시 활성 | +| `Failed(kind)` | `load()` throw | `_WarmupErrorBanner` 표시 | +| `Unavailable` | `quickCheck != ready` (opt-out / downloading / missing) | 평상 (warmup 라벨 X, 기존 lazy 경로 fallback) | + +`ChatWarmupFailureKind`: +- `fileMissing` — `FileSystemException`. 회복 = `SettingsScreen` push (재다운로드). +- `runtime` — 기타. 회복 = `retry()`. + +## API + +### `ChatWarmupController` + +```dart +final chatWarmupProvider = + StateNotifierProvider.autoDispose(...); +``` + +- `Future start()` — quickCheck → load → state 전이. 재진입 가드 (Loading 중 호출은 no-op). `ChatScreen.initState` 의 `postFrameCallback` 에서 호출. +- `Future retry()` — Idle reset 후 start() 재호출. +- dispose 시 `_disposed = true` → `_safeSet` 가 후속 state 변경 무시. + +### `ModelLifecycle.quickCheck()` + +```dart +Future quickCheck(); +``` + +`checkAvailability()` 의 lightweight 버전. SHA-256 재해싱 (~2.4GB) 을 건너뛰고 meta_kv + 파일 존재만으로 ready 추정. tamper 감지는 `checkAvailability()` 의 cold path (SettingsScreen) 에 위임. + +| meta_kv 상태 | quickCheck 반환 | +|---|---| +| `ai_opt_in != 'true'` | `missing` | +| `ai_download_state in (downloading, paused)` | `downloading` | +| `ai_model_path` null | `missing` | +| `ai_model_sha256` null | `corrupt` | +| 파일 부재 | `missing` | +| 그 외 | `ready` | +| 내부 throw | `corrupt` (보수적 fallback) | + +### Concurrent load guard + +`GemmaLlmService.load()` + `MockLlmService.load()` 가 `_loadingFuture` 가드 공유: + +```dart +Future load() { + if (_loaded) return Future.value(); + final existing = _loadingFuture; + if (existing != null) return existing; + final future = _doLoad(); + _loadingFuture = future; + return future.whenComplete(() { _loadingFuture = null; }); +} +``` + +ChatScreen warm-up + `userTurn` lazy load 가 race 해도 native init 1회만 실행. `_doLoad` throw 시 `whenComplete` 가 `_loadingFuture = null` 처리 → 다음 caller 가 새 시도. + +## UI binding (`chat_screen.dart`) + +- `TextField.enabled = !state.isStreaming` — warmup state 와 무관 (타이핑 미리 작성 가능, UX R1+R2). +- `hintText` 분기 — warmup loading 시 `AI 준비 중… 첫 시작은 몇 초 걸려요`, 그 외 평상. +- send 영역 — `state.isStreaming || isWarming` 이면 `CircularProgressIndicator(strokeWidth: 2)`, 그 외 `IconButton.filled`. +- `_WarmupErrorBanner` (`Failed` 일 때만 표시): + - 메시지 본문 = 상태 기술만 (명령형 X, AC12). + - 우측 정렬 `OutlinedButton`: + - `fileMissing` → `[설정으로 가기]` + `Navigator.push(SettingsScreen).then((_) => retry())` (pop 후 자동 retry). + - `runtime` → `[다시 시도]` + `retry()` (즉시). + +## 마이크로카피 + +| 상황 | 한국어 | +|------|--------| +| warmup 중 hintText | `AI 준비 중… 첫 시작은 몇 초 걸려요` | +| 평상 hintText | `습관 추가, 기록, 카탈로그 질문…` | +| Failed(fileMissing) | `AI 모델 파일을 찾을 수 없어요.` | +| Failed(runtime) | `AI 를 시작하지 못했어요.` | + +명령형 ("다시 시도해주세요" 등) 은 본문 금지 — 행동은 버튼이 담당. + +## 테스트 + +- `app/test/state/chat_warmup_test.dart` — 8 unit (AC1-2, 5-7, 11-12 + retry + 재진입 가드). +- `app/test/data/ai/model_lifecycle_test.dart` — quickCheck 4 신규. +- `app/test/ui/chat_screen_test.dart` — widget E2E 보류 (NOTE comment 사유). `CircularProgressIndicator` 무한 ticker ↔ `pumpAndSettle` race 가 framework-level 한계. + +## 관련 문서 + +- 설계서: [docs/design/311-llm-warmup/](../design/311-llm-warmup/) +- 사용자 가이드: [AI 코치와 대화하기](../guides/ai-chat-using.md) +- 변경 이력: [CHANGELOG.md](../../CHANGELOG.md) [0.4.1]