[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:
105
docs/adr/0003-on-device-llm-gemma.md
Normal file
105
docs/adr/0003-on-device-llm-gemma.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# ADR-0003: On-device LLM 도입 — Gemma 4 E2B Q4_0 + `flutter_gemma`
|
||||
|
||||
> **상태**: Accepted
|
||||
> **날짜**: 2026-06-12 · **결정자**: [AI] Architect (사용자 Planner 채택안 수용) · **관련 이슈**: #215
|
||||
> **관련 ADR**: 없음 (신규 결정)
|
||||
|
||||
## 맥락 (Context)
|
||||
|
||||
life-helper Phase 1 MVP(#204) 위에 사용자 입력 자동화 기능을 얹는다. 입력 마찰점은
|
||||
1) L0/L1 raw text → L2/L3 프레임 재작성, 2) 자유서술 → 구조화 변환이다.
|
||||
기존 `FramePattern` 30개 정적 카탈로그는 keyword exact-match 기반이라
|
||||
사용자 입력 다양성(synonym, 한국어 구어체, 도메인 변형)을 흡수하지 못한다.
|
||||
|
||||
선택지는:
|
||||
- **A. 클라우드 LLM API** (OpenAI / Claude / Gemini) — 품질 ↑, 비용·프라이버시·오프라인 X.
|
||||
- **B. On-device LLM** (Gemma 4 / Llama / Phi 등) — 프라이버시·오프라인 ↑, 품질·기기 부담.
|
||||
- **C. 도입 X, 정적 카탈로그 확장** — 안전하지만 동적 입력 흡수 한계.
|
||||
|
||||
Planner(#215)가 5개 의사결정으로 **B 채택안 + Gemma 4 E2B + 런타임 다운로드 + opt-in**
|
||||
방향을 확정했다. 본 ADR은 그 선택을 되돌리기 어려운 기술 결정으로 고정한다.
|
||||
|
||||
## 결정 (Decision)
|
||||
|
||||
life-helper에 **on-device LLM**을 도입한다. 모델은 **Gemma 4 E2B (Q4_0 양자화)** 단일.
|
||||
런타임 통합은 **`flutter_gemma`** 패키지(LiteRT-LM Android backend) 사용.
|
||||
모델 파일은 **opt-in 토글 후 런타임에 백그라운드 다운로드**, opt-out 시 즉시 삭제.
|
||||
모든 추론은 **on-device 전용**이며 어떠한 형태의 클라우드 fallback도 두지 않는다.
|
||||
|
||||
함수 호출(function calling) 기능을 사용해 LLM 응답을 **구조화된 JSON**으로 받는다.
|
||||
프롬프트는 정적 하드코딩이 아닌 **SoT 카탈로그(`FramePattern` / `Protocol`)에서
|
||||
런타임에 few-shot 예시를 동적 추출**해 조립한다.
|
||||
|
||||
## 근거 (Rationale)
|
||||
|
||||
- **프라이버시 = SoT 원칙과 일치**: life-helper는 개인 습관·중독·실패 기록을 다룬다.
|
||||
사용자 자유서술이 외부로 떠나면 안 된다. on-device 전용은 비타협 요구.
|
||||
- **오프라인 동작**: 사용자는 새벽·이동 중·산행 등 네트워크 약한 환경에서도 체크인한다.
|
||||
클라우드 의존은 R8(≤ 60초 체크인) 보장 불가.
|
||||
- **Gemma 4 E2B Q4_0 ≈ 1.5GB RAM**: 6GB RAM mid-range Android 까지 커버.
|
||||
E4B(5GB)는 high-end 한정. v1은 E2B 단일로 시작해 hardware fragmentation 단순화.
|
||||
- **`flutter_gemma`의 function calling**: LLM 응답을 자유 텍스트가 아닌 구조화 JSON으로
|
||||
강제 → 파싱 신뢰도 ↑, 도메인 모델(`FrameCandidate`) 직결.
|
||||
- **SoT few-shot 동적 추출**: 정적 prompt 하드코딩은 SoT(`huberman-protocols.md` /
|
||||
`FramePattern` 카탈로그) 변경 시 동기화 부담 발생. 런타임 조립은 SoT 1회 갱신으로 전파.
|
||||
- **런타임 다운로드**: APK +1.5GB는 첫 진입 장벽 과대. Play Store 제한(150MB)도 위반.
|
||||
WiFi 권장 + 일시정지/재개 + 진행률 UX로 흡수.
|
||||
- **Opt-in 기본 OFF**: AI 기능을 "추가 비용(스토리지·배터리)을 감내한 사용자만"이
|
||||
켠다는 명시적 동의로, R6 사용자 통제권 원칙과 일치.
|
||||
|
||||
## 결과 (Consequences)
|
||||
|
||||
- **긍정**:
|
||||
- 클라우드 비용·레이트리밋·약관 변경 리스크 0.
|
||||
- 사용자 자유서술이 절대 단말 밖으로 나가지 않음 → 신뢰 자산.
|
||||
- 오프라인 100% 동작.
|
||||
- `flutter_gemma`가 Android(LiteRT-LM) + iOS 둘 다 지원 → Phase 2 iOS 진입 시 재사용.
|
||||
- Function calling 응답이 구조화 → 도메인 모델 직접 매핑, 파싱 코드 최소.
|
||||
- SoT 카탈로그 갱신이 LLM 출력 품질에 즉시 반영 (정적 prompt 동기화 부담 X).
|
||||
|
||||
- **부정 / 비용**:
|
||||
- 모델 파일 1.5GB 다운로드 — UX 부담. 일시정지/재개 + 진행률 + WiFi 권장 필수.
|
||||
- 첫 추론 latency 1–3초 (모델 로드 cold start). 이후 0.5–2초/응답.
|
||||
- Pixel 5 이하·RAM 4GB 이하 기기는 로드 실패 가능 → 사전 사양 체크 필요.
|
||||
- 배터리 0.5–1% / 추론 추정. 빈도 제한(throttle) 정책 필요.
|
||||
- `flutter_gemma` 패키지 semver 변경 위험 — v1은 latest version 고정 후 pubspec.lock 동결.
|
||||
- 한국어 품질 미검증 — Architect 단계 prototype 통과가 게이트 조건.
|
||||
|
||||
- **후속 작업**:
|
||||
- Architect: 설계서 `docs/design/215-gemma-frame-suggest/` 작성 (본 ADR과 한 쌍).
|
||||
- Architect: 한국어 prompt 프로토타입 통과 확인 (Open Question).
|
||||
- Developer: `flutter_gemma` 의존성 추가, `LlmService` 인터페이스 + `GemmaLlmService` 구현.
|
||||
- QA: 한국어 출력 품질 평가 corpus 30 케이스, 저사양 기기(RAM 4/6/8GB) 회귀.
|
||||
- Documenter: 사용자 가이드에 "AI 도움" 섹션 추가 (오프라인·프라이버시 강조).
|
||||
|
||||
## 검토한 대안 (Alternatives Considered)
|
||||
|
||||
- **A. 클라우드 LLM API (OpenAI / Claude / Gemini)**
|
||||
- 기각 사유: (1) 사용자 자유서술 외부 전송 → SoT 프라이버시 원칙 위반.
|
||||
(2) 오프라인 미지원 → R8(≤ 60초 체크인) 보장 불가.
|
||||
(3) API 비용·레이트리밋·약관 변경 리스크.
|
||||
(4) Vendor lock-in.
|
||||
|
||||
- **C. 도입 X, 정적 카탈로그 확장 (FramePattern 30 → 100 → 300...)**
|
||||
- 기각 사유: (1) 동적 입력(synonym, 구어체) 흡수 한계는 카탈로그 크기로 해결 불가.
|
||||
(2) 한국어 변형 폭이 너무 넓어 enumeration 비현실적.
|
||||
(3) Architect/Developer 유지보수 부담이 LLM 도입보다 큼 (역설).
|
||||
|
||||
- **D. Llama 3.2 1B / Phi-3.5 Mini**
|
||||
- 기각 사유: (1) Gemma 4 E2B가 한국어 성능 더 좋다는 Google 자체 벤치 + 한국 커뮤니티 보고.
|
||||
(2) `flutter_gemma`가 first-class 지원 — Llama용 동급 Flutter 패키지 부재.
|
||||
(3) Google이 LiteRT-LM Android 최적화에 직접 투자.
|
||||
|
||||
- **E. E4B (4B parameter)**
|
||||
- 기각 사유: 5GB RAM 요구 → high-end Pixel 8+ / Galaxy S24+ 한정.
|
||||
Phase 2-A는 mid-range까지 커버가 우선. E4B는 v1.1 이상에서 토글 옵션으로 추가.
|
||||
|
||||
- **F. APK 번들 모델**
|
||||
- 기각 사유: (1) Play Store APK 제한 150MB 위반. AAB로 회피 시도 가능하나 download size 절감 효과 미미.
|
||||
(2) AI 미사용자에게도 1.5GB 강제 → 옵트인 원칙 위반.
|
||||
|
||||
## 추적성
|
||||
|
||||
- 설계서: `docs/design/215-gemma-frame-suggest/README.md` + `fn-*.md`.
|
||||
- 의존: #204 Phase 1 MVP의 `FramePattern` 30 시드 + `validate_frame_level` 함수.
|
||||
- 후속 결정 가능성: ADR-0004 (E4B 토글), ADR-0005 (시나리오 #2~#6 함수 확장).
|
||||
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)
|
||||
381
docs/design/215-gemma-frame-suggest/fn-model_lifecycle.md
Normal file
381
docs/design/215-gemma-frame-suggest/fn-model_lifecycle.md
Normal file
@@ -0,0 +1,381 @@
|
||||
# 함수 설계서: `ModelLifecycle` + `LlmService` (#215)
|
||||
|
||||
> **부모 설계서**: ./README.md · **상태**: Draft
|
||||
> **작성**: [AI] Architect · **구현**: `app/lib/data/ai/model_lifecycle.dart`, `llm_service.dart`, `gemma_llm_service.dart` (TBD) · **테스트**: `app/test/data/ai/{model_lifecycle,gemma_llm_service}_test.dart` (TBD)
|
||||
|
||||
> 본 문서는 모델 수명주기와 추론 인터페이스를 묶어 다룬다. 모두 I/O 경계 — flutter_gemma + 파일시스템 + 네트워크 + 네이티브 메모리.
|
||||
|
||||
---
|
||||
|
||||
## §A. `LlmService` (추상 인터페이스)
|
||||
|
||||
### 1. 시그니처
|
||||
|
||||
```dart
|
||||
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` → throw `StateError("not loaded")`.
|
||||
- `generateStructured` 응답은 schema 의 `parameters` 와 일치 — 위반 시 throw `FormatException`.
|
||||
- `unload` 는 idempotent. `!isLoaded` 상태에서 호출해도 noop.
|
||||
- timeout 은 호출자가 `.timeout(...)` 로 부여. 본 인터페이스는 별도 타임아웃 처리 X.
|
||||
|
||||
### 6. 추적성
|
||||
|
||||
- 인수조건: #215 AC-6 (응답), AC-9 (graceful — throw 종류 명세).
|
||||
- 관련 ADR: ADR-0003.
|
||||
|
||||
---
|
||||
|
||||
## §B. `GemmaLlmService` (flutter_gemma 구현체)
|
||||
|
||||
### 1. 시그니처
|
||||
|
||||
```dart
|
||||
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 = true` flag 로 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. 시그니처
|
||||
|
||||
```dart
|
||||
Future<ModelAvailability> checkAvailability();
|
||||
|
||||
enum ModelAvailability { ready, missing, corrupt, downloading }
|
||||
```
|
||||
|
||||
### 2. 책임 (1줄)
|
||||
|
||||
디스크의 모델 파일 존재 + SHA-256 무결성 + meta_kv 상태를 종합해 가용성을 결정한다.
|
||||
|
||||
### 3. 입력 / 출력
|
||||
|
||||
- 입력: none (내부에서 `MetaKvDao` + `path_provider` 사용).
|
||||
- 출력: `ModelAvailability` enum.
|
||||
|
||||
### 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. 시그니처
|
||||
|
||||
```dart
|
||||
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 보장).
|
||||
335
docs/design/215-gemma-frame-suggest/fn-suggest_frame.md
Normal file
335
docs/design/215-gemma-frame-suggest/fn-suggest_frame.md
Normal file
@@ -0,0 +1,335 @@
|
||||
# 함수 설계서: `suggestFrame` + `buildFewShotPrompt` + `parseFrameCandidates` (#215)
|
||||
|
||||
> **부모 설계서**: ./README.md · **상태**: Draft
|
||||
> **작성**: [AI] Architect · **구현**: `app/lib/domain/ai/suggest_frame.dart`, `few_shot_builder.dart`, `parse_response.dart` (TBD) · **테스트**: `app/test/domain/ai/{suggest_frame,few_shot_builder,parse_response}_test.dart` (TBD)
|
||||
|
||||
> 본 문서는 도메인 핵심 알고리즘 함수 3개를 묶어 다룬다. 셋 다 순수 함수 (또는 LlmService 만 의존) 로 테스트 가능성을 우선한다.
|
||||
|
||||
---
|
||||
|
||||
## §A. `suggestFrame` (메인)
|
||||
|
||||
### 1. 시그니처
|
||||
|
||||
```dart
|
||||
Future<List<FrameCandidate>> suggestFrame(
|
||||
SuggestFrameInput input, {
|
||||
required LlmService llm,
|
||||
required List<FramePattern> framePatterns,
|
||||
FrameValidator validator = const FrameValidator(),
|
||||
Duration timeout = const Duration(seconds: 10),
|
||||
});
|
||||
```
|
||||
|
||||
### 2. 책임 (1줄)
|
||||
|
||||
raw text 를 Gemma 4 에 보내 L2/L3 프레임 후보 ≤ 3 개를 받아 반환한다. L0/L1 응답은 자동 폐기.
|
||||
|
||||
### 3. 입력
|
||||
|
||||
| 파라미터 | 타입 | 제약/검증 | 설명 |
|
||||
|----------|------|-----------|------|
|
||||
| `input.rawText` | String | 1 ≤ length ≤ 200, NFC normalize | 사용자 자유 입력 |
|
||||
| `input.habitType` | enum {build, break} | 필수 | few-shot 매칭 방향 결정 |
|
||||
| `input.anchorHint` | String? | nullable | optional "아침 양치 후" 등 |
|
||||
| `llm` | `LlmService` | 추상 인터페이스 (DI) | flutter_gemma 구현체 또는 mock |
|
||||
| `framePatterns` | `List<FramePattern>` | #204 시드 30개 | few-shot 동적 추출 소스 |
|
||||
| `validator` | `FrameValidator` | 기본값 OK | `validateFrameLevel` 의 래퍼 |
|
||||
| `timeout` | Duration | 1~30s | LlmService.generateStructured 의 타임아웃 |
|
||||
|
||||
### 4. 출력
|
||||
|
||||
- **반환**: `List<FrameCandidate>` — 길이 0~3.
|
||||
- **부수효과**: 없음 (순수). LlmService 호출은 인자로 받은 의존성을 통해서만.
|
||||
- **graceful**: 실패 시 throw 하지 않고 빈 리스트 반환. 호출자 (UI provider) 가 메시지 결정.
|
||||
|
||||
### 5. 동작 / 알고리즘
|
||||
|
||||
```
|
||||
1. input 경계 검증
|
||||
- rawText.trim().length in [1, 200] 아니면 → return [] (호출자에 위임)
|
||||
- NFC normalize 이미 안 되어 있으면 적용
|
||||
|
||||
2. prompt = buildFewShotPrompt(input, framePatterns)
|
||||
- §B 참조
|
||||
|
||||
3. JSON schema = FrameCandidate function calling schema (README §6)
|
||||
|
||||
4. try:
|
||||
json = await llm.generateStructured(prompt, schema).timeout(timeout)
|
||||
catch TimeoutException, StateError, FormatException, Exception:
|
||||
log meta (latency, error type) — NO prompt body
|
||||
return []
|
||||
|
||||
5. candidates = parseFrameCandidates(json) — §C 참조
|
||||
|
||||
6. validated = candidates.where((c) {
|
||||
final result = validator.validate(FrameInput(
|
||||
level: c.level,
|
||||
framedText: c.framedText,
|
||||
originalText: input.rawText,
|
||||
));
|
||||
return result.status != FrameStatus.error; // L0/L1 또는 hard avoid → 폐기
|
||||
}).toList()
|
||||
|
||||
7. return validated.take(3).toList()
|
||||
```
|
||||
|
||||
### 6. 에러 & 실패 모드
|
||||
|
||||
| 조건 | 처리 | 반환 |
|
||||
|------|------|------|
|
||||
| `rawText` 빈/200자 초과 | 즉시 반환 | `[]` |
|
||||
| LlmService timeout | catch → log latency, `error_type=timeout` | `[]` |
|
||||
| LlmService throw StateError (모델 미로드) | catch → log | `[]` |
|
||||
| 응답이 malformed JSON | `parseFrameCandidates` 가 FormatException → catch | `[]` |
|
||||
| 모든 후보가 L0/L1 또는 hard avoid | 정상 흐름. validated 빈 리스트 | `[]` |
|
||||
| validator 자체 throw | 비정상. catch → log + skip 후보 | 부분 리스트 |
|
||||
|
||||
> 모든 예외를 catch — 도메인 함수는 throw 하지 않음 (graceful). 호출자가 빈 리스트 시 UI 메시지 결정.
|
||||
|
||||
### 7. 엣지케이스
|
||||
|
||||
- `rawText = " "` (whitespace) → trim 후 length=0 → `[]`.
|
||||
- `rawText` 가 코드 / 이모지 / 영어만 → prompt 에 그대로 들어가 모델이 한국어 응답 시도. 결과 품질 낮을 가능성 — AC-10 평가 대상.
|
||||
- `framePatterns = []` (시드 미로드) → `buildFewShotPrompt` 가 fallback 시스템 prompt 만 사용 — quality 저하 경고.
|
||||
- `habitType = break` + raw text 가 build 패턴에 가까움 → few-shot 매칭이 약함. 모델이 break 방향으로 frame 시도.
|
||||
- LlmService 가 같은 호출에 다른 응답 — 정상. cache 없음.
|
||||
|
||||
### 8. 복잡도 / 성능
|
||||
|
||||
- **호출 빈도**: 사용자가 "AI 제안" 탭한 시점만. throttle 5회/세션.
|
||||
- **시간**: cold start 1–3초 (모델 로드 포함), warm 0.5–2초. 본 함수 자체 (LlmService 호출 제외) 는 O(N) — N = framePatterns 길이 = 30. 사실상 < 5ms.
|
||||
- **공간**: prompt string ≈ 2–4KB. JSON response ≈ 1KB.
|
||||
|
||||
### 9. 의존성
|
||||
|
||||
- 호출 함수: `buildFewShotPrompt` (§B), `parseFrameCandidates` (§C), `validator.validate` (= `validateFrameLevel` 래퍼, #204).
|
||||
- 외부 API: `LlmService.generateStructured` (인터페이스, 구현체 = `GemmaLlmService`).
|
||||
- 모델: `FramePattern` (#204 카탈로그), `FrameCandidate` (도메인), `SuggestFrameInput` (도메인).
|
||||
|
||||
### 10. 테스트 케이스
|
||||
|
||||
- [ ] **정상**: rawText="술 끊고 싶어", habitType=break, mock LlmService 가 valid JSON 3개 반환 → `result.length == 3`, 모두 L2/L3.
|
||||
- [ ] **L0/L1 폐기**: mock 응답에 L1 1개 + L2 2개 → `result.length == 2`.
|
||||
- [ ] **timeout**: mock LlmService 가 Future.delayed(15s) → timeout 10s → `[]`.
|
||||
- [ ] **malformed JSON**: mock 응답 `{"foo": "bar"}` → `parseFrameCandidates` throw → catch → `[]`.
|
||||
- [ ] **빈 rawText**: `rawText: " "` → LlmService 미호출, `[]`.
|
||||
- [ ] **rawText > 200자**: 201자 입력 → `[]`.
|
||||
- [ ] **framePatterns 비어있음**: → LlmService 호출은 하되 prompt 가 fallback. mock 으로 응답 시 정상 동작 보장.
|
||||
- [ ] **LlmService throw StateError** (모델 미로드): catch → `[]`.
|
||||
- [ ] **non-blocking 보장**: 어떤 예외 케이스에서도 throw 하지 않음 (assert no exception thrown).
|
||||
|
||||
### 11. 추적성
|
||||
|
||||
- 인수조건: #215 AC-6, AC-7, AC-9 (graceful).
|
||||
- 관련 ADR: ADR-0003 (on-device LLM + function calling + few-shot 동적 추출).
|
||||
|
||||
---
|
||||
|
||||
## §B. `buildFewShotPrompt`
|
||||
|
||||
### 1. 시그니처
|
||||
|
||||
```dart
|
||||
String buildFewShotPrompt(
|
||||
SuggestFrameInput input,
|
||||
List<FramePattern> framePatterns, {
|
||||
int maxFewShot = 5,
|
||||
});
|
||||
```
|
||||
|
||||
### 2. 책임 (1줄)
|
||||
|
||||
`FramePattern` 카탈로그에서 raw text + habit type 키워드 매칭 상위 N개를 추출해 system + few-shot + user 섹션으로 구성된 prompt string 을 반환한다.
|
||||
|
||||
### 3. 입력
|
||||
|
||||
| 파라미터 | 타입 | 제약/검증 | 설명 |
|
||||
|----------|------|-----------|------|
|
||||
| `input` | `SuggestFrameInput` | 검증된 입력 | rawText, habitType, anchorHint |
|
||||
| `framePatterns` | `List<FramePattern>` | 시드 30 가정 | matching pool |
|
||||
| `maxFewShot` | int | 1~10 | top-N few-shot 갯수 |
|
||||
|
||||
### 4. 출력
|
||||
|
||||
- **반환**: prompt string (≈ 2–4KB).
|
||||
- **부수효과**: 없음 (순수). 입력 인자만으로 결과 결정.
|
||||
|
||||
### 5. 동작 / 알고리즘
|
||||
|
||||
```
|
||||
1. tokens = rawText 의 단어 토큰화 (whitespace + 한국어 형태소 lite)
|
||||
- 형태소 분석기 비도입. 정규식 split + 길이 ≥ 2 한국어 substring 만 남김
|
||||
|
||||
2. scored = framePatterns
|
||||
.where((p) => p.habitType == input.habitType || p.habitType == null)
|
||||
.map((p) => MapEntry(p, scoreMatch(tokens, p.keywords)))
|
||||
.where((e) => e.value > 0)
|
||||
.toList()
|
||||
..sort((a, b) => b.value - a.value)
|
||||
|
||||
3. selected = scored.take(maxFewShot).toList()
|
||||
- scored 빈 리스트면 framePatterns 중 임의 3개 fallback (habit_type 만 일치)
|
||||
|
||||
4. prompt 조립:
|
||||
<SYSTEM>
|
||||
당신은 Huberman 프로토콜 한국어 코치입니다. 사용자의 raw text 를
|
||||
L2 (조건부 긍정) 또는 L3 (정체성) 프레임의 한국어 문장으로 변환합니다.
|
||||
- L2 예: "스트레스 받을 때 책 한 페이지를 펼친다"
|
||||
- L3 예: "나는 글을 읽는 사람이다"
|
||||
- L0/L1 (회피/부정) 금지: "안", "끊다", "그만두다"
|
||||
- 응답은 반드시 함수 호출 emit_frame_candidates(candidates: [...]) 로.
|
||||
|
||||
<FEW_SHOT>
|
||||
for p in selected:
|
||||
# 예시 {n}: {p.title}
|
||||
L0: {p.level_l0_example}
|
||||
L2: {p.level_l2_example}
|
||||
L3: {p.level_l3_example}
|
||||
|
||||
<USER>
|
||||
habit_type: {input.habitType}
|
||||
raw_text: "{input.rawText}"
|
||||
anchor_hint: {input.anchorHint ?? "없음"}
|
||||
|
||||
위 raw_text 를 L2/L3 후보 3개로 변환하세요.
|
||||
|
||||
5. return prompt
|
||||
```
|
||||
|
||||
> `scoreMatch(tokens, keywords)` = 두 리스트 교집합 크기 + 한국어 substring 부분 매칭 보정. 정확한 점수 공식은 구현 시 단순한 set intersection 으로 시작 — 평가 후 보강.
|
||||
|
||||
### 6. 에러 & 실패 모드
|
||||
|
||||
| 조건 | 처리 | 반환 |
|
||||
|------|------|------|
|
||||
| framePatterns 비어있음 | fallback: system + user 만 (few-shot 섹션 생략) | prompt 단축본 |
|
||||
| rawText 비어있음 | 호출자 (`suggestFrame`) 가 사전 검증. 본 함수는 어떻게든 prompt 반환 | empty user_input prompt |
|
||||
| keyword 매칭 0개 | 임의 3개 fallback (habitType 일치 기준) | 정상 prompt |
|
||||
|
||||
### 7. 엣지케이스
|
||||
|
||||
- 한국어 형태소 분석기 없음 → keyword 매칭 false negative 다수. v1 baseline. v1.1 에서 `mecab-ko` 도입 검토.
|
||||
- 같은 raw text 가 두 번 들어와도 결정론적 → cache 없음, 매번 같은 prompt 생성.
|
||||
- `anchorHint` 길이 폭주 (사용자가 100자 입력) → prompt 비대화. UI 단에서 ≤ 50자 제한.
|
||||
|
||||
### 8. 복잡도 / 성능
|
||||
|
||||
- O(N × M) — N = framePatterns 길이 (30), M = 평균 keyword 갯수 (≈ 3). 사실상 < 5ms.
|
||||
- prompt string concat — O(L), L = 총 길이 (≈ 4KB).
|
||||
|
||||
### 9. 의존성
|
||||
|
||||
- `SuggestFrameInput`, `FramePattern` 도메인 모델만.
|
||||
- Dart core (String, List).
|
||||
- **외부 의존 0** — 순수 함수.
|
||||
|
||||
### 10. 테스트 케이스
|
||||
|
||||
- [ ] **정상 매칭**: rawText="술 끊고", patterns 에 술 관련 3개 + 운동 5개 → selected 의 첫 3개가 술 관련.
|
||||
- [ ] **fallback**: rawText="xyz unknown", 매칭 0 → habit_type=break 인 임의 3개로 fallback.
|
||||
- [ ] **빈 patterns**: framePatterns=[] → few-shot 섹션 없는 prompt + L2/L3 가이드만.
|
||||
- [ ] **anchorHint null**: prompt 에 "anchor_hint: 없음" 명시.
|
||||
- [ ] **maxFewShot=1**: selected.length = 1.
|
||||
- [ ] **결정론**: 같은 입력 두 번 → 같은 출력 string.
|
||||
- [ ] **NFC**: rawText 가 NFD form 으로 들어오면 caller 가 normalize 책임 (본 함수는 가정).
|
||||
|
||||
### 11. 추적성
|
||||
|
||||
- 인수조건: #215 AC-6 (few-shot 동적 추출), AC-10 (한국어 품질).
|
||||
- 관련 ADR: ADR-0003 (SoT few-shot 동적 추출 원칙).
|
||||
|
||||
---
|
||||
|
||||
## §C. `parseFrameCandidates`
|
||||
|
||||
### 1. 시그니처
|
||||
|
||||
```dart
|
||||
List<FrameCandidate> parseFrameCandidates(Map<String, dynamic> json);
|
||||
```
|
||||
|
||||
### 2. 책임 (1줄)
|
||||
|
||||
function calling JSON 응답을 `FrameCandidate[]` 으로 변환한다. 형식 위반 시 `FormatException`.
|
||||
|
||||
### 3. 입력
|
||||
|
||||
| 파라미터 | 타입 | 제약/검증 | 설명 |
|
||||
|----------|------|-----------|------|
|
||||
| `json` | `Map<String, dynamic>` | function calling 응답 | `{"candidates": [...]}` 구조 가정 |
|
||||
|
||||
### 4. 출력
|
||||
|
||||
- **반환**: `List<FrameCandidate>` — 0~3 길이.
|
||||
- **부수효과**: 없음 (순수).
|
||||
|
||||
### 5. 동작 / 알고리즘
|
||||
|
||||
```
|
||||
1. raw = json['candidates']
|
||||
- null 또는 not List → throw FormatException("candidates missing")
|
||||
|
||||
2. result = []
|
||||
3. for each item in raw:
|
||||
- levelStr = item['level'] as String? ?? throw FormatException
|
||||
- level = FrameLevel.parse(levelStr) — L0/L1/L2/L3 enum
|
||||
- 알 수 없는 값 → skip (log)
|
||||
- framedText = item['framed_text'] as String? ?? throw FormatException
|
||||
- trim, length in [1, 120] 아니면 skip
|
||||
- confidence = (item['confidence'] as num?)?.toDouble() ?? 0.5
|
||||
- clamp(0.0, 1.0)
|
||||
- sourcePatternId = item['source_pattern_id'] as String? // optional
|
||||
- result.add(FrameCandidate(level, framedText, confidence, sourcePatternId))
|
||||
|
||||
4. return result
|
||||
```
|
||||
|
||||
> L0/L1 폐기는 본 함수가 아닌 호출자 `suggestFrame` 에서 `validateFrameLevel` 로 수행. parseFrameCandidates 는 형식 검증만.
|
||||
|
||||
### 6. 에러 & 실패 모드
|
||||
|
||||
| 조건 | 처리 | 반환/예외 |
|
||||
|------|------|-----------|
|
||||
| json 에 candidates 키 없음 | throw | FormatException("candidates missing") |
|
||||
| candidates not List | throw | FormatException("candidates not array") |
|
||||
| item 에 level 누락 | throw | FormatException(...) |
|
||||
| level 값이 enum 외 ("L99") | item skip + log | 부분 리스트 |
|
||||
| framed_text 길이 위반 | item skip + log | 부분 리스트 |
|
||||
| confidence not number | 0.5 fallback | 정상 진행 |
|
||||
|
||||
### 7. 엣지케이스
|
||||
|
||||
- `candidates: []` → 빈 리스트 반환 (예외 아님).
|
||||
- 4개 이상 후보 반환 → 모두 파싱. `suggestFrame` 에서 take(3).
|
||||
- 동일한 framed_text 가 2개 → 중복 그대로 반환. dedup 은 호출자 선택.
|
||||
- Unicode 이모지 포함 → 허용 (length 카운트는 grapheme 가 아닌 UTF-16 길이).
|
||||
|
||||
### 8. 복잡도 / 성능
|
||||
|
||||
- O(N) — N = candidates 길이 (보통 3).
|
||||
- 사실상 < 1ms.
|
||||
|
||||
### 9. 의존성
|
||||
|
||||
- `FrameCandidate`, `FrameLevel` 도메인 모델.
|
||||
- Dart core (Map, List).
|
||||
|
||||
### 10. 테스트 케이스
|
||||
|
||||
- [ ] **정상**: 3 valid items → length 3.
|
||||
- [ ] **candidates 누락**: `{"foo": "bar"}` → throw FormatException.
|
||||
- [ ] **candidates not list**: `{"candidates": "string"}` → throw.
|
||||
- [ ] **L0 + L2 + L3 mix**: 모두 파싱 (L0 폐기는 호출자 책임).
|
||||
- [ ] **알 수 없는 level "L99"**: skip → length 2 (3 중 2).
|
||||
- [ ] **framed_text 길이 120 초과**: skip.
|
||||
- [ ] **confidence 누락**: 0.5 fallback.
|
||||
- [ ] **confidence -0.1**: clamp 0.0.
|
||||
- [ ] **빈 candidates list**: `[]` → 빈 리스트 반환 (예외 X).
|
||||
- [ ] **이모지 포함**: 정상 파싱.
|
||||
|
||||
### 11. 추적성
|
||||
|
||||
- 인수조건: #215 AC-7 (function calling JSON 파싱 + L0/L1 폐기 결합).
|
||||
- 관련 ADR: ADR-0003 (function calling 강제).
|
||||
Reference in New Issue
Block a user