[02-Architect] #311 design spec + UX-Reviewer persona for LLM warm-up

- docs/design/311-llm-warmup/README.md — 기능 설계서. ChatWarmupController (5-state) + GemmaLlmService _loadingFuture concurrent guard + ModelLifecycle.quickCheck (lightweight ready).
- docs/design/311-llm-warmup/UX-REVIEW.md — UX-Reviewer parallel pass. Strong 4 + Suggest 2 권고. 입력창 enabled 유지 (타이핑 가능) + hintText 만 교체 + 상태-행동 분리.
- docs/design/311-llm-warmup/fn-chat_warmup_controller.md — start/retry 상세 + 빠른 경로 (isLoaded 시 Loading skip).
- docs/design/311-llm-warmup/fn-concurrent_load_guard.md — _loadingFuture 패턴 + whenComplete cleanup.
- .claude/agents/ux-reviewer.md — 신규 페르소나 (02-Architect 단계 내 parallel reviewer, 카테고리 부여 X).

AC 8 → 12 (UX 신규 4건 통합). OQ 3건 모두 해소. ADR 없음 (backward-compatible 추가).

Refs #311 #260
This commit is contained in:
2026-06-15 11:41:03 +09:00
parent 44d571f4ee
commit 1fa4f24a8a
5 changed files with 618 additions and 0 deletions

View File

@@ -0,0 +1,103 @@
# 함수 설계서: `ChatWarmupController.start` (#311)
> **부모 설계서**: ./README.md · **상태**: Draft
> **작성**: [AI] Architect · **구현**: `app/lib/state/chat_warmup_provider.dart:start` · **테스트**: `app/test/state/chat_warmup_test.dart`
## 1. 시그니처
```dart
class ChatWarmupController extends StateNotifier<ChatWarmupState> {
ChatWarmupController({
required this.llm,
required this.lifecycle,
}) : super(const ChatWarmupIdle());
final LlmService llm;
final ModelLifecycle lifecycle;
bool _disposed = false;
Future<void> start();
Future<void> retry();
@override
void dispose() { _disposed = true; super.dispose(); }
}
```
## 2. 책임 (단일 책임)
모델 ready 추정 → background `load()` → state 전이까지를 한 번의 트랜잭션으로 묶고, 모든 실패/취소 분기에서 안전하게 state 만 갱신한다.
## 3. 입력
| 파라미터 | 타입 | 제약/검증 | 설명 |
|----------|------|-----------|------|
| (ctor) `llm` | `LlmService` | non-null | `load`/`isLoaded` 만 사용. |
| (ctor) `lifecycle` | `ModelLifecycle` | non-null | `quickCheck` 만 사용. |
## 4. 출력
- **반환**: `Future<void>` — 완료 시점에 state 가 ready/failed/unavailable 중 하나로 확정.
- **부수효과**: `state =` 설정. 다른 I/O 없음.
## 5. 동작 / 알고리즘
```
1. 현재 state 가 Loading 이면 즉시 return (재진입 가드, retry 외에는 발생 X).
2. state = ChatWarmupLoading() 임시 설정 (단, 아래 빠른 경로 확인 전이라 안전).
→ ChatScreen 재진입 시 깜빡임 방지 위해 isLoaded 빠른 경로를 먼저 확인:
if (llm.isLoaded) {
_safeSet(const ChatWarmupReady());
return;
}
3. quickCheck = await lifecycle.quickCheck();
4. quickCheck != ready:
_safeSet(const ChatWarmupUnavailable());
return;
5. _safeSet(const ChatWarmupLoading()); // 본격 로드 시작
6. try { await llm.load(); }
catch (e) {
_safeSet(ChatWarmupFailed(_messageFor(e)));
return;
}
7. _safeSet(const ChatWarmupReady());
_safeSet(s) = if (_disposed) return; state = s;
```
`retry()` = `state = ChatWarmupIdle();``await start();`.
## 6. 에러 & 실패 모드
| 조건 | 처리 | 반환/예외 |
|------|------|-----------|
| `quickCheck` 가 DB lock 등으로 throw | `lifecycle.quickCheck` 내부 catch → `corrupt` 반환 | state = `Unavailable` (보수적) |
| `llm.load()``FileSystemException('model file missing')` | _messageFor 가 매핑 → "AI 모델 파일을 찾을 수 없어요. 설정에서 다시 다운로드해주세요." | state = `Failed` |
| `llm.load()` 가 기타 throw (native init 실패, OOM) | _messageFor → "AI 시작 중 오류가 발생했어요. 잠시 후 다시 시도해주세요." | state = `Failed` |
| start() 진행 중 dispose() | `_disposed = true``_safeSet` 가 no-op | state 변경 안 함 (마지막 set 유지) |
| concurrent start() 호출 | step 1 의 Loading 가드 — 외부에서는 retry() 만 사용하므로 정상 흐름에서 미발생 | early return |
## 7. 엣지케이스
- **ChatScreen 재진입 (이미 loaded)**: step 2 의 빠른 경로로 Loading 단계 skip → 라벨 깜빡임 없음.
- **start() 진행 중 ChatScreen pop → push (빠른 재진입)**: 첫 인스턴스 dispose, 두 번째 인스턴스의 start() 가 새로 호출. `_disposed` 가 인스턴스별이라 race 없음. `llm._loadingFuture` 가 native init 중복 차단.
- **opt-in 토글 race**: 사용자가 ChatScreen 진입과 동시에 SettingsScreen 에서 opt-out → ChatScreen 의 🤖 entry 가 hidden 되며 즉시 pop. dispose 가드로 안전.
## 8. 복잡도 / 성능
- 시간: O(1) + `lifecycle.quickCheck` O(1) (meta_kv 4 쿼리 + 1 stat) + `llm.load()` (수 초).
- 공간: state object 1개.
- 호출 빈도: ChatScreen mount 당 1회 (+retry 횟수).
## 9. 의존성
- `LlmService` (`load`, `isLoaded`) — 인터페이스 안정.
- `ModelLifecycle.quickCheck` (신규).
- `flutter_riverpod` `StateNotifier`.
## 10. 테스트 케이스
- [ ] 정상 happy: quickCheck=ready, load delay 100ms → 시퀀스 [Idle → Loading → Ready].
- [ ] 빠른 경로: isLoaded=true → 시퀀스 [Idle → Ready] (Loading 없음).
- [ ] unavailable: quickCheck=missing → 시퀀스 [Idle → Unavailable], load 호출 안 됨.
- [ ] failure: load throws FileSystemException → state = Failed + 매핑된 한국어 메시지.
- [ ] failure: load throws StateError → state = Failed + generic 메시지.
- [ ] retry: Failed → retry() → Loading → Ready.
- [ ] unmount race: start() 진행 중 dispose() → state 변경 시도 무시 (마지막 state = Loading 유지).
- [ ] DB 예외: quickCheck 가 throw → Unavailable.
## 11. 추적성
- 인수조건: AC1, AC2, AC3, AC4, AC5, AC6.
- 관련 ADR: 없음.