From d31b17f3e80e02c094acc55b7f243462203d1088 Mon Sep 17 00:00:00 2001 From: joungmin Date: Fri, 12 Jun 2026 11:16:15 +0900 Subject: [PATCH] [Architect] #215 ADR-0003 + design spec for Gemma frame suggest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- docs/adr/0003-on-device-llm-gemma.md | 105 +++++ docs/design/215-gemma-frame-suggest/README.md | 443 ++++++++++++++++++ .../fn-model_lifecycle.md | 381 +++++++++++++++ .../fn-suggest_frame.md | 335 +++++++++++++ 4 files changed, 1264 insertions(+) create mode 100644 docs/adr/0003-on-device-llm-gemma.md create mode 100644 docs/design/215-gemma-frame-suggest/README.md create mode 100644 docs/design/215-gemma-frame-suggest/fn-model_lifecycle.md create mode 100644 docs/design/215-gemma-frame-suggest/fn-suggest_frame.md diff --git a/docs/adr/0003-on-device-llm-gemma.md b/docs/adr/0003-on-device-llm-gemma.md new file mode 100644 index 0000000..81ffeaf --- /dev/null +++ b/docs/adr/0003-on-device-llm-gemma.md @@ -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 함수 확장). diff --git a/docs/design/215-gemma-frame-suggest/README.md b/docs/design/215-gemma-frame-suggest/README.md new file mode 100644 index 0000000..303ad09 --- /dev/null +++ b/docs/design/215-gemma-frame-suggest/README.md @@ -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`. +- `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> 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 load()` | (none, path from lifecycle) | void | 파일 없음 → FileSystemException; OOM → 사용자 메시지 | **복잡** → [fn-model_lifecycle.md](./fn-model_lifecycle.md) | +| `GemmaLlmService.unload` | 메모리에서 모델 해제 | `Future unload()` | none | void | idempotent | 단순 | +| `ModelLifecycle.checkAvailability` | 디스크 + 무결성 확인 | `Future checkAvailability()` | none | enum {ready, missing, corrupt, downloading} | I/O 실패 → corrupt 취급 | **복잡** → [fn-model_lifecycle.md](./fn-model_lifecycle.md) | +| `ModelLifecycle.download` | 백그라운드 다운로드 + 일시정지/재개 + SHA-256 검증 | `Stream download()` | none | progress stream | 네트워크 실패 → resume, 무결성 실패 → 파일 삭제 후 재다운로드 | **복잡** → [fn-model_lifecycle.md](./fn-model_lifecycle.md) | +| `ModelLifecycle.purge` | opt-out 시 모델 파일 + meta_kv 키 모두 삭제 | `Future purge()` | none | 해제된 바이트 수 | 파일 미존재 → 0 반환 (idempotent) | 단순 | +| `suggestFrame` | raw text → FrameCandidate[] (메인) | `Future> suggestFrame(SuggestFrameInput)` | input | candidates (≤ 3) | LlmService 실패 시 빈 리스트 반환 + 호출자에 메시지 위임 | **복잡** → [fn-suggest_frame.md](./fn-suggest_frame.md) | +| `buildFewShotPrompt` | FramePattern[] + raw text → prompt string | `String buildFewShotPrompt(SuggestFrameInput, List)` | input, patterns | prompt | patterns 비어있음 → fallback 시스템 prompt | **복잡** → [fn-suggest_frame.md](./fn-suggest_frame.md) §buildFewShotPrompt | +| `parseFrameCandidates` | JSON → FrameCandidate[] + L0/L1 폐기 | `List 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` | none | settings | meta_kv read 실패 → opt_in=false fallback | 단순 | +| `modelAvailabilityProvider` | Riverpod: 모델 가용성 | `FutureProvider` | none | enum | I/O 실패 → corrupt | 단순 | +| `frameSuggestionsProvider` | Riverpod: suggestFrame async wrap | `FutureProvider.family, 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'] = `, `meta_kv['ai_model_sha256'] = `, `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'] = ` 저장 상태. +3. 앱 재시작 → `modelAvailabilityProvider` 초기화 시 `checkAvailability()` 호출. +4. 상태 = `downloading` (paused) → 사용자에게 Settings 진입 시 "다운로드 재개" 버튼 표시. +5. [재개] 탭 → `download()` Stream 이 HTTP `Range: 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) diff --git a/docs/design/215-gemma-frame-suggest/fn-model_lifecycle.md b/docs/design/215-gemma-frame-suggest/fn-model_lifecycle.md new file mode 100644 index 0000000..e95214b --- /dev/null +++ b/docs/design/215-gemma-frame-suggest/fn-model_lifecycle.md @@ -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 load(); + Future unload(); + bool get isLoaded; + Future> generateStructured( + String prompt, + Map 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 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 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` — 진행률 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 보장). diff --git a/docs/design/215-gemma-frame-suggest/fn-suggest_frame.md b/docs/design/215-gemma-frame-suggest/fn-suggest_frame.md new file mode 100644 index 0000000..65a697b --- /dev/null +++ b/docs/design/215-gemma-frame-suggest/fn-suggest_frame.md @@ -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> suggestFrame( + SuggestFrameInput input, { + required LlmService llm, + required List 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` | #204 시드 30개 | few-shot 동적 추출 소스 | +| `validator` | `FrameValidator` | 기본값 OK | `validateFrameLevel` 의 래퍼 | +| `timeout` | Duration | 1~30s | LlmService.generateStructured 의 타임아웃 | + +### 4. 출력 + +- **반환**: `List` — 길이 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 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` | 시드 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 조립: + + 당신은 Huberman 프로토콜 한국어 코치입니다. 사용자의 raw text 를 + L2 (조건부 긍정) 또는 L3 (정체성) 프레임의 한국어 문장으로 변환합니다. + - L2 예: "스트레스 받을 때 책 한 페이지를 펼친다" + - L3 예: "나는 글을 읽는 사람이다" + - L0/L1 (회피/부정) 금지: "안", "끊다", "그만두다" + - 응답은 반드시 함수 호출 emit_frame_candidates(candidates: [...]) 로. + + + for p in selected: + # 예시 {n}: {p.title} + L0: {p.level_l0_example} + L2: {p.level_l2_example} + L3: {p.level_l3_example} + + + 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 parseFrameCandidates(Map json); +``` + +### 2. 책임 (1줄) + +function calling JSON 응답을 `FrameCandidate[]` 으로 변환한다. 형식 위반 시 `FormatException`. + +### 3. 입력 + +| 파라미터 | 타입 | 제약/검증 | 설명 | +|----------|------|-----------|------| +| `json` | `Map` | function calling 응답 | `{"candidates": [...]}` 구조 가정 | + +### 4. 출력 + +- **반환**: `List` — 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 강제).