[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:
443
docs/design/215-gemma-frame-suggest/README.md
Normal file
443
docs/design/215-gemma-frame-suggest/README.md
Normal file
@@ -0,0 +1,443 @@
|
||||
# 설계서: Phase 2-A — Gemma 4 on-device 프레임 자동 생성 (#215)
|
||||
|
||||
> **상태**: Draft
|
||||
> **작성**: [AI] Architect · **최종수정**: 2026-06-12
|
||||
> **추적성** — Redmine: #215 · 관련 ADR: [ADR-0003 on-device LLM Gemma](../../adr/0003-on-device-llm-gemma.md)
|
||||
> · 구현 파일: `app/lib/data/ai/`, `app/lib/domain/ai/`, `app/lib/state/ai_providers.dart` (TBD by Developer)
|
||||
> · 테스트: `app/test/domain/ai/`, `app/test/data/ai/` (TBD by Developer)
|
||||
> **하위 문서**:
|
||||
> - [fn-suggest_frame.md](./fn-suggest_frame.md) — Gemma 4 호출 + few-shot 조립 + 응답 파싱
|
||||
> - [fn-model_lifecycle.md](./fn-model_lifecycle.md) — 모델 다운로드 / 로드 / 언로드 / 삭제 + LlmService 인터페이스
|
||||
|
||||
---
|
||||
|
||||
## 1. 목적 (Why)
|
||||
|
||||
> Planner 목표 (#215): "사용자가 입력한 L0/L1 raw text를 Gemma 4 E2B Q4_0이 L2/L3 프레임 후보로 변환해주는 vertical-slice 1개를 #204 위에 얹는다."
|
||||
|
||||
#204 에서 만든 `FramePattern` 30개 정적 카탈로그는 `keyword` 정확 매치 기반이라 한국어 구어체·동의어·신조어를 흡수하지 못한다. 결과적으로 "술 끊고 싶어" 같은 L0 입력에서 사용자가 직접 L2/L3 문장을 손으로 작성해야 한다 — Phase 1 의 최대 마찰점.
|
||||
|
||||
본 Phase 2-A 의 단일 과제는 **"on-device Gemma 4 E2B 가 사용자 raw text 를 입력받아, `FramePattern` 카탈로그를 few-shot 으로 참고하면서 L2/L3 후보 3개를 반환하는 단일 vertical slice 를 #204 위에 얹는 것"** 이다. 이 위에서 #2~#6 시나리오 (앵커 추출, dose variants 분해, if-then, lapse 구조화, 주간 요약) 는 동일한 `LlmService` 인터페이스에 함수만 추가하는 형태로 확장된다.
|
||||
|
||||
## 2. 범위 (Scope)
|
||||
|
||||
### 포함
|
||||
|
||||
- `flutter_gemma` (Gemma 4 E2B Q4_0, LiteRT-LM Android backend) 통합.
|
||||
- `LlmService` 추상 인터페이스 + `GemmaLlmService` 구현 (DI 친화).
|
||||
- `ModelLifecycle` — 모델 파일 가용성 확인 / 백그라운드 다운로드 (일시정지/재개 + 진행률) / 로드 / 언로드 / opt-out 시 즉시 삭제.
|
||||
- 단일 도메인 함수 `suggestFrame(rawText, habitType, anchorHint?) → List<FrameCandidate>`.
|
||||
- `buildFewShotPrompt` — `FramePattern` 카탈로그에서 habit type + raw text 키워드 매칭으로 few-shot 예시 N개 동적 추출.
|
||||
- `parseFrameCandidates` — function calling JSON → `FrameCandidate` 도메인 모델 변환 + `validateFrameLevel` 으로 L0/L1 자동 거부.
|
||||
- Riverpod providers: `aiSettingsProvider` (opt-in 토글), `modelAvailabilityProvider` (다운로드 상태), `frameSuggestionsProvider` (async).
|
||||
- UI: `HabitCreateScreen` 의 framed_text 입력란 옆 "AI 제안" 버튼 → `FrameSuggestionDialog` (후보 3개 중 1개 선택 → framed_text 채움).
|
||||
- `SettingsScreen` 의 "AI 도움" 섹션 (opt-in 토글 + 다운로드 진행률 + 사용 통계).
|
||||
- 한국어 prompt 프로토타입 평가 corpus 30 케이스 (test fixture).
|
||||
- AI 미가용 (opt-out / 다운로드 미완료 / 저사양 기기 / 모델 로드 실패) 시 graceful degradation — 기존 수동 framed_text 입력 경로 100% 유지.
|
||||
|
||||
### 제외 (out of scope)
|
||||
|
||||
- **시나리오 #2~#6** (앵커 추출, dose variants 분해, if-then, lapse 구조화, 주간 요약) — Phase 2-B+. 본 Phase 는 시나리오 #1 단일.
|
||||
- **iOS 빌드** — Phase 1 과 동일하게 Android 우선. `flutter_gemma` 의 iOS support 는 구조만 남기고 비활성.
|
||||
- **E4B 모델** — Phase 2-C 토글 (5GB RAM 요구, high-end 한정). v1 비활성 (ADR-0003 결정 #2).
|
||||
- **클라우드 LLM fallback** — 어떠한 형태로도 두지 않음 (ADR-0003 결정).
|
||||
- **파인튜닝 / LoRA** — v2+ 검토.
|
||||
- **멀티모달 (음성/이미지 입력)** — Phase 3+.
|
||||
- **모델 버전 교체 UI** (E2B v4.1 → v4.2 swap) — Phase 2-B+ 검토. v1 은 단일 모델 URL 하드코딩.
|
||||
- **시드 SoT 본문 (huberman protocols) 을 RAG 로 prompt 에 주입** — 본 Phase 는 `FramePattern` few-shot 만. RAG 는 Phase 2-C+.
|
||||
- **AI 사용 통계의 Drift 영속화** — 본 Phase 는 메모리 카운터만 (재시작 시 리셋).
|
||||
|
||||
## 3. 인수조건 (Acceptance Criteria)
|
||||
|
||||
> Planner 가 확정한 AC 10개. QA 가 이걸로 판정.
|
||||
|
||||
- [ ] **AC-1**: `flutter pub add flutter_gemma` 가 통과하고 `flutter analyze` 0 issue, `flutter build apk --debug` 성공.
|
||||
- [ ] **AC-2**: Settings 화면의 "AI 도움" 토글 기본 OFF. ON 으로 변경 시 동의 다이얼로그 (저장 공간 ≈ 1.5GB + WiFi 권장) 표시 후 백그라운드 다운로드 시작.
|
||||
- [ ] **AC-3**: 다운로드 중 일시정지 / 재개 / 취소 가능. 앱 강제 종료 후 재시작 시 다운로드 상태가 복원된다 (resume 지원).
|
||||
- [ ] **AC-4**: 다운로드 완료 후 `meta_kv['ai_model_path']` 에 절대 경로 저장, 무결성 체크 (SHA-256) 통과.
|
||||
- [ ] **AC-5**: AI 토글 ON + 모델 다운로드 완료 상태에서 `HabitCreateScreen` 의 framed_text 입력란 옆에 "AI 제안" 버튼이 표시된다. AI 토글 OFF 또는 모델 미로드 시 버튼 미표시 (기존 수동 입력 경로만).
|
||||
- [ ] **AC-6**: "술 끊고 싶어" (L0 입력) → "AI 제안" 탭 → 3 초 이내 (cold start) / 2 초 이내 (warm) 에 L2/L3 후보 3개가 다이얼로그에 표시된다. 1개 선택 시 framed_text 입력란이 자동으로 채워진다.
|
||||
- [ ] **AC-7**: 응답이 function calling JSON 으로 받아져 `FrameCandidate` 도메인 모델로 파싱된다. `validateFrameLevel` 통과한 후보만 노출 (L0/L1 응답은 자동 폐기 + 폐기 카운터 +1).
|
||||
- [ ] **AC-8**: opt-out 으로 AI 토글을 OFF 시 모델 파일이 즉시 삭제되고 (`File.delete`), `meta_kv['ai_model_path']` 가 비워진다. 사용자에게 "공간 확보됨 X.X GB" 토스트.
|
||||
- [ ] **AC-9**: 모델 로드 실패 / 저사양 기기 (RAM < 4GB 감지) / function calling timeout (10 초) 시 모든 경로가 graceful degradation — 사용자에게 "AI 제안 사용 불가, 직접 입력해주세요" 메시지 + 수동 입력 경로 차단 없음.
|
||||
- [ ] **AC-10**: 한국어 평가 corpus 30 케이스에서 ≥ 70% 가 (a) L2/L3 등급 + (b) `validateFrameLevel` 통과 + (c) 사용자 원본 의도와 의미적 일치 (수동 평가).
|
||||
|
||||
## 4. 컨텍스트 & 제약
|
||||
|
||||
### 의존성
|
||||
|
||||
- **#204 Phase 1 MVP** 완료 상태 위 (Drift 21 테이블, 6 도메인 함수, 4 UI 화면, `FramePattern` 카탈로그 30 시드, `validateFrameLevel` 함수 모두 존재 가정).
|
||||
- **`flutter_gemma`** — pub.dev 패키지. Gemma 4 + function calling + LiteRT-LM Android backend 지원. v1 은 latest 버전 고정 후 pubspec.lock 동결.
|
||||
- **Gemma 4 E2B Q4_0** — 단일 모델. 다운로드 URL 은 Google 공식 호스팅 (Kaggle / HuggingFace 의 `google/gemma-4-e2b-it-q4_0`). 정확한 URL 은 Developer 단계에서 `flutter_gemma` 문서 확인 후 확정.
|
||||
- **`path_provider`** — 모델 파일 저장 위치 (`getApplicationSupportDirectory()`).
|
||||
- **`dio` 또는 `http`** — 재개 가능 다운로드. `flutter_gemma` 가 자체 제공하면 그것 사용.
|
||||
- **`crypto`** — SHA-256 무결성 검증.
|
||||
- **NO 네트워크 의존 (추론)** — 다운로드 외 모든 추론은 단말 로컬.
|
||||
|
||||
### 제약
|
||||
|
||||
- **R8 (≤ 60초 체크인)** — habit 생성 흐름은 체크인이 아니지만, AI 제안도 사용자 인내 한계 ≤ 5초. cold start 3초 / warm 2초 / function calling timeout 10초 hard cap.
|
||||
- **메모리** — Gemma 4 E2B Q4_0 ≈ 1.5GB RAM 상주. 모델 로드는 사용자가 "AI 제안" 탭한 시점 lazy load. UI 종료 후 60초 idle → 언로드 (메모리 회수).
|
||||
- **저장 공간** — 모델 파일 ≈ 1.5GB. opt-out 시 즉시 삭제 보장 (사용자 신뢰 자산).
|
||||
- **배터리** — 추론 1회 당 0.5–1% 추정. throttle 정책: 같은 habit 생성 세션 내 "AI 제안" ≤ 5회 (5회 초과 시 "잠시 후 다시" 메시지).
|
||||
- **프라이버시** — 사용자 raw text 는 어떠한 형태로도 단말 밖으로 나가지 않음 (ADR-0003). 로그도 prompt 본문을 기록하지 않고 (length, latency, success/fail) 메타만.
|
||||
- **단일 모델 가정** — 멀티 모델 슬롯 없음. v1.1 에서 E4B 토글 추가 시 ADR-0004.
|
||||
|
||||
### 가정
|
||||
|
||||
- Android API 26+ (Phase 1 과 동일).
|
||||
- 다운로드 시점 WiFi 권장 (cellular 도 허용하되 경고 다이얼로그).
|
||||
- 사용자가 의식적으로 "AI 도움" 토글을 ON 했다 — 정보 (저장 공간, 배터리) 는 동의 다이얼로그에서 전달 완료 가정.
|
||||
- `FramePattern` 카탈로그 30개로 few-shot 매칭이 충분 (한국어 prototype 평가에서 검증).
|
||||
|
||||
## 5. 아키텍처 개요
|
||||
|
||||
### 디렉토리 구조 (추가분만)
|
||||
|
||||
```
|
||||
app/
|
||||
├── lib/
|
||||
│ ├── data/
|
||||
│ │ └── ai/ # 신규
|
||||
│ │ ├── llm_service.dart # abstract LlmService
|
||||
│ │ ├── gemma_llm_service.dart # flutter_gemma 구현체
|
||||
│ │ └── model_lifecycle.dart # 다운로드/로드/언로드/삭제
|
||||
│ ├── domain/
|
||||
│ │ └── ai/ # 신규
|
||||
│ │ ├── frame_candidate.dart # FrameCandidate 모델
|
||||
│ │ ├── suggest_frame.dart # 핵심 도메인 함수
|
||||
│ │ ├── few_shot_builder.dart # FramePattern → few-shot prompt
|
||||
│ │ └── parse_response.dart # function calling JSON → FrameCandidate[]
|
||||
│ ├── state/
|
||||
│ │ └── ai_providers.dart # Riverpod (Ai 가용성·설정·로드 상태)
|
||||
│ └── ui/
|
||||
│ ├── screens/
|
||||
│ │ ├── habit_create_screen.dart # 수정: "AI 제안" 버튼 추가
|
||||
│ │ └── settings_screen.dart # 신규: AI 토글 + 다운로드 진행률
|
||||
│ └── widgets/
|
||||
│ └── frame_suggestion_dialog.dart # 신규: 후보 3개 선택
|
||||
└── test/
|
||||
├── data/ai/
|
||||
│ ├── gemma_llm_service_test.dart # mock LlmService 동작
|
||||
│ └── model_lifecycle_test.dart
|
||||
├── domain/ai/
|
||||
│ ├── suggest_frame_test.dart # mock LlmService 주입
|
||||
│ ├── few_shot_builder_test.dart # FramePattern 시드 30개 입력
|
||||
│ └── parse_response_test.dart # malformed JSON 케이스
|
||||
└── fixtures/ai/
|
||||
└── prompt_eval_corpus.json # 30 케이스 한국어 평가 corpus
|
||||
```
|
||||
|
||||
### 데이터 흐름
|
||||
|
||||
```
|
||||
[App start]
|
||||
│
|
||||
▼
|
||||
[main.dart] ──► [AppDatabase 초기화 (기존)]
|
||||
│
|
||||
└─► [aiSettingsProvider 초기화 (meta_kv['ai_opt_in'])]
|
||||
│
|
||||
├─ opt-in 이면:
|
||||
│ └─► [modelAvailabilityProvider]
|
||||
│ │
|
||||
│ ├─ 모델 파일 존재 + 무결성 OK → Ready
|
||||
│ ├─ 다운로드 중 → resume
|
||||
│ └─ 미시작 → Idle (사용자 trigger 대기)
|
||||
│
|
||||
└─ opt-out 이면: Disabled (모델 미로드)
|
||||
│
|
||||
▼
|
||||
[사용자가 HabitCreateScreen 진입]
|
||||
│
|
||||
▼
|
||||
framed_text 입력란 옆 "AI 제안" 버튼 노출 여부 = aiAvailable && modelReady
|
||||
│
|
||||
▼ (사용자가 raw text "술 끊고 싶어" 입력 + AI 제안 탭)
|
||||
│
|
||||
▼
|
||||
[FrameSuggestionDialog]
|
||||
│
|
||||
▼
|
||||
[frameSuggestionsProvider(raw, habitType, anchorHint)]
|
||||
│
|
||||
├─ if !modelLoaded: [LlmService.load()] (cold start 1–3초)
|
||||
│
|
||||
▼
|
||||
[suggestFrame(raw, habitType, anchorHint)]
|
||||
│
|
||||
├─► [buildFewShotPrompt(raw, habitType)]
|
||||
│ └─ FramePattern 30 시드에서 raw text keyword 매칭 N개 추출
|
||||
│ └─ habit type + level 가이드라인 + few-shot 예시 + 출력 스키마
|
||||
│
|
||||
├─► [LlmService.generateStructured(prompt, schema)]
|
||||
│ └─ flutter_gemma function calling → JSON
|
||||
│
|
||||
├─► [parseFrameCandidates(json)]
|
||||
│ └─ FrameCandidate[] 변환
|
||||
│ └─ 각 후보에 validateFrameLevel 적용
|
||||
│ └─ L0/L1 응답 자동 폐기 + 카운터 +1
|
||||
│
|
||||
▼
|
||||
[FrameSuggestionDialog 후보 3개 표시]
|
||||
│
|
||||
▼ (사용자가 1개 탭)
|
||||
│
|
||||
▼
|
||||
[HabitCreateScreen.framedTextController.text = 선택값]
|
||||
│
|
||||
▼ (기존 #204 흐름 합류: 사용자가 저장 탭)
|
||||
│
|
||||
▼
|
||||
[validateFrameLevel] (기존 R3/R7 검사) ──► [HabitDao.insertWithVariants] (기존)
|
||||
```
|
||||
|
||||
### I/O ↔ 순수 로직 경계
|
||||
|
||||
- **I/O 계층** (`lib/data/ai/`): `flutter_gemma` 호출, 파일 다운로드, 디스크 read/write, SHA-256 검증.
|
||||
- **순수 도메인 로직** (`lib/domain/ai/`): `FramePattern[]` + raw text → prompt string (순수), JSON → `FrameCandidate[]` (순수), `validateFrameLevel` 적용 (이미 순수). **`flutter_gemma` import 금지**.
|
||||
- **DI 경계**: `LlmService` 는 추상. domain 함수는 `LlmService` 만 의존 → 테스트가 `MockLlmService` 로 가능 (네이티브 inference 없이).
|
||||
- **UI** (`lib/ui/`): Riverpod provider 통해서만 AI 상태/결과 받음. 비즈니스 결정 (어떤 후보를 추천? L0/L1 폐기?) 은 모두 domain 함수가 결정.
|
||||
|
||||
## 6. 데이터 모델
|
||||
|
||||
### 입력 (`SuggestFrameInput`)
|
||||
|
||||
```dart
|
||||
class SuggestFrameInput {
|
||||
final String rawText; // 사용자 자유 입력 (1~200자)
|
||||
final HabitType habitType; // build | break
|
||||
final String? anchorHint; // optional "아침 양치 후" 등
|
||||
}
|
||||
```
|
||||
|
||||
경계 검증:
|
||||
- `rawText.trim().length in [1, 200]`. 초과 시 reject (UI 단에서 truncate + 경고).
|
||||
- `rawText` NFC normalize.
|
||||
- `anchorHint` null OK.
|
||||
|
||||
### 출력 (`FrameCandidate`)
|
||||
|
||||
```dart
|
||||
class FrameCandidate {
|
||||
final FrameLevel level; // L2 | L3 (L0/L1 자동 폐기)
|
||||
final String framedText; // 모델이 생성한 한국어 텍스트
|
||||
final double confidence; // 0.0~1.0 (모델이 반환한 score, 없으면 0.5)
|
||||
final String? sourcePatternId; // few-shot 매칭에 쓰인 FramePattern.id (refer-back)
|
||||
}
|
||||
```
|
||||
|
||||
### Function calling 스키마 (모델에 전달)
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "emit_frame_candidates",
|
||||
"description": "Return 3 framed habit goal candidates at L2 or L3 level.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"candidates": {
|
||||
"type": "array",
|
||||
"minItems": 3,
|
||||
"maxItems": 3,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"level": { "type": "string", "enum": ["L2", "L3"] },
|
||||
"framed_text": { "type": "string", "maxLength": 120 },
|
||||
"confidence": { "type": "number", "minimum": 0, "maximum": 1 }
|
||||
},
|
||||
"required": ["level", "framed_text"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["candidates"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 저장 (`meta_kv` 키 추가, schema 변경 없음)
|
||||
|
||||
| key | value 타입 | 의미 |
|
||||
|-----|-----------|------|
|
||||
| `ai_opt_in` | `'true'` / `'false'` | AI 토글 상태 |
|
||||
| `ai_model_path` | 절대 경로 string | 모델 파일 위치 (다운로드 완료 시) |
|
||||
| `ai_model_sha256` | hex string | 다운로드 무결성 검증 결과 |
|
||||
| `ai_download_state` | `'idle'` / `'paused'` / `'downloading'` / `'completed'` | 다운로드 상태 |
|
||||
| `ai_download_bytes` | int as string | 진행률 복원용 |
|
||||
|
||||
> `meta_kv` 는 Phase 1 에서 이미 존재. schema 변경 0건 → migration 불필요.
|
||||
|
||||
## 7. 함수 명세 (Function Specs)
|
||||
|
||||
> 단순 = 본 표만으로 충분 / **복잡** = `fn-*.md` 별도 작성.
|
||||
|
||||
| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? |
|
||||
|------|-----------|----------------|------|------|-----------|-------|
|
||||
| `LlmService.generateStructured` | function calling JSON 응답 1회 받기 | `Future<Map<String,dynamic>> generateStructured(String prompt, Map schema)` | prompt, schema | parsed JSON | timeout 10s → TimeoutException; 모델 미로드 → StateError | **복잡** → [fn-model_lifecycle.md](./fn-model_lifecycle.md) §LlmService |
|
||||
| `GemmaLlmService.load` | 모델 파일 → 메모리 로드 | `Future<void> load()` | (none, path from lifecycle) | void | 파일 없음 → FileSystemException; OOM → 사용자 메시지 | **복잡** → [fn-model_lifecycle.md](./fn-model_lifecycle.md) |
|
||||
| `GemmaLlmService.unload` | 메모리에서 모델 해제 | `Future<void> unload()` | none | void | idempotent | 단순 |
|
||||
| `ModelLifecycle.checkAvailability` | 디스크 + 무결성 확인 | `Future<ModelAvailability> checkAvailability()` | none | enum {ready, missing, corrupt, downloading} | I/O 실패 → corrupt 취급 | **복잡** → [fn-model_lifecycle.md](./fn-model_lifecycle.md) |
|
||||
| `ModelLifecycle.download` | 백그라운드 다운로드 + 일시정지/재개 + SHA-256 검증 | `Stream<DownloadProgress> download()` | none | progress stream | 네트워크 실패 → resume, 무결성 실패 → 파일 삭제 후 재다운로드 | **복잡** → [fn-model_lifecycle.md](./fn-model_lifecycle.md) |
|
||||
| `ModelLifecycle.purge` | opt-out 시 모델 파일 + meta_kv 키 모두 삭제 | `Future<int> purge()` | none | 해제된 바이트 수 | 파일 미존재 → 0 반환 (idempotent) | 단순 |
|
||||
| `suggestFrame` | raw text → FrameCandidate[] (메인) | `Future<List<FrameCandidate>> suggestFrame(SuggestFrameInput)` | input | candidates (≤ 3) | LlmService 실패 시 빈 리스트 반환 + 호출자에 메시지 위임 | **복잡** → [fn-suggest_frame.md](./fn-suggest_frame.md) |
|
||||
| `buildFewShotPrompt` | FramePattern[] + raw text → prompt string | `String buildFewShotPrompt(SuggestFrameInput, List<FramePattern>)` | input, patterns | prompt | patterns 비어있음 → fallback 시스템 prompt | **복잡** → [fn-suggest_frame.md](./fn-suggest_frame.md) §buildFewShotPrompt |
|
||||
| `parseFrameCandidates` | JSON → FrameCandidate[] + L0/L1 폐기 | `List<FrameCandidate> parseFrameCandidates(Map json)` | json | candidates | malformed → throw FormatException | **복잡** → [fn-suggest_frame.md](./fn-suggest_frame.md) §parseFrameCandidates |
|
||||
| `validateFrameLevel` | (기존 #204) R3 + R7 검사 | (기존) | FrameInput | result | (기존) | 기존 함수 재사용 |
|
||||
| `aiSettingsProvider` | Riverpod: ai_opt_in 토글 상태 | `StateNotifierProvider<AiSettingsNotifier, AiSettings>` | none | settings | meta_kv read 실패 → opt_in=false fallback | 단순 |
|
||||
| `modelAvailabilityProvider` | Riverpod: 모델 가용성 | `FutureProvider<ModelAvailability>` | none | enum | I/O 실패 → corrupt | 단순 |
|
||||
| `frameSuggestionsProvider` | Riverpod: suggestFrame async wrap | `FutureProvider.family<List<FrameCandidate>, SuggestFrameInput>` | input | candidates | suggestFrame 의 예외 그대로 전파 | 단순 |
|
||||
| `FrameSuggestionDialog` | UI: 후보 3개 선택 다이얼로그 | `Widget` | input, onSelect cb | Widget | 로딩 중 spinner, 빈 리스트 시 "다시 시도" 버튼 | 단순 |
|
||||
| `AiSettingsSection` | UI: Settings 화면의 AI 섹션 | `Widget` | none | Widget | 다운로드 진행률 listen | 단순 |
|
||||
|
||||
> **복잡 함수 6개**: `LlmService.generateStructured` / `GemmaLlmService.load` / `ModelLifecycle.checkAvailability` / `ModelLifecycle.download` 는 [fn-model_lifecycle.md](./fn-model_lifecycle.md) 한 문서에서 묶어서 다룬다 (모두 모델 수명주기 + flutter_gemma I/O 경계의 한 덩어리). `suggestFrame` / `buildFewShotPrompt` / `parseFrameCandidates` 는 [fn-suggest_frame.md](./fn-suggest_frame.md) 에서 묶는다 (도메인 핵심 알고리즘).
|
||||
|
||||
## 8. 흐름 / 알고리즘
|
||||
|
||||
### 시나리오 A: AI 토글 ON + 첫 다운로드
|
||||
|
||||
1. 사용자가 `SettingsScreen` 의 "AI 도움" 토글 OFF → ON 으로 전환.
|
||||
2. UI: 동의 다이얼로그 — "Gemma 4 E2B 모델 ≈ 1.5GB 를 다운로드합니다. WiFi 권장. 진행률은 Settings 에서 확인할 수 있습니다. 끄면 즉시 삭제됩니다." [동의] / [취소].
|
||||
3. [동의] 탭 → `aiSettingsProvider` 가 `meta_kv['ai_opt_in'] = 'true'` 저장.
|
||||
4. `ModelLifecycle.download()` Stream 시작. 진행률 broadcast.
|
||||
5. UI: Settings 의 AI 섹션에 진행률 bar + 일시정지/재개/취소 버튼.
|
||||
6. 다운로드 완료 → SHA-256 검증 → `meta_kv['ai_model_path'] = <path>`, `meta_kv['ai_model_sha256'] = <hex>`, `meta_kv['ai_download_state'] = 'completed'`.
|
||||
7. `modelAvailabilityProvider` 가 `ready` 로 전환 → `HabitCreateScreen` 의 "AI 제안" 버튼 활성.
|
||||
|
||||
### 시나리오 B: 사용자가 habit 생성 중 "AI 제안" 탭
|
||||
|
||||
1. `HabitCreateScreen` 에서 사용자가 raw text "술 끊고 싶어" 입력.
|
||||
2. "AI 제안" 버튼 탭 → `FrameSuggestionDialog` 표시 (spinner).
|
||||
3. dialog 가 `frameSuggestionsProvider(input)` 구독.
|
||||
4. domain: `suggestFrame(input)` 호출.
|
||||
- 4-1. `LlmService` 가 미로드 상태면 `GemmaLlmService.load()` 트리거 (cold start 1–3초).
|
||||
- 4-2. `buildFewShotPrompt(input, framePatterns)`:
|
||||
- habit type = break + rawText keywords (`술`, `끊`, `금주`) 로 `FramePattern` 30개에서 매칭 ≥ 1 인 항목 상위 N=5 추출.
|
||||
- 시스템 prompt = "당신은 Huberman 프로토콜의 한국어 코치입니다. ..." (정적).
|
||||
- few-shot = 각 매칭 패턴의 `level_l0_example` / `level_l2_example` / `level_l3_example` 쌍을 예시로.
|
||||
- 사용자 입력 + 출력 스키마 안내.
|
||||
- 4-3. `LlmService.generateStructured(prompt, schema)` → function calling JSON.
|
||||
- 4-4. `parseFrameCandidates(json)`:
|
||||
- `candidates[]` length 검사.
|
||||
- 각 후보에 `validateFrameLevel({level, framed_text: framedText, original_text: rawText})` 적용.
|
||||
- `ok` 또는 `warn` 만 통과. `err` (L0/L1 또는 회피 키워드 hard) 자동 폐기.
|
||||
- 통과 ≥ 1 → 결과 반환. 모두 폐기 → 빈 리스트 + dev log 경고.
|
||||
5. `FrameSuggestionDialog` 가 후보 3개 (또는 그 이하) 카드로 표시. 빈 리스트면 "다시 시도" 버튼.
|
||||
6. 사용자가 카드 1개 탭 → `onSelect(candidate.framedText)` 콜백 → `HabitCreateScreen.framedTextController.text` 자동 채움.
|
||||
7. dialog 닫힘. 사용자는 기존 #204 흐름 (저장 버튼 → `validateFrameLevel` 재검증 → `HabitDao.insertWithVariants`) 으로 합류.
|
||||
8. dialog 닫힌 후 60초 idle 추적 → `GemmaLlmService.unload()` (메모리 회수).
|
||||
|
||||
### 시나리오 C: AI 토글 OFF (opt-out)
|
||||
|
||||
1. 사용자가 Settings 의 "AI 도움" 토글 ON → OFF.
|
||||
2. 확인 다이얼로그 — "모델 파일 ≈ 1.5GB 가 즉시 삭제됩니다. 다시 켜면 다시 다운로드됩니다." [확인] / [취소].
|
||||
3. [확인] 탭 → `ModelLifecycle.purge()` 호출.
|
||||
4. `unload()` → 파일 `delete()` → `meta_kv` 의 모든 `ai_*` 키 (opt_in 제외) clear.
|
||||
5. `meta_kv['ai_opt_in'] = 'false'`.
|
||||
6. 토스트: "공간 확보됨 X.X GB".
|
||||
7. `HabitCreateScreen` 의 "AI 제안" 버튼 숨김.
|
||||
|
||||
### 시나리오 D: 다운로드 중 앱 종료 / 재시작
|
||||
|
||||
1. 사용자가 다운로드 진행 중 앱 강제 종료.
|
||||
2. `meta_kv['ai_download_state'] = 'paused'`, `meta_kv['ai_download_bytes'] = <bytes>` 저장 상태.
|
||||
3. 앱 재시작 → `modelAvailabilityProvider` 초기화 시 `checkAvailability()` 호출.
|
||||
4. 상태 = `downloading` (paused) → 사용자에게 Settings 진입 시 "다운로드 재개" 버튼 표시.
|
||||
5. [재개] 탭 → `download()` Stream 이 HTTP `Range: bytes=<bytes>-` 로 resume.
|
||||
|
||||
## 9. 엣지케이스 & 에러 처리
|
||||
|
||||
| 상황 | 처리 | 비고 |
|
||||
|------|------|------|
|
||||
| 모델 다운로드 중 네트워크 끊김 | 자동 retry 3회 (exponential backoff) → 실패 시 `paused` 상태로 보존 | 사용자 수동 재개 |
|
||||
| 다운로드 완료 후 SHA-256 mismatch | 파일 삭제 + 사용자 메시지 "다운로드 손상, 재시도" + state 초기화 | rare, 대역폭 낭비 |
|
||||
| 모델 파일이 다른 앱/사용자에 의해 삭제 (외부) | `checkAvailability` 가 `missing` 반환 → "AI 제안" 버튼 숨김 + Settings 에 "재다운로드" 노출 | scoped storage 로 사실상 불가능하나 sd카드 외장 케이스 |
|
||||
| `flutter_gemma` 추론 timeout (10초 초과) | TimeoutException → 빈 리스트 반환 + dialog 에 "응답이 늦어집니다. 다시 시도" | UX 영향 큼, throttle 카운터 +1 |
|
||||
| function calling 응답이 malformed JSON | `FormatException` → 빈 리스트 반환 + dev log + 폐기 카운터 +1 | 모델 품질 문제 |
|
||||
| function calling 응답의 모든 후보가 L0/L1 | `parseFrameCandidates` 가 빈 리스트 반환 → dialog "더 구체적으로 입력해주세요" 메시지 | prompt 개선 필요 신호 |
|
||||
| 사용자 raw text > 200자 | UI 단에서 입력 차단 + 경고 | prompt 비용 + 한국어 token 폭증 방지 |
|
||||
| 모델 로드 실패 (OOM) | StateError → graceful: AI 비활성 + 사용자에게 "기기 메모리 부족으로 AI 사용 불가" 토스트 | Pixel 5 이하 / RAM 4GB 이하 |
|
||||
| 사용자가 dialog 닫고 1초 안에 다시 탭 | warm 상태 (모델 로드됨) → 0.5–2초 응답 | cache 없음, 매번 새 추론 |
|
||||
| 같은 세션 "AI 제안" ≥ 5회 | throttle → "잠시 후 다시 시도" | 배터리 보호 |
|
||||
| 추론 도중 앱이 background 전환 | flutter_gemma 가 native 측에서 isolated → 결과는 완성되면 broadcast | UX: spinner 유지 |
|
||||
|
||||
### 안전한 기본값
|
||||
|
||||
- AI 토글 기본 OFF — 사용자가 의식적 opt-in 했을 때만 동작.
|
||||
- 모든 AI 경로가 실패 시 빈 리스트 반환 + 호출자가 UI 메시지 결정. domain 함수는 throw 하지 않음 (graceful).
|
||||
- L0/L1 응답 자동 폐기 — 데이터 등급 보장.
|
||||
- 60초 idle 시 자동 unload — 메모리 회수 자동.
|
||||
|
||||
## 10. 테스트 계획
|
||||
|
||||
### 단위 테스트 (AC 매핑)
|
||||
|
||||
| AC | 테스트 | 위치 | 모킹 |
|
||||
|----|--------|------|------|
|
||||
| AC-1 | `flutter analyze` 통과 (CI) | scripts/ci | — |
|
||||
| AC-2 | `ai_settings_test.dart` — opt_in 토글 + meta_kv 영속 | test/state | mock meta_kv DAO |
|
||||
| AC-3 | `model_lifecycle_test.dart` — 다운로드 일시정지/재개/취소 + 앱 재시작 복원 | test/data/ai | mock HTTP server (range request) |
|
||||
| AC-4 | `model_lifecycle_test.dart` — SHA-256 검증 | test/data/ai | fixture file |
|
||||
| AC-5 | `habit_create_widget_test.dart` — 토글 ON/OFF 에 따른 버튼 노출 | test/ui | mock providers |
|
||||
| AC-6 | `suggest_frame_test.dart` — latency assert (mock LlmService 가 0ms 응답) | test/domain/ai | MockLlmService |
|
||||
| AC-7 | `parse_response_test.dart` — function calling JSON → FrameCandidate + L0/L1 폐기 | test/domain/ai | static JSON fixtures |
|
||||
| AC-8 | `model_lifecycle_test.dart` — purge() idempotent + meta_kv clear | test/data/ai | tmp file |
|
||||
| AC-9 | `suggest_frame_test.dart` — LlmService throw 시 빈 리스트 + 비차단 | test/domain/ai | MockLlmService throwing |
|
||||
| AC-10 | `prompt_eval_corpus.json` 30 케이스 평가 (manual or scripted scorer) | test/fixtures/ai + scripts/eval | real Gemma 4 (E2E, optional CI) |
|
||||
|
||||
### 모킹 / 드라이런
|
||||
|
||||
- **`LlmService` 추상화** — 모든 domain 테스트는 `MockLlmService` 주입. flutter_gemma 네이티브 미호출 → CI 에서 빠른 실행.
|
||||
- **HTTP mock** — 다운로드 테스트는 `httpServerTest` 로 local server + Range header 검증.
|
||||
- **fixture 모델 파일** — 1KB dummy + 알려진 SHA-256 으로 무결성 로직 검증 (실제 1.5GB 다운로드 X).
|
||||
- **한국어 평가 corpus** — `test/fixtures/ai/prompt_eval_corpus.json` 30 케이스: `{raw, habit_type, expected_keywords[], expected_levels[]}`. AC-10 의 ≥ 70% 통과 기준.
|
||||
- **Goldens** — UI 다이얼로그는 golden test 1개 (3 후보 카드 레이아웃).
|
||||
|
||||
## 11. 리스크 & 대안 검토
|
||||
|
||||
### 핵심 결정: on-device LLM 도입 자체
|
||||
|
||||
→ [ADR-0003 on-device LLM Gemma](../../adr/0003-on-device-llm-gemma.md) 에 전면 기록. 5가지 대안 (A 클라우드, C 정적 카탈로그 확장, D Llama/Phi, E E4B, F APK 번들) 모두 기각 사유 명시.
|
||||
|
||||
### 본 설계서 내 결정
|
||||
|
||||
| 결정 | 채택 | 대안 | 근거 |
|
||||
|------|------|------|------|
|
||||
| `LlmService` 추상화 | ✓ | flutter_gemma 직접 호출 | 테스트 가능성 + 향후 모델 swap 여지 |
|
||||
| Function calling | ✓ | 자유 텍스트 + 정규표현식 파싱 | 응답 신뢰도 + 코드 단순 (ADR-0003) |
|
||||
| few-shot 동적 추출 | ✓ | 정적 prompt 하드코딩 | SoT (`FramePattern`) 변경 시 동기화 부담 0 (ADR-0003) |
|
||||
| 60초 idle 시 자동 unload | ✓ | 항상 로드 유지 | 메모리 1.5GB 부담 — RAM 6GB 기기 멀티태스킹 |
|
||||
| throttle 5회/세션 | ✓ | 무제한 | 배터리 보호 (1회당 0.5–1%) |
|
||||
| AC-10 ≥ 70% threshold | ✓ | ≥ 90% | v1 현실적 baseline. v1.1 에서 prompt 개선으로 상승 목표 |
|
||||
| 단일 모델 (E2B) | ✓ | E2B/E4B 듀얼 | hardware fragmentation 단순화 (ADR-0003 결정 #2) |
|
||||
|
||||
### 되돌리기 어려운 결정 → ADR 후보
|
||||
|
||||
- on-device LLM 자체 = **ADR-0003 발행 완료**.
|
||||
- E4B 토글 추가 = **ADR-0004 후보** (Phase 2-C).
|
||||
- 시나리오 #2~#6 함수 확장 = **ADR-0005 후보** (Phase 2-B+).
|
||||
|
||||
## 12. 미해결 질문 (Open Questions)
|
||||
|
||||
| OQ | 질문 | 결정 시점 | 비고 |
|
||||
|----|------|----------|------|
|
||||
| **OQ-1** | Gemma 4 E2B Q4_0 의 정확한 다운로드 URL? | Developer 단계 (flutter_gemma 문서 확인) | pubspec.lock 동결 시 함께 고정 |
|
||||
| **OQ-2** | 한국어 평가 corpus 30 케이스의 expected_levels 라벨링 기준? | QA 단계 직전 | Architect 가 sample 5개 작성 후 사용자 (joungmin) 확정 |
|
||||
| **OQ-3** | 다운로드 시 모바일 데이터 허용 — 기본 OFF + 사용자 토글? 아니면 매번 confirm? | Developer 단계 (UX 결정) | Settings 의 "WiFi 전용" 토글 추가 검토 |
|
||||
| **OQ-4** | function calling timeout 10초가 적절? | QA 단계 (실측) | 저사양 기기에서 cold start 5초+ 가능성 |
|
||||
| **OQ-5** | 폐기 카운터 (`ai_l0l1_discard_count` 등) 를 `meta_kv` 에 영속? | v1.1 검토 | v1 은 메모리만 |
|
||||
| **OQ-6** | 사용자가 후보 3개 모두 마음에 안 들 때 "다시 생성" 버튼 — 같은 prompt 로 재요청 vs prompt 변형? | Developer 단계 | Temperature 변경 옵션? Gemma 4 의 sampling 파라미터 노출 여부 확인 필요 |
|
||||
|
||||
---
|
||||
|
||||
## 부록: 자가 점검 (Architect 가 작업 종료 시 검증)
|
||||
|
||||
- [x] `_TEMPLATE.md` 12 개 섹션 모두 비어있지 않음
|
||||
- [x] ADR-0003 cross-link 완료 (§5 §11 §부록)
|
||||
- [x] 복잡 함수 7개 → 2개 fn-*.md 로 묶어서 다룸 (suggest_frame 3개 + model_lifecycle 4개)
|
||||
- [x] §7 함수 명세 표에 모든 함수 등재 (단순 8 + 복잡 7)
|
||||
- [x] AC §3 — Planner 가 정한 10개 AC 모두 검증 가능한 형태로 §10 테스트와 1:1 매핑
|
||||
- [x] graceful degradation 명시: 모든 AI 경로 실패 시 수동 입력 차단 X (AC-9, §9 안전한 기본값)
|
||||
- [x] 프라이버시 원칙 명시: raw text 단말 밖 송출 0 (§4 제약, §9 log 메타만)
|
||||
- [x] §12 OQ 6개 명시 + 결정 시점 + 책임자
|
||||
- [x] #204 와의 통합점 명확: `FramePattern` 30 시드 + `validateFrameLevel` 재사용 (§5 §8 시나리오 B)
|
||||
Reference in New Issue
Block a user