Files
life-helper/docs/design/215-gemma-frame-suggest/README.md
joungmin d31b17f3e8 [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
2026-06-12 11:16:15 +09:00

444 lines
30 KiB
Markdown
Raw 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.
# 설계서: 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.51% 추정. 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 13초)
[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 13초).
- 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.52초 응답 | 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.51%) |
| 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)