Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c18dca1def | |||
| e81f3e44a4 | |||
| 3b8ea95aa6 | |||
| 94a9cd474b | |||
| 41457ab96e | |||
| 121108f63c | |||
| 071afefc54 | |||
| 7c90eca30c | |||
| 5b4c05316a | |||
| 1fa4f24a8a | |||
| 44d571f4ee | |||
| 7037b9e245 | |||
| a8446d0c88 | |||
| b9f5674f51 | |||
| b1bed4d5ca | |||
| eca097aa2c | |||
| 321d3af53b | |||
| 4665f06a94 | |||
| 25be18063e | |||
| da60dd1a5a | |||
| 1b90f58585 | |||
| 14632e11df | |||
| f71d132fa3 | |||
| 9a9eb2abd5 | |||
| a1f3c5f85d | |||
| ed340839a0 |
44
.claude/agents/ux-reviewer.md
Normal file
44
.claude/agents/ux-reviewer.md
Normal file
@@ -0,0 +1,44 @@
|
||||
---
|
||||
name: ux-reviewer
|
||||
description: "[AI] UX-Reviewer — 02-Architect 의 설계서 + Planner AC 를 사용자 흐름·정신 모델·마찰 관점에서 검토. 파이프라인 카테고리는 부여하지 않는 parallel reviewer."
|
||||
tools: Bash, Read, Edit, Write, Grep, Glob
|
||||
model: opus
|
||||
---
|
||||
|
||||
너는 life-helper 파이프라인의 **[AI] UX-Reviewer** 다.
|
||||
|
||||
시작 전에 반드시 읽는다: `CLAUDE.md`, `docs/pipeline/QUEUE-PROTOCOL.md`.
|
||||
|
||||
## 위치
|
||||
- 파이프라인 카테고리 부여 X. **02-Architect 단계 내**에서 설계서가 작성된 직후 parallel 로 동작한다.
|
||||
- 작업 큐 직접 소비 X — Architect 가 본인 작업 끝낼 때 명시적으로 UX-Reviewer 호출.
|
||||
- 결과는 Architect 가 흡수 → 03-Developer 인계.
|
||||
|
||||
## 역할 (Designer 와의 차이)
|
||||
| | Designer (05) | UX-Reviewer (이 페르소나) |
|
||||
|---|---|---|
|
||||
| 시점 | 코드 작성 후 polish | 설계 단계 (코드 전) |
|
||||
| 대상 | microcopy / 색상 / contrast | 사용자 흐름 / 정신 모델 / 마찰 |
|
||||
| 산출물 | 코드 수정 | 설계서에 통합되는 리뷰 노트 |
|
||||
|
||||
## 검토 관점
|
||||
- **흐름**: 사용자가 화면에 들어와서 첫 의미있는 행동까지의 경로. 단계 누락 / 막다른 길 / 되돌아갈 수 없는 지점.
|
||||
- **정신 모델**: 사용자가 "지금 무슨 일이 일어나는가" 를 추론 가능한가. 상태가 숨어있지 않은가.
|
||||
- **마찰**: 사용자가 멈춰야 하는 모든 지점 — disabled 상태, 모달, 확인, 에러. 정당한가, 줄일 수 있는가.
|
||||
- **지연 / 비동기**: 로딩 / 진행 / 실패 상태가 명시적인가. 사용자가 기다리는 동안 무엇을 보는가.
|
||||
- **에러 회복**: 실패 후 사용자가 다시 시도할 수 있는가. 다음 행동이 명확한가.
|
||||
- **취소 / 탈출**: 사용자가 흐름을 떠날 수 있는가. 진행 중 state 가 어떻게 되는가.
|
||||
- **접근성 / 다국어**: 라벨이 한국어 자연 톤인가. 시각 대비. 터치 타겟 크기.
|
||||
|
||||
## 산출물
|
||||
- 설계서 디렉토리에 `UX-REVIEW.md` 추가 또는 README 의 `## UX 리뷰` 섹션.
|
||||
- 형식: **권고 (Strong / Suggest / Question)** × (관점, 근거, 제안).
|
||||
- **Strong**: 사용자가 막힐 위험. Architect 가 설계 변경 또는 명시 거절 (OQ로) 해야 함.
|
||||
- **Suggest**: 사용자 경험이 더 좋아짐. 채택은 Architect 재량.
|
||||
- **Question**: 결정 전 더 정보 필요. Architect 또는 후속 사용자 인터뷰.
|
||||
- 각 항목에 Planner AC 번호와 mapping (어느 AC 의 해석을 좁히거나 넓히는지).
|
||||
|
||||
## 핸드오프
|
||||
- 코드/설계 직접 수정 X — 모든 변경은 Architect 가 흡수.
|
||||
- 산출물 git 커밋 가능 (`[UX-Reviewer] #<ID> ...`).
|
||||
- Redmine 저널에 1-line 요약.
|
||||
125
CHANGELOG.md
125
CHANGELOG.md
@@ -3,6 +3,131 @@
|
||||
본 프로젝트의 모든 의미있는 변경은 본 파일에 기록한다.
|
||||
형식: [Keep a Changelog](https://keepachangelog.com/) · 버전: [SemVer](https://semver.org/).
|
||||
|
||||
## [0.4.2] — 2026-06-15 (hotfix, dev)
|
||||
|
||||
### Fixed (Redmine #342)
|
||||
- **ChatScreen 하단 잘림** — Android edge-to-edge 모드에서 시스템 nav bar (3-button / gesture handle) 가 입력창을 덮던 문제. `Scaffold.body` 를 `SafeArea(top: false, …)` 로 감쌈. AppBar 가 이미 top inset 처리하므로 top 만 false.
|
||||
|
||||
### UX round 1 — raw enum 노출 정리 (Redmine #342 추가)
|
||||
- **습관 카드 부제** — `build · L3 · …` (raw enum) → `만들기 · …`. FrameLevel 노출 제거 (시스템 규약이라 사용자 가치 낮음).
|
||||
- **스트릭 화면 현재 티어** — `T0` / `T1` raw → `🌱 새싹` / `🥉 3회 도전` / `🥈 7일 형성` / `🥇 30일 정착` / `🏆 6주 완주` 이모지+한국어 라벨.
|
||||
- **스트릭 강등 경고** — `Never miss twice 발동 — 티어 강등` (영문 잠언) → `이틀 연속 빠졌어요. 한 단계 강등됐습니다.`.
|
||||
- **스트릭 hero 위계** — 현재 스트릭을 `displayLarge` 큰 숫자 + 티어 라벨로 시각 강조 (사용자의 핵심 동기 지표).
|
||||
- **습관 추가 드롭다운** — `만들기 (build)` → `만들기` (영어 식별자 병기 제거).
|
||||
- 신규 `app/lib/ui/labels.dart` — domain enum 의 한국어 라벨 매핑 단일 지점. domain layer 에 `koreanLabel` 두지 않음 (관심사 분리).
|
||||
|
||||
### UX round 2 — 빈 상태 + 날짜 + 라벨 명확화 (Redmine #342 추가)
|
||||
- **ChatScreen 빈 상태 안내** — 첫 진입 시 빈 메시지 리스트 대신 아이콘 + 한 줄 설명 + 예시 prompt 4개 (`아침 햇빛 받기 습관 추가해줘`, `오늘 운동 했어`, `내 스트릭 보여줘`, `수면 프로토콜 알려줘`). tap → 입력창 자동 채움 (자동 send X, 사용자 수정 여지).
|
||||
- **CheckIn 날짜 한국식** — `2026-06-15` raw → `6월 15일 (월)`. DB 저장은 `_ymd` 유지.
|
||||
- **HabitCreate 표현 방식** — `프레임 레벨` (의미 모호) → `표현 방식` + helperText `행동 위주 vs 정체성 위주`. 아이템 라벨 `L2 · 조건부 긍정` / `L3 · 정체성` → `조건부 행동 (예: 아침에 햇빛 받기)` / `정체성 (예: 나는 일찍 자는 사람)` 식 예시 포함.
|
||||
|
||||
### Dev
|
||||
- **LLM 실패 빨간 배너에 full message + stack trace** — 단말 진단을 위해 release 빌드에서도 노출. `LLM 응답 실패: <Type>\n<message>\n--- STACK ---\n<stack>` 형식. SelectableText + monospace + 최대 화면 1/3 높이 + scroll. 사용자 친화 메시지로 좁히는 작업은 #342 종료 후 follow-up.
|
||||
|
||||
## [0.4.1] — 2026-06-15
|
||||
|
||||
### Added — ChatScreen LLM warm-up (Redmine #311, follow-up of #260)
|
||||
- **백그라운드 warm-up** — `ChatScreen` 진입 시 `ChatWarmupController` 가 `LlmService.load()` 를 백그라운드로 트리거. 첫 send 에서 cold native-init (수 초) 비용 제거.
|
||||
- **`ModelLifecycle.quickCheck()`** (신규) — SHA-256 재해싱 없이 meta_kv + 파일 존재만으로 ready 추정. ~2.4GB Gemma 4 E2B 파일에 대한 매 mount 마다의 hash 비용 회피.
|
||||
- **Concurrent load guard** — `GemmaLlmService.load()` + `MockLlmService.load()` 에 `_loadingFuture` 가드 추가. ChatScreen warm-up + 동시 `userTurn` lazy load 가 race 해도 native init 1회만 실행.
|
||||
- **Sealed state machine** — `ChatWarmupState`: Idle / Loading / Ready / Failed(kind) / Unavailable. autoDispose StateNotifier + `_disposed` 가드로 unmount race 방지.
|
||||
|
||||
### UX (Designer + Reviewer)
|
||||
- warmup 중 입력창 `enabled` 유지 — 사용자가 미리 메시지 작성 가능 (UX R1+R2).
|
||||
- send 자리에 `CircularProgressIndicator(strokeWidth: 2)` — `isStreaming` 패턴과 일관.
|
||||
- hintText 교체 — warmup: `AI 준비 중… 첫 시작은 몇 초 걸려요` / 평상: `습관 추가, 기록, 카탈로그 질문…`.
|
||||
- 실패 메시지는 상태 기술만 (UX R5/AC12) — `AI 모델 파일을 찾을 수 없어요.` / `AI 를 시작하지 못했어요.`. 행동은 버튼이 담당.
|
||||
- `_WarmupErrorBanner` 가 `kind` 분기:
|
||||
- `fileMissing` → **[설정으로 가기]** + `SettingsScreen` push + pop 후 자동 `retry()` (UX R5/R6).
|
||||
- `runtime` → **[다시 시도]** + 즉시 `retry()`.
|
||||
- `isLoaded=true` 재진입 시 Loading state skip — 1 frame 라벨 깜빡임 방지 (UX R4/AC11).
|
||||
|
||||
### Added — Tests
|
||||
- 167/167 passed (1 pre-existing skip) — 신규 12 (`chat_warmup_test.dart` 8 + `model_lifecycle_test.dart` quickCheck 4).
|
||||
- AC1~AC2, AC5~AC7, AC11, AC12 controller-level 검증.
|
||||
- AC3/AC4/AC8/AC9/AC10 widget E2E 는 deferred — `CircularProgressIndicator` 무한 ticker + `Future.delayed` ↔ `pumpAndSettle` race. `chat_screen_test.dart` NOTE comment 에 사유 명시.
|
||||
|
||||
### Docs
|
||||
- 설계서 `docs/design/311-llm-warmup/` (4 파일) — README + 2 fn-spec + UX-REVIEW.md.
|
||||
- 신규 페르소나 `ux-reviewer.md` — 02-Architect 단계의 parallel review.
|
||||
|
||||
### Known follow-ups (후속 이슈 권장)
|
||||
- Widget E2E 인프라 개선 (FakeAsync 또는 spinner 가짜 대체) — ticker race 해소.
|
||||
- 다른 recovery loop 도 `Navigator.push().then((_) => retry())` 패턴 적용 검토.
|
||||
|
||||
## [0.4.0] — 2026-06-15
|
||||
|
||||
### Added — Phase 2-B in-app tool calling (Redmine #260)
|
||||
- **In-process Dart tool runtime** (ADR-0005): MCP 와 동등한 capability 추상화를 별도 서버 없이 in-process Dart 함수로 구현. latency 거의 0.
|
||||
- **6 tools** (`app/lib/ai/tools/`): `search_catalog`, `query_protocol`, `list_active_habits`, `get_streak` (read-only) / `add_habit`, `log_tracker_entry` (destructive).
|
||||
- **Multi-turn loop** (`ChatSessionController`) — MAX_TURNS=4 안전 cap, 8-turn soft history warning. `ToolChoice.auto` 로 reply-only + tool call 모두 지원.
|
||||
- **ConfirmGate 모달** — destructive tool 호출 시 AlertDialog (`이 작업을 수행할까요?`) 의무. 좁은 화면 SingleChildScrollView.
|
||||
- **2KB result cap** (ADR-0005 §OQ-2) — `encodeToolResult` 가 ToolOk payload 초과 시 `_truncated:true` + `_hint` 로 잘림 (`chat_providers.dart:192` 에서 runtime wire).
|
||||
- **R 규칙 enforce = 핸들러 책임** — 모델 prompt 학습 아닌 코드 게이트. R3 quota, R5 (habit,date) dedup, R7 회피 키워드, R8 XOR (build/break) 모두 ToolErr 코드로 노출.
|
||||
- **ChatScreen** (`app/lib/ui/screens/chat_screen.dart`) — 신규 AI 코치 화면. HabitListScreen AppBar 의 🤖 entry (AI opt-in 시).
|
||||
- **schema SoT = Dart 코드** (ADR-0005 §D-4) — `ToolDefinition.parametersSchema` Map 리터럴.
|
||||
|
||||
### Polish (Designer)
|
||||
- ToolCallChatMessage 라벨 한국어화 (`_kToolKoreanLabels` — `add_habit → 습관 추가` 등 6종 매핑).
|
||||
- ConfirmDialog content 를 SingleChildScrollView 로 감싸 좁은 폰 + 긴 description 대응.
|
||||
- Streaming cursor `▍` 를 `Text.rich` 로 분리 후 `colorScheme.primary` 적용 — 다크 모드 contrast.
|
||||
- AppBar tooltip `새 대화` → `새 대화 (이전 기록 비우기)`.
|
||||
|
||||
### Added — Tests
|
||||
- 154/154 passed (1 skip) — 신규 41 → 43 (tool_envelope 6 + catalog_tools 7 + habit_tools 8 + tracker_tools 7 + dispatcher 6 + controller 8 + widget E2E 2).
|
||||
- AC-9 회귀: 인위 `huge_dump` tool 로 `_truncated:true` + `_hint` 직접 검증.
|
||||
- AC-10 widget E2E (`test/ui/chat_screen_test.dart`): add_habit 호출 → ConfirmDialog `수행` → habits +1 / `취소` → 무변화 + `취소됨` 라벨.
|
||||
|
||||
### Docs
|
||||
- 설계서 `docs/design/260-gemma-tool-calling/` (5 파일, 844 라인) — README + 4 함수 fn-spec.
|
||||
- ADR-0005 — In-app tool calling architecture (4 결정사항).
|
||||
|
||||
### Known follow-ups (후속 이슈 권장)
|
||||
- `ToolDefinition.koreanLabel` 필드 도입 — 현재 `_kToolKoreanLabels` hardcoded.
|
||||
- `log_tracker_entry` value=blank 시 confirm skip — 현재 done/blank 무차별 모달.
|
||||
- `search_catalog` category matching case-insensitive — 모델 hallucination 대비.
|
||||
|
||||
### Release artifact
|
||||
- `app-release.apk` 287MB (300.9MB raw / 287MB on-disk), SHA-256 `6670da0c4e9bf5e826174ebc48088540867d877cf58699119a519e2ffb40ea3a`.
|
||||
- Build: `flutter build apk --release` (Gradle assembleRelease 106.4s).
|
||||
|
||||
## [0.3.0] — 2026-06-12
|
||||
|
||||
### Added — Phase 2-A OQ-1 resolved: real Gemma 4 E2B inference (Redmine #218)
|
||||
- `GemmaLlmService` 본문 구현 — `flutter_gemma` 0.16.5 위에 Gemma 4 E2B 실 추론. `InferenceModel.createChat(modelType: gemma4, supportsFunctionCalls: true, toolChoice: required, tools: [...])` + `collectFunctionCall(stream)` 로 structured JSON 강제.
|
||||
- `_LazyLlmService` (main.dart) — Mock ↔ Gemma 런타임 어댑터. 매 호출마다 `checkAvailability` 재평가 → opt-in/opt-out 즉시 반영 (앱 재시작 불필요).
|
||||
- 실 모델 핀: `gemma-4-E2B-it.litertlm` 2.41GB, SHA-256 `181938105e0eefd105961417e8da75903eacda102c4fce9ce90f50b97139a63c` (HF `litert-community/gemma-4-E2B-it-litert-lm`).
|
||||
- HF_TOKEN `--dart-define` 주입 — 빈 기본값으로 빌드 안전.
|
||||
|
||||
### Added — Device gate (AC-6)
|
||||
- 플랫폼 채널 `life_helper/device_caps` (`MainActivity.kt` → `ActivityManager.MemoryInfo.totalMem`) — Android 단말 실 RAM 측정. `device_info_plus` 의 `isLowRamDevice` (~1GB) 로는 4GB 임계치 불가하여 채널 도입.
|
||||
- `DeviceCapabilities` 추상 + `PlatformDeviceCapabilities` 구현 (테스트 주입 가능). `kAiMinRamBytes = 4 GiB`. fail-closed (`null` → false).
|
||||
- `deviceMeetsAiRamProvider` (Riverpod `FutureProvider`) — `SettingsScreen` 토글 disabled + 안내 문구.
|
||||
|
||||
### Added — Tests
|
||||
- 88/88 통과 — 신규 10 (`device_capabilities_test.dart` 7 + lazy resolve regression 3).
|
||||
|
||||
### Polish (Designer)
|
||||
- AC-6 게이트 안내 톤 정렬 — "RAM 부족" → "이 단말에서는 AI 도움을 사용할 수 없어요 (RAM 4GB 이상 필요)".
|
||||
- `_describe(missing, meetsRam:)` 분기 — 토글 disabled 상황에서 "토글 켜면" 모순 제거.
|
||||
- 옵트아웃 다이얼로그 "다시 다시" 중복 → "처음부터".
|
||||
|
||||
### Fixed (Reviewer)
|
||||
- `_LazyLlmService._delegate` sticky cache — 첫 호출 시점의 delegate 종류가 앱 재시작까지 유지되던 버그 (Mock → Gemma 전환 안 됨). re-resolve + (kind + modelPath) 일치 시만 캐시 재사용.
|
||||
- Reference 문서 nit 3건 — `215-ai-frame-suggest.md` (L184 채널 사실 정정 / L186 F1 follow-up 매핑 / L191 OOS 기준).
|
||||
|
||||
### Release artifact
|
||||
- `app-release.apk` 286MB, SHA-256 `4a237d5124bfcd56aaa8c0ae89060a9ecf9ce7cc739f0b056ce66e9b9ca6b54a`.
|
||||
|
||||
### Known limitations (deferred to #219~#222)
|
||||
- **AC-7** (실 단말 cold-start 예산) — DEFER. 실기기 E2E 검증은 본 릴리스 후 권고.
|
||||
- **#219** F1: 60초 idle auto-unload.
|
||||
- **#220** GemmaLlmService.load 동시성 가드 + `isThinking:false` 명시.
|
||||
- **#221** AC-10 한국어 corpus ≥70%.
|
||||
- **#222** HF_TOKEN keystore 기반 secret 전환.
|
||||
|
||||
---
|
||||
|
||||
## [0.2.0] — 2026-06-12
|
||||
|
||||
### Added — Phase 2-A: On-device Gemma 4 frame suggestion (Redmine #215)
|
||||
|
||||
@@ -30,6 +30,13 @@ android {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
// #218: keep flutter_gemma JNI bindings — see proguard-rules.pro.
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
30
app/android/app/proguard-rules.pro
vendored
Normal file
30
app/android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
# flutter_gemma 0.16.5 — keep MediaPipe + LiteRT native bindings (#218)
|
||||
# Without these the release build (R8 minify) strips JNI entry points
|
||||
# and the first inference call crashes with NoSuchMethodError.
|
||||
|
||||
# MediaPipe LLM (.task path)
|
||||
-keep class com.google.mediapipe.** { *; }
|
||||
-dontwarn com.google.mediapipe.**
|
||||
|
||||
# LiteRT runtime (.litertlm path used by Gemma 4 E2B)
|
||||
-keep class com.google.ai.edge.** { *; }
|
||||
-keep class com.google.ai.litert.** { *; }
|
||||
-dontwarn com.google.ai.edge.**
|
||||
-dontwarn com.google.ai.litert.**
|
||||
|
||||
# TensorFlow Lite (used by LiteRT under the hood)
|
||||
-keep class org.tensorflow.lite.** { *; }
|
||||
-dontwarn org.tensorflow.lite.**
|
||||
|
||||
# Protobuf-lite (LiteRT message classes referenced via reflection)
|
||||
-keep class com.google.protobuf.** { *; }
|
||||
-dontwarn com.google.protobuf.**
|
||||
|
||||
# flutter_gemma plugin's own native bridge
|
||||
-keep class dev.flutterberlin.flutter_gemma.** { *; }
|
||||
-dontwarn dev.flutterberlin.flutter_gemma.**
|
||||
|
||||
# Generic JNI methods — covers any LiteRT/MediaPipe class loaded dynamically
|
||||
-keepclasseswithmembernames class * {
|
||||
native <methods>;
|
||||
}
|
||||
@@ -1,4 +1,11 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- #218: flutter_gemma downloads ≈ 2.4GB model checkpoint. -->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||
<!-- Foreground service for large downloads (>500MB auto-detect). -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<application
|
||||
android:label="life_helper"
|
||||
android:name="${applicationName}"
|
||||
|
||||
@@ -1,5 +1,36 @@
|
||||
package kr.cloud_handson.life_helper
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.content.Context
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
/// Hosts the `life_helper/device_caps` MethodChannel.
|
||||
///
|
||||
/// #218 AC-6: the AI feature requires ≥ 4GB RAM; getting an accurate total
|
||||
/// from Dart needs ActivityManager.MemoryInfo, which is Android-only — so we
|
||||
/// expose `totalMemoryBytes` as a platform method here.
|
||||
class MainActivity : FlutterActivity() {
|
||||
private val deviceCapsChannel = "life_helper/device_caps"
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, deviceCapsChannel)
|
||||
.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"totalMemoryBytes" -> {
|
||||
try {
|
||||
val am = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
|
||||
val info = ActivityManager.MemoryInfo()
|
||||
am.getMemoryInfo(info)
|
||||
result.success(info.totalMem)
|
||||
} catch (t: Throwable) {
|
||||
result.error("RAM_QUERY_FAILED", t.message, null)
|
||||
}
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[
|
||||
{
|
||||
"id": "morning_sunlight",
|
||||
"category": "health",
|
||||
"category": "light_circadian",
|
||||
"title": "아침 햇빛",
|
||||
"title_en": "Morning Sunlight",
|
||||
"what": "기상 후 야외에서 햇빛을 직접 눈에 받기.",
|
||||
@@ -21,13 +21,17 @@
|
||||
"after_what": "기상 후 양치"
|
||||
},
|
||||
"min_dose_for_start": "햇빛 30초~2분 (Tiny Habits 시작 도즈)",
|
||||
"reference_ids": ["ref_podcast_hl_2_sleep", "ref_podcast_hl_68_light", "ref_doi_10_1016_j_cub_2013_06_039"],
|
||||
"reference_ids": [
|
||||
"ref_podcast_hl_2_sleep",
|
||||
"ref_podcast_hl_68_light",
|
||||
"ref_doi_10_1016_j_cub_2013_06_039"
|
||||
],
|
||||
"evidence_strength": "strong_rct",
|
||||
"source_doc": "huberman-protocols.md"
|
||||
},
|
||||
{
|
||||
"id": "evening_sunlight",
|
||||
"category": "health",
|
||||
"category": "light_circadian",
|
||||
"title": "저녁 햇빛",
|
||||
"title_en": "Evening Sunlight",
|
||||
"what": "일몰 즈음 햇빛 보기.",
|
||||
@@ -40,13 +44,15 @@
|
||||
"야외 5~10분 (저녁 산책과 결합)."
|
||||
],
|
||||
"check": "일몰 ±1시간 안에 야외 / 5분 이상",
|
||||
"reference_ids": ["ref_podcast_hl_68_light"],
|
||||
"reference_ids": [
|
||||
"ref_podcast_hl_68_light"
|
||||
],
|
||||
"evidence_strength": "mechanistic",
|
||||
"source_doc": "huberman-protocols.md"
|
||||
},
|
||||
{
|
||||
"id": "night_light_avoidance",
|
||||
"category": "health",
|
||||
"category": "light_circadian",
|
||||
"title": "야간 빛 차단",
|
||||
"title_en": "Night Light Avoidance",
|
||||
"what": "밤 10시 ~ 새벽 4시 머리 위 강한 빛 차단.",
|
||||
@@ -63,13 +69,16 @@
|
||||
"default_anchor": {
|
||||
"when": "21:00"
|
||||
},
|
||||
"reference_ids": ["ref_podcast_hl_68_light", "ref_doi_10_1038_tp_2016_262"],
|
||||
"reference_ids": [
|
||||
"ref_podcast_hl_68_light",
|
||||
"ref_doi_10_1038_tp_2016_262"
|
||||
],
|
||||
"evidence_strength": "mechanistic",
|
||||
"source_doc": "huberman-protocols.md"
|
||||
},
|
||||
{
|
||||
"id": "sleep_stack",
|
||||
"category": "health",
|
||||
"category": "sleep",
|
||||
"title": "수면 스택",
|
||||
"title_en": "Sleep Stack",
|
||||
"what": "입면·심부 체온·자극 차단을 결합한 취침 전 90분 루틴.",
|
||||
@@ -84,13 +93,16 @@
|
||||
"침실 18~19℃, 침대 진입 직전 화면 OFF."
|
||||
],
|
||||
"check": "기상 시각 ±1h / 카페인 컷오프 / 식사 2~3h 전 종료 / 침실 18~19℃",
|
||||
"reference_ids": ["ref_podcast_hl_84_sleep_toolkit", "ref_podcast_hl_2_sleep"],
|
||||
"reference_ids": [
|
||||
"ref_podcast_hl_84_sleep_toolkit",
|
||||
"ref_podcast_hl_2_sleep"
|
||||
],
|
||||
"evidence_strength": "expert_opinion",
|
||||
"source_doc": "huberman-protocols.md"
|
||||
},
|
||||
{
|
||||
"id": "caffeine_protocol",
|
||||
"category": "health",
|
||||
"category": "sleep",
|
||||
"title": "카페인 타이밍",
|
||||
"title_en": "Caffeine Protocol",
|
||||
"what": "기상 직후 카페인 회피 + 컷오프 시각 준수.",
|
||||
@@ -105,13 +117,16 @@
|
||||
],
|
||||
"check": "기상 후 90분 전 카페인 X / 컷오프 시각 이후 카페인 X",
|
||||
"caution": "90~120분 지연은 직접 RCT 부재. adenosine 약리학 기반 추론. 근거 ⚠️.",
|
||||
"reference_ids": ["ref_podcast_hl_101_caffeine", "ref_doi_10_5664_jcsm_3170"],
|
||||
"reference_ids": [
|
||||
"ref_podcast_hl_101_caffeine",
|
||||
"ref_doi_10_5664_jcsm_3170"
|
||||
],
|
||||
"evidence_strength": "expert_opinion",
|
||||
"source_doc": "huberman-protocols.md"
|
||||
},
|
||||
{
|
||||
"id": "weekly_movement_template",
|
||||
"category": "health",
|
||||
"category": "movement",
|
||||
"title": "주간 운동 템플릿",
|
||||
"title_en": "Weekly Movement Template",
|
||||
"what": "유산소(Zone 2 + VO2max) + 근력의 주간 분배.",
|
||||
@@ -127,13 +142,15 @@
|
||||
],
|
||||
"check": "Zone 2 ≥ 150분/주 / VO2max 1회 / 근력 2~4회",
|
||||
"min_dose_for_start": "운동 1세트 또는 5분 산책",
|
||||
"reference_ids": ["ref_doi_10_1001_jamanetworkopen_2018_3605"],
|
||||
"reference_ids": [
|
||||
"ref_doi_10_1001_jamanetworkopen_2018_3605"
|
||||
],
|
||||
"evidence_strength": "observational",
|
||||
"source_doc": "huberman-protocols.md"
|
||||
},
|
||||
{
|
||||
"id": "deliberate_cold_exposure",
|
||||
"category": "health",
|
||||
"category": "recovery_stress",
|
||||
"title": "의도적 냉수 노출",
|
||||
"title_en": "Deliberate Cold Exposure",
|
||||
"what": "찬물 샤워 또는 ice bath.",
|
||||
@@ -149,13 +166,16 @@
|
||||
],
|
||||
"check": "1회 ≥ 1분 / 주 합산 ≥ 11분",
|
||||
"caution": "근비대 직후 4h 회피. 심혈관 질환자 의사 상담.",
|
||||
"reference_ids": ["ref_podcast_hl_66_cold", "ref_doi_10_1007_s004210050065"],
|
||||
"reference_ids": [
|
||||
"ref_podcast_hl_66_cold",
|
||||
"ref_doi_10_1007_s004210050065"
|
||||
],
|
||||
"evidence_strength": "strong_rct",
|
||||
"source_doc": "huberman-protocols.md"
|
||||
},
|
||||
{
|
||||
"id": "deliberate_heat_exposure",
|
||||
"category": "health",
|
||||
"category": "recovery_stress",
|
||||
"title": "사우나",
|
||||
"title_en": "Deliberate Heat Exposure",
|
||||
"what": "80~100℃ 사우나.",
|
||||
@@ -169,13 +189,16 @@
|
||||
],
|
||||
"check": "주 합산 ≥ 57분 (선택)",
|
||||
"caution": "임신/심혈관/저혈압 시 의사 상담. 알코올 결합 X.",
|
||||
"reference_ids": ["ref_podcast_hl_69_heat", "ref_doi_10_1001_jamainternmed_2014_8187"],
|
||||
"reference_ids": [
|
||||
"ref_podcast_hl_69_heat",
|
||||
"ref_doi_10_1001_jamainternmed_2014_8187"
|
||||
],
|
||||
"evidence_strength": "observational",
|
||||
"source_doc": "huberman-protocols.md"
|
||||
},
|
||||
{
|
||||
"id": "foundational_supplements",
|
||||
"category": "health",
|
||||
"category": "nutrition",
|
||||
"title": "핵심 보충제",
|
||||
"title_en": "Foundational Supplements",
|
||||
"what": "Vitamin D3, Magnesium L-threonate, (옵션) Apigenin, Theanine.",
|
||||
@@ -189,13 +212,16 @@
|
||||
],
|
||||
"check": "처방/권장량 준수 / 신규 도입 한 번에 1종",
|
||||
"caution": "의약품/임신/기저질환 시 의사 상담. Theanine은 혈압약 상호작용 가능.",
|
||||
"reference_ids": ["ref_podcast_hl_84_sleep_toolkit", "ref_doi_10_1016_j_sleepx_2024_100121"],
|
||||
"reference_ids": [
|
||||
"ref_podcast_hl_84_sleep_toolkit",
|
||||
"ref_doi_10_1016_j_sleepx_2024_100121"
|
||||
],
|
||||
"evidence_strength": "strong_rct",
|
||||
"source_doc": "huberman-protocols.md"
|
||||
},
|
||||
{
|
||||
"id": "focused_meditation",
|
||||
"category": "meditation",
|
||||
"category": "focus_cognition",
|
||||
"title": "집중 명상",
|
||||
"title_en": "Focused Meditation",
|
||||
"what": "단일 대상(호흡/미간)에 주의 고정.",
|
||||
@@ -212,13 +238,16 @@
|
||||
"check": "13분 완료 / 알아챔→복귀 1회 이상 의식",
|
||||
"caution": "잠들기 직전 진행 시 각성 유발 가능.",
|
||||
"min_dose_for_start": "명상 1분",
|
||||
"reference_ids": ["ref_podcast_hl_96_meditation", "ref_doi_10_1016_j_bbr_2018_08_023"],
|
||||
"reference_ids": [
|
||||
"ref_podcast_hl_96_meditation",
|
||||
"ref_doi_10_1016_j_bbr_2018_08_023"
|
||||
],
|
||||
"evidence_strength": "strong_rct",
|
||||
"source_doc": "huberman-protocols.md"
|
||||
},
|
||||
{
|
||||
"id": "nsdr_yoga_nidra",
|
||||
"category": "meditation",
|
||||
"category": "recovery_stress",
|
||||
"title": "NSDR / Yoga Nidra",
|
||||
"title_en": "Non-Sleep Deep Rest",
|
||||
"what": "누운 자세에서 가이드 보디스캔 + 긴 날숨.",
|
||||
@@ -233,13 +262,16 @@
|
||||
"종료 후 30초 잔여감."
|
||||
],
|
||||
"check": "가이드 끝까지 / 종료 후 30초 잔여감",
|
||||
"reference_ids": ["ref_podcast_hl_28_daily_tools", "ref_doi_10_1016_S0926_6410_01_00106_9"],
|
||||
"reference_ids": [
|
||||
"ref_podcast_hl_28_daily_tools",
|
||||
"ref_doi_10_1016_S0926_6410_01_00106_9"
|
||||
],
|
||||
"evidence_strength": "mechanistic",
|
||||
"source_doc": "huberman-protocols.md"
|
||||
},
|
||||
{
|
||||
"id": "cyclic_sighing",
|
||||
"category": "meditation",
|
||||
"category": "recovery_stress",
|
||||
"title": "생리적 한숨",
|
||||
"title_en": "Cyclic Sighing",
|
||||
"what": "들숨 2회 + 긴 날숨 1회.",
|
||||
@@ -255,13 +287,16 @@
|
||||
],
|
||||
"check": "패턴 유지 / 1분 이상",
|
||||
"min_dose_for_start": "cyclic sighing 30초~1분",
|
||||
"reference_ids": ["ref_podcast_hl_10_stress", "ref_doi_10_1016_j_xcrm_2022_100895"],
|
||||
"reference_ids": [
|
||||
"ref_podcast_hl_10_stress",
|
||||
"ref_doi_10_1016_j_xcrm_2022_100895"
|
||||
],
|
||||
"evidence_strength": "strong_rct",
|
||||
"source_doc": "huberman-protocols.md"
|
||||
},
|
||||
{
|
||||
"id": "box_breathing",
|
||||
"category": "meditation",
|
||||
"category": "recovery_stress",
|
||||
"title": "Box Breathing",
|
||||
"title_en": "Box Breathing",
|
||||
"what": "4초 들숨–4초 멈춤–4초 날숨–4초 멈춤.",
|
||||
@@ -277,13 +312,16 @@
|
||||
],
|
||||
"check": "4-4-4-4 박자 / 2분 이상",
|
||||
"caution": "특이성 RCT 빈약 — cyclic sighing(§2.3)보다 효과 작음.",
|
||||
"reference_ids": ["ref_podcast_hl_10_stress", "ref_doi_10_3389_fnhum_2018_00353"],
|
||||
"reference_ids": [
|
||||
"ref_podcast_hl_10_stress",
|
||||
"ref_doi_10_3389_fnhum_2018_00353"
|
||||
],
|
||||
"evidence_strength": "mechanistic",
|
||||
"source_doc": "huberman-protocols.md"
|
||||
},
|
||||
{
|
||||
"id": "cold_sigh_combo",
|
||||
"category": "meditation",
|
||||
"category": "recovery_stress",
|
||||
"title": "Cold + Sigh Combo",
|
||||
"title_en": "Cold + Sigh Combo",
|
||||
"what": "찬물 세면 + cyclic sighing.",
|
||||
@@ -301,7 +339,7 @@
|
||||
},
|
||||
{
|
||||
"id": "protect_dopamine_baseline",
|
||||
"category": "motivation",
|
||||
"category": "focus_cognition",
|
||||
"title": "도파민 baseline 보호",
|
||||
"title_en": "Protect Dopamine Baseline",
|
||||
"what": "활동 자체에 외부 도파민 자극을 중첩(stacking)하지 않기.",
|
||||
@@ -315,13 +353,16 @@
|
||||
"주 1~2회 '맨몸' 세션으로 baseline 회복."
|
||||
],
|
||||
"check": "stacking ≤ 1 / 직후 5분 차단",
|
||||
"reference_ids": ["ref_podcast_hl_39_dopamine", "ref_book_lembke_dopamine_nation"],
|
||||
"reference_ids": [
|
||||
"ref_podcast_hl_39_dopamine",
|
||||
"ref_book_lembke_dopamine_nation"
|
||||
],
|
||||
"evidence_strength": "expert_opinion",
|
||||
"source_doc": "huberman-protocols.md"
|
||||
},
|
||||
{
|
||||
"id": "reward_prediction_relabeling",
|
||||
"category": "motivation",
|
||||
"category": "focus_cognition",
|
||||
"title": "보상 예측 재배치",
|
||||
"title_en": "Reward Prediction Relabeling",
|
||||
"what": "노력 자체에 보상을 결합하는 내적 라벨링.",
|
||||
@@ -335,13 +376,16 @@
|
||||
"끝난 후 외적 보상 X."
|
||||
],
|
||||
"check": "라벨링 1회+ / 외적 보상 안 줌",
|
||||
"reference_ids": ["ref_podcast_hl_39_dopamine", "ref_podcast_hl_113_dopamine_procrastination"],
|
||||
"reference_ids": [
|
||||
"ref_podcast_hl_39_dopamine",
|
||||
"ref_podcast_hl_113_dopamine_procrastination"
|
||||
],
|
||||
"evidence_strength": "mechanistic",
|
||||
"source_doc": "huberman-protocols.md"
|
||||
},
|
||||
{
|
||||
"id": "dopamine_recovery_stack",
|
||||
"category": "motivation",
|
||||
"category": "focus_cognition",
|
||||
"title": "도파민 회복 스택",
|
||||
"title_en": "Dopamine Recovery Stack",
|
||||
"what": "자연적 baseline 상승 도구 묶음.",
|
||||
@@ -356,13 +400,15 @@
|
||||
"디지털 디톡스 주 1회 24h."
|
||||
],
|
||||
"check": "각 구성요소 1회+",
|
||||
"reference_ids": ["ref_podcast_hl_113_dopamine_procrastination"],
|
||||
"reference_ids": [
|
||||
"ref_podcast_hl_113_dopamine_procrastination"
|
||||
],
|
||||
"evidence_strength": "expert_opinion",
|
||||
"source_doc": "huberman-protocols.md"
|
||||
},
|
||||
{
|
||||
"id": "amcc_will_training",
|
||||
"category": "motivation",
|
||||
"category": "focus_cognition",
|
||||
"title": "aMCC 의지력 훈련",
|
||||
"title_en": "aMCC Will-Training",
|
||||
"what": "'하기 싫지만 안전한' 일을 매일 1개 의도적 수행.",
|
||||
@@ -375,13 +421,16 @@
|
||||
"완료 후 'aMCC 1 rep' 라벨링."
|
||||
],
|
||||
"check": "오늘의 싫은 일 정의 / 수행 완료",
|
||||
"reference_ids": ["ref_doi_10_1016_j_cortex_2019_09_011", "ref_doi_10_1093_braincomms_fcac163"],
|
||||
"reference_ids": [
|
||||
"ref_doi_10_1016_j_cortex_2019_09_011",
|
||||
"ref_doi_10_1093_braincomms_fcac163"
|
||||
],
|
||||
"evidence_strength": "mechanistic",
|
||||
"source_doc": "huberman-protocols.md"
|
||||
},
|
||||
{
|
||||
"id": "digital_dopamine_detox",
|
||||
"category": "motivation",
|
||||
"category": "focus_cognition",
|
||||
"title": "디지털 디톡스",
|
||||
"title_en": "Digital Dopamine Detox",
|
||||
"what": "고자극 컨텐츠 차단 (쇼츠/릴스/포르노/SNS 스크롤).",
|
||||
@@ -395,13 +444,16 @@
|
||||
"종료 후 첫 사용 5분 제한."
|
||||
],
|
||||
"check": "24h 차단 / 첫 사용 5분 이내",
|
||||
"reference_ids": ["ref_podcast_hl_39_dopamine", "ref_book_lembke_dopamine_nation"],
|
||||
"reference_ids": [
|
||||
"ref_podcast_hl_39_dopamine",
|
||||
"ref_book_lembke_dopamine_nation"
|
||||
],
|
||||
"evidence_strength": "expert_opinion",
|
||||
"source_doc": "huberman-protocols.md"
|
||||
},
|
||||
{
|
||||
"id": "three_phases_of_day",
|
||||
"category": "habit",
|
||||
"category": "focus_cognition",
|
||||
"title": "하루 3 위상",
|
||||
"title_en": "Three Phases of the Day",
|
||||
"what": "신경전달물질 우세 시간대에 작업 배치.",
|
||||
@@ -415,13 +467,16 @@
|
||||
"Phase 3: 회상·정리·디지털 OFF."
|
||||
],
|
||||
"check": "가장 어려운 일 Phase 1 배치 / Phase 3 자극적 디지털 X",
|
||||
"reference_ids": ["ref_podcast_hl_28_daily_tools", "ref_podcast_hl_53_habits"],
|
||||
"reference_ids": [
|
||||
"ref_podcast_hl_28_daily_tools",
|
||||
"ref_podcast_hl_53_habits"
|
||||
],
|
||||
"evidence_strength": "expert_opinion",
|
||||
"source_doc": "huberman-protocols.md"
|
||||
},
|
||||
{
|
||||
"id": "task_bracketing",
|
||||
"category": "habit",
|
||||
"category": "focus_cognition",
|
||||
"title": "시간·맥락 브래킷",
|
||||
"title_en": "Task Bracketing",
|
||||
"what": "새 습관을 같은 시간 + 같은 직전/직후 행동으로 고정.",
|
||||
@@ -436,13 +491,16 @@
|
||||
"6주간 같은 위치 유지."
|
||||
],
|
||||
"check": "직전 브래킷 정의 / 직후 브래킷 정의 / 오늘 같은 시각 실행",
|
||||
"reference_ids": ["ref_podcast_hl_53_habits", "ref_doi_10_1146_annurev_neuro_29_051605_112851"],
|
||||
"reference_ids": [
|
||||
"ref_podcast_hl_53_habits",
|
||||
"ref_doi_10_1146_annurev_neuro_29_051605_112851"
|
||||
],
|
||||
"evidence_strength": "mechanistic",
|
||||
"source_doc": "huberman-protocols.md"
|
||||
},
|
||||
{
|
||||
"id": "six_week_integration_rule",
|
||||
"category": "habit",
|
||||
"category": "focus_cognition",
|
||||
"title": "6주 자동화 규칙",
|
||||
"title_en": "6-Week Integration Rule",
|
||||
"what": "'6주 동안 주 6/7'을 자동화 기준으로.",
|
||||
@@ -457,13 +515,16 @@
|
||||
"6주 후 자동화 자가 평가."
|
||||
],
|
||||
"check": "트래커 존재 / 이번 주 6/7 / 결석 후 다음 날 복귀",
|
||||
"reference_ids": ["ref_podcast_hl_53_habits", "ref_doi_10_1002_ejsp_674"],
|
||||
"reference_ids": [
|
||||
"ref_podcast_hl_53_habits",
|
||||
"ref_doi_10_1002_ejsp_674"
|
||||
],
|
||||
"evidence_strength": "observational",
|
||||
"source_doc": "huberman-protocols.md"
|
||||
},
|
||||
{
|
||||
"id": "limbic_friction_scoring",
|
||||
"category": "habit",
|
||||
"category": "focus_cognition",
|
||||
"title": "마찰 점수화",
|
||||
"title_en": "Limbic Friction Scoring",
|
||||
"what": "각 습관에 0~10 마찰 점수.",
|
||||
@@ -477,13 +538,15 @@
|
||||
"평균 3↓ 2주 유지 → 자동화 진입."
|
||||
],
|
||||
"check": "friction 기록 / 주간 평균 확인",
|
||||
"reference_ids": ["ref_podcast_hl_53_habits"],
|
||||
"reference_ids": [
|
||||
"ref_podcast_hl_53_habits"
|
||||
],
|
||||
"evidence_strength": "expert_opinion",
|
||||
"source_doc": "huberman-protocols.md"
|
||||
},
|
||||
{
|
||||
"id": "new_habit_onboarding",
|
||||
"category": "habit",
|
||||
"category": "focus_cognition",
|
||||
"title": "신규 습관 도입 규칙",
|
||||
"title_en": "New Habit Onboarding",
|
||||
"what": "동시 1~3개, 최소 단위로 시작.",
|
||||
@@ -498,13 +561,16 @@
|
||||
"6주 후 평가 → 다음 1~3개."
|
||||
],
|
||||
"check": "현재 신규 ≤ 3개 / 각 습관 최소 단위 정의",
|
||||
"reference_ids": ["ref_podcast_hl_53_habits", "ref_book_fogg_tiny_habits"],
|
||||
"reference_ids": [
|
||||
"ref_podcast_hl_53_habits",
|
||||
"ref_book_fogg_tiny_habits"
|
||||
],
|
||||
"evidence_strength": "expert_opinion",
|
||||
"source_doc": "huberman-protocols.md"
|
||||
},
|
||||
{
|
||||
"id": "habit_breaking_via_replacement",
|
||||
"category": "habit",
|
||||
"category": "focus_cognition",
|
||||
"title": "대체 행동으로 끊기",
|
||||
"title_en": "Habit Breaking via Replacement",
|
||||
"what": "트리거 직후 호환 불가능한 대체 행동 삽입.",
|
||||
@@ -518,13 +584,16 @@
|
||||
"6주 평가."
|
||||
],
|
||||
"check": "트리거 식별 / 대체 행동 정의 / 오늘 1회+ 성공",
|
||||
"reference_ids": ["ref_podcast_hl_53_habits", "ref_doi_10_1037_0033_295X_114_4_843"],
|
||||
"reference_ids": [
|
||||
"ref_podcast_hl_53_habits",
|
||||
"ref_doi_10_1037_0033_295X_114_4_843"
|
||||
],
|
||||
"evidence_strength": "mechanistic",
|
||||
"source_doc": "huberman-protocols.md"
|
||||
},
|
||||
{
|
||||
"id": "ultradian_focus_block",
|
||||
"category": "learning",
|
||||
"category": "focus_cognition",
|
||||
"title": "90분 Ultradian 집중 블록",
|
||||
"title_en": "90-min Ultradian Focus Block",
|
||||
"what": "90분 deep work + 10~20분 휴식.",
|
||||
@@ -539,13 +608,17 @@
|
||||
"종료 후 10~20분 NSDR 또는 산책. SNS X."
|
||||
],
|
||||
"check": "진입 의식 / 단일 과제 / 휴식이 도파민 자극 아님",
|
||||
"reference_ids": ["ref_podcast_hl_8_learning", "ref_podcast_hl_88_focus", "ref_doi_10_1093_sleep_5_4_311"],
|
||||
"reference_ids": [
|
||||
"ref_podcast_hl_8_learning",
|
||||
"ref_podcast_hl_88_focus",
|
||||
"ref_doi_10_1093_sleep_5_4_311"
|
||||
],
|
||||
"evidence_strength": "mechanistic",
|
||||
"source_doc": "huberman-protocols.md"
|
||||
},
|
||||
{
|
||||
"id": "visual_focus_priming",
|
||||
"category": "learning",
|
||||
"category": "focus_cognition",
|
||||
"title": "시각 집중 점화",
|
||||
"title_en": "Visual Focus Priming",
|
||||
"what": "한 지점 응시로 전두엽 집중 회로 활성.",
|
||||
@@ -560,13 +633,17 @@
|
||||
],
|
||||
"check": "30초 이상 응시 후 진입",
|
||||
"caution": "narrow-aperture LC 활성은 Huberman 통합 모델 — 근거 ⚠️.",
|
||||
"reference_ids": ["ref_podcast_hl_6_focus_brain", "ref_podcast_hl_88_focus", "ref_doi_10_1146_annurev_neuro_28_061604_135709"],
|
||||
"reference_ids": [
|
||||
"ref_podcast_hl_6_focus_brain",
|
||||
"ref_podcast_hl_88_focus",
|
||||
"ref_doi_10_1146_annurev_neuro_28_061604_135709"
|
||||
],
|
||||
"evidence_strength": "mechanistic",
|
||||
"source_doc": "huberman-protocols.md"
|
||||
},
|
||||
{
|
||||
"id": "post_learning_nsdr",
|
||||
"category": "learning",
|
||||
"category": "recovery_stress",
|
||||
"title": "학습 직후 NSDR",
|
||||
"title_en": "Post-Learning NSDR",
|
||||
"what": "학습 직후 10분 NSDR.",
|
||||
@@ -579,13 +656,16 @@
|
||||
"종료 후 5분 메모로 재진술."
|
||||
],
|
||||
"check": "학습 직후 SNS 안 봄 / NSDR 10분 / 메모 재진술",
|
||||
"reference_ids": ["ref_podcast_hl_8_learning", "ref_doi_10_1177_0956797612441220"],
|
||||
"reference_ids": [
|
||||
"ref_podcast_hl_8_learning",
|
||||
"ref_doi_10_1177_0956797612441220"
|
||||
],
|
||||
"evidence_strength": "strong_rct",
|
||||
"source_doc": "huberman-protocols.md"
|
||||
},
|
||||
{
|
||||
"id": "protein_first",
|
||||
"category": "diet",
|
||||
"category": "nutrition",
|
||||
"title": "단백질 우선",
|
||||
"title_en": "Protein-First",
|
||||
"what": "첫 식사 + 일일 총량을 단백질 기준으로 잡는다.",
|
||||
@@ -600,13 +680,17 @@
|
||||
],
|
||||
"check": "첫 식사 단백질 ≥ 30g",
|
||||
"min_dose_for_start": "첫 끼 단백질 +10g",
|
||||
"reference_ids": ["ref_doi_10_1139_apnm_2015_0550", "ref_doi_10_1136_bjsports_2017_097608", "ref_doi_10_3945_ajcn_114_084038"],
|
||||
"reference_ids": [
|
||||
"ref_doi_10_1139_apnm_2015_0550",
|
||||
"ref_doi_10_1136_bjsports_2017_097608",
|
||||
"ref_doi_10_3945_ajcn_114_084038"
|
||||
],
|
||||
"evidence_strength": "meta_analysis",
|
||||
"source_doc": "diet-protocols.md"
|
||||
},
|
||||
{
|
||||
"id": "refined_sugar_minimize",
|
||||
"category": "diet",
|
||||
"category": "nutrition",
|
||||
"title": "정제당·액상 과당 최소화",
|
||||
"title_en": "Minimize Refined / Liquid Sugar",
|
||||
"what": "첨가당과 액상 과당(과일 주스 포함) 우선 줄임. 통과일·통곡물은 별개.",
|
||||
@@ -620,13 +704,17 @@
|
||||
"라벨 'added sugar' 확인 (가공식품 1주일 1회 인벤토리)."
|
||||
],
|
||||
"check": "오늘 액상 과당 0",
|
||||
"reference_ids": ["ref_url_who_sugar_2015", "ref_doi_10_1002_oby_21371", "ref_doi_10_1038_sj_ijo_0801229"],
|
||||
"reference_ids": [
|
||||
"ref_url_who_sugar_2015",
|
||||
"ref_doi_10_1002_oby_21371",
|
||||
"ref_doi_10_1038_sj_ijo_0801229"
|
||||
],
|
||||
"evidence_strength": "meta_analysis",
|
||||
"source_doc": "diet-protocols.md"
|
||||
},
|
||||
{
|
||||
"id": "fiber_intake",
|
||||
"category": "diet",
|
||||
"category": "nutrition",
|
||||
"title": "식이섬유",
|
||||
"title_en": "Fiber Intake",
|
||||
"what": "통곡물·콩류·채소·통과일에서 일일 25~38g.",
|
||||
@@ -639,13 +727,17 @@
|
||||
"갑자기 늘리면 가스/팽만 → 2~3주 점진 증가."
|
||||
],
|
||||
"check": "오늘 채소 ≥ 3 종류",
|
||||
"reference_ids": ["ref_doi_10_1016_S0140_6736_18_31809_9", "ref_doi_10_1016_j_cell_2021_06_019", "ref_doi_10_1038_s41579_019_0191_8"],
|
||||
"reference_ids": [
|
||||
"ref_doi_10_1016_S0140_6736_18_31809_9",
|
||||
"ref_doi_10_1016_j_cell_2021_06_019",
|
||||
"ref_doi_10_1038_s41579_019_0191_8"
|
||||
],
|
||||
"evidence_strength": "meta_analysis",
|
||||
"source_doc": "diet-protocols.md"
|
||||
},
|
||||
{
|
||||
"id": "water_electrolytes",
|
||||
"category": "diet",
|
||||
"category": "nutrition",
|
||||
"title": "수분·전해질",
|
||||
"title_en": "Water & Electrolytes",
|
||||
"what": "기상 후 물 500ml + 소금 한 꼬집. 일일 총 수분 체중 × 30~35 ml.",
|
||||
@@ -667,7 +759,7 @@
|
||||
},
|
||||
{
|
||||
"id": "meal_timing_tre",
|
||||
"category": "diet",
|
||||
"category": "nutrition",
|
||||
"title": "식사 시점 / 시간 제한",
|
||||
"title_en": "Meal Timing / TRE",
|
||||
"what": "마지막 식사를 취침 2~3시간 전 종료. (선택) 16:8 등 TRE.",
|
||||
@@ -680,13 +772,16 @@
|
||||
"TRE 시작 시 14:10 → 16:8 점진."
|
||||
],
|
||||
"check": "마지막 식사 취침 2~3h 전 종료",
|
||||
"reference_ids": ["ref_doi_10_1016_j_cmet_2020_06_018", "ref_doi_10_1038_ijo_2012_229"],
|
||||
"reference_ids": [
|
||||
"ref_doi_10_1016_j_cmet_2020_06_018",
|
||||
"ref_doi_10_1038_ijo_2012_229"
|
||||
],
|
||||
"evidence_strength": "strong_rct",
|
||||
"source_doc": "diet-protocols.md"
|
||||
},
|
||||
{
|
||||
"id": "omega3",
|
||||
"category": "diet",
|
||||
"category": "nutrition",
|
||||
"title": "Omega-3 (EPA/DHA)",
|
||||
"title_en": "Omega-3",
|
||||
"what": "지방 생선 주 2~3회 또는 EPA 보충 1~2g/일.",
|
||||
@@ -699,7 +794,9 @@
|
||||
"항응고제 복용 시 의사 상담."
|
||||
],
|
||||
"check": "주 단위 weekly reflection",
|
||||
"reference_ids": ["ref_doi_10_1016_j_mayocp_2020_08_034"],
|
||||
"reference_ids": [
|
||||
"ref_doi_10_1016_j_mayocp_2020_08_034"
|
||||
],
|
||||
"evidence_strength": "meta_analysis",
|
||||
"source_doc": "diet-protocols.md"
|
||||
}
|
||||
|
||||
171
app/lib/ai/tools/catalog_tools.dart
Normal file
171
app/lib/ai/tools/catalog_tools.dart
Normal file
@@ -0,0 +1,171 @@
|
||||
import '../../domain/catalog/catalog_item.dart';
|
||||
import '../../domain/catalog/display_category.dart';
|
||||
import 'tool_definition.dart';
|
||||
import 'tool_envelope.dart';
|
||||
|
||||
/// Read-only catalog tools. `search_catalog` returns trimmed list rows;
|
||||
/// `query_protocol` returns the full record. Splitting keeps `search` cheap
|
||||
/// in tokens (OQ-2) and the model fetches detail only when needed.
|
||||
|
||||
final ToolDefinition searchCatalogTool = ToolDefinition(
|
||||
name: 'search_catalog',
|
||||
description: '카테고리/키워드로 Huberman 프로토콜 카탈로그를 검색한다. '
|
||||
'결과는 id + 제목 + 60자 요약만. 상세는 query_protocol 로.',
|
||||
parametersSchema: const {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'category': {
|
||||
'type': 'string',
|
||||
'description': '카테고리 키 (lightCircadian, sleep, movement, nutrition, '
|
||||
'focusCognition, recoveryStress, emotionRelationship, breakHabit). '
|
||||
'생략하면 전체.',
|
||||
},
|
||||
'keyword': {
|
||||
'type': 'string',
|
||||
'description': '제목/요약에 포함될 키워드. 생략 가능.',
|
||||
},
|
||||
'limit': {
|
||||
'type': 'integer',
|
||||
'description': '최대 결과 개수 (1~10, 기본 10).',
|
||||
},
|
||||
},
|
||||
'required': [],
|
||||
},
|
||||
handler: _searchCatalogHandler,
|
||||
);
|
||||
|
||||
final ToolDefinition queryProtocolTool = ToolDefinition(
|
||||
name: 'query_protocol',
|
||||
description: '카탈로그 ID 로 프로토콜 상세를 조회한다. '
|
||||
'Protocol/Break/Diet 종류에 따라 다른 필드 셋을 반환.',
|
||||
parametersSchema: const {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'id': {
|
||||
'type': 'string',
|
||||
'description': '카탈로그 항목 ID (예: morning_sunlight).',
|
||||
},
|
||||
},
|
||||
'required': ['id'],
|
||||
},
|
||||
handler: _queryProtocolHandler,
|
||||
);
|
||||
|
||||
Future<ToolResult> _searchCatalogHandler(
|
||||
Map<String, dynamic> args, ToolDeps deps) async {
|
||||
final categoryRaw = args['category'];
|
||||
final keywordRaw = args['keyword'];
|
||||
final limitRaw = args['limit'];
|
||||
|
||||
DisplayCategory? category;
|
||||
if (categoryRaw is String && categoryRaw.isNotEmpty) {
|
||||
category = DisplayCategory.values
|
||||
.where((c) => c.name == categoryRaw)
|
||||
.firstOrNull;
|
||||
if (category == null) {
|
||||
return ToolErr('validation',
|
||||
'알 수 없는 카테고리: $categoryRaw. 허용값: ${DisplayCategory.values.map((c) => c.name).join(', ')}');
|
||||
}
|
||||
}
|
||||
|
||||
final keyword = (keywordRaw is String) ? keywordRaw.trim() : '';
|
||||
if (keyword.length > 50) {
|
||||
return const ToolErr('validation', 'keyword 는 50자 이하여야 합니다.');
|
||||
}
|
||||
|
||||
var limit = 10;
|
||||
if (limitRaw is int) {
|
||||
limit = limitRaw;
|
||||
} else if (limitRaw is num) {
|
||||
limit = limitRaw.toInt();
|
||||
}
|
||||
if (limit < 1 || limit > 10) {
|
||||
return const ToolErr('validation', 'limit 는 1~10 사이여야 합니다.');
|
||||
}
|
||||
|
||||
final all = await deps.catalog.all();
|
||||
Iterable<CatalogItem> filtered = all;
|
||||
if (category != null) {
|
||||
filtered = filtered.where((it) => it.displayCategory == category);
|
||||
}
|
||||
if (keyword.isNotEmpty) {
|
||||
final lk = keyword.toLowerCase();
|
||||
filtered = filtered.where((it) =>
|
||||
it.title.toLowerCase().contains(lk) ||
|
||||
it.summary.toLowerCase().contains(lk));
|
||||
}
|
||||
final results = filtered.take(limit).toList();
|
||||
|
||||
return ToolOk({
|
||||
'count': results.length,
|
||||
'items': results
|
||||
.map((it) => {
|
||||
'id': it.id,
|
||||
'title': it.title,
|
||||
'category': it.displayCategory.name,
|
||||
'summary': it.summary,
|
||||
})
|
||||
.toList(),
|
||||
});
|
||||
}
|
||||
|
||||
Future<ToolResult> _queryProtocolHandler(
|
||||
Map<String, dynamic> args, ToolDeps deps) async {
|
||||
final id = args['id'];
|
||||
if (id is! String || id.isEmpty) {
|
||||
return const ToolErr('validation', 'id 는 비어있지 않은 문자열이어야 합니다.');
|
||||
}
|
||||
final item = await deps.catalog.byId(id);
|
||||
if (item == null) {
|
||||
return ToolErr('not_found', '카탈로그에서 \'$id\' 를 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
return ToolOk(_serializeItem(item));
|
||||
}
|
||||
|
||||
Map<String, dynamic> _serializeItem(CatalogItem item) {
|
||||
final base = {
|
||||
'id': item.id,
|
||||
'title': item.title,
|
||||
if (item.titleEn != null) 'title_en': item.titleEn,
|
||||
'category': item.displayCategory.name,
|
||||
'summary': item.summary,
|
||||
if (item.evidenceStrength != null)
|
||||
'evidence_strength': item.evidenceStrength,
|
||||
'reference_ids': item.referenceIds,
|
||||
};
|
||||
switch (item) {
|
||||
case ProtocolCatalogItem p:
|
||||
return {
|
||||
...base,
|
||||
'kind': 'protocol',
|
||||
'what': p.what,
|
||||
'when': p.whenText,
|
||||
'dose': p.dose,
|
||||
'why': p.why,
|
||||
'how': p.how,
|
||||
'check': p.checkText,
|
||||
if (p.caution != null) 'caution': p.caution,
|
||||
if (p.minDoseForStart != null) 'min_dose_for_start': p.minDoseForStart,
|
||||
};
|
||||
case BreakCatalogItem b:
|
||||
return {
|
||||
...base,
|
||||
'kind': 'break',
|
||||
'break_category': b.breakCategory,
|
||||
'huberman_summary': b.hubermanSummary,
|
||||
'phases': b.phases,
|
||||
'default_common_frames': b.defaultCommonFrames,
|
||||
if (b.medicalWarning != null) 'medical_warning': b.medicalWarning,
|
||||
};
|
||||
case DietCatalogItem d:
|
||||
return {
|
||||
...base,
|
||||
'kind': 'diet',
|
||||
'name': d.name,
|
||||
'core': d.core,
|
||||
if (d.koreanContextFit != null)
|
||||
'korean_context_fit': d.koreanContextFit,
|
||||
};
|
||||
}
|
||||
}
|
||||
75
app/lib/ai/tools/confirm_gate.dart
Normal file
75
app/lib/ai/tools/confirm_gate.dart
Normal file
@@ -0,0 +1,75 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'tool_definition.dart';
|
||||
|
||||
/// Modal Confirm gate for destructive tools (ADR-0005 §OQ-3).
|
||||
///
|
||||
/// Shown by [ToolDispatcher] right before invoking a destructive handler.
|
||||
/// Returns `true` only if the user explicitly tapped the confirm action;
|
||||
/// outside-tap / back-press / unmounted-context all return `false`.
|
||||
class ConfirmGate {
|
||||
const ConfirmGate();
|
||||
|
||||
Future<bool> show(
|
||||
BuildContext context,
|
||||
ToolDefinition tool,
|
||||
Map<String, dynamic> args,
|
||||
) async {
|
||||
if (!context.mounted) return false;
|
||||
final summary = tool.summarize?.call(args) ?? _fallbackSummary(args);
|
||||
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
builder: (ctx) {
|
||||
final theme = Theme.of(ctx);
|
||||
return AlertDialog(
|
||||
title: const Text('이 작업을 수행할까요?'),
|
||||
// SingleChildScrollView 로 감싸 좁은 모바일 화면에서 description 이
|
||||
// 길거나 summary 가 multi-line 일 때 잘리지 않고 스크롤되게 한다.
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(tool.description, style: theme.textTheme.bodyMedium),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(summary),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
FilledButton(
|
||||
autofocus: true,
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: const Text('수행'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
String _fallbackSummary(Map<String, dynamic> args) {
|
||||
try {
|
||||
return const JsonEncoder.withIndent(' ').convert(args);
|
||||
} catch (_) {
|
||||
return args.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
210
app/lib/ai/tools/habit_tools.dart
Normal file
210
app/lib/ai/tools/habit_tools.dart
Normal file
@@ -0,0 +1,210 @@
|
||||
import '../../core/time.dart';
|
||||
import '../../data/db/daos/habit_dao.dart';
|
||||
import '../../domain/catalog/catalog_item.dart';
|
||||
import '../../domain/frame/validate_frame_level.dart';
|
||||
import '../../domain/models/habit.dart';
|
||||
import '../../domain/rules/active_habit_quota.dart';
|
||||
import 'tool_definition.dart';
|
||||
import 'tool_envelope.dart';
|
||||
|
||||
final ToolDefinition addHabitTool = ToolDefinition(
|
||||
name: 'add_habit',
|
||||
description: '카탈로그 항목 1개를 사용자의 활성 습관으로 추가한다. '
|
||||
'frame_level 은 L2 (조건부 긍정) 또는 L3 (정체성) 만 허용. '
|
||||
'L0/L1 (회피·부정 명령) 은 R3 위반으로 거부됨.',
|
||||
parametersSchema: const {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'protocol_id': {
|
||||
'type': 'string',
|
||||
'description': '카탈로그 항목 ID (search_catalog 결과의 id).',
|
||||
},
|
||||
'frame_level': {
|
||||
'type': 'string',
|
||||
'description': 'L2 또는 L3.',
|
||||
},
|
||||
'framed_text': {
|
||||
'type': 'string',
|
||||
'description': '사용자에게 보일 1줄 문구 (1~200자).',
|
||||
},
|
||||
'anchor_when': {
|
||||
'type': 'string',
|
||||
'description': '시점 트리거 (예: "기상 후"). 선택.',
|
||||
},
|
||||
'anchor_after_what': {
|
||||
'type': 'string',
|
||||
'description': '직전 행동 트리거 (예: "세수"). 선택.',
|
||||
},
|
||||
'dose_text': {
|
||||
'type': 'string',
|
||||
'description': '용량/강도 문구 (예: "5분"). 선택.',
|
||||
},
|
||||
},
|
||||
'required': ['protocol_id', 'frame_level', 'framed_text'],
|
||||
},
|
||||
isDestructive: true,
|
||||
summarize: (args) {
|
||||
final text = args['framed_text'] ?? args['protocol_id'];
|
||||
final lv = args['frame_level'] ?? '?';
|
||||
return '\'$text\' ($lv 프레임) 를 활성 습관으로 추가합니다.';
|
||||
},
|
||||
handler: _addHabitHandler,
|
||||
);
|
||||
|
||||
final ToolDefinition listActiveHabitsTool = ToolDefinition(
|
||||
name: 'list_active_habits',
|
||||
description: '현재 활성 상태인 습관 목록을 반환한다. '
|
||||
'R3 quota 점검 또는 사용자 현황 안내 전 호출.',
|
||||
parametersSchema: const {
|
||||
'type': 'object',
|
||||
'properties': {},
|
||||
'required': [],
|
||||
},
|
||||
handler: _listActiveHabitsHandler,
|
||||
);
|
||||
|
||||
Future<ToolResult> _addHabitHandler(
|
||||
Map<String, dynamic> args, ToolDeps deps) async {
|
||||
// 1. 의미 검증.
|
||||
final protocolId = args['protocol_id'];
|
||||
if (protocolId is! String || protocolId.isEmpty) {
|
||||
return const ToolErr('validation', 'protocol_id 는 비어있지 않은 문자열이어야 합니다.');
|
||||
}
|
||||
final frameLevelRaw = args['frame_level'];
|
||||
if (frameLevelRaw is! String) {
|
||||
return const ToolErr('validation', 'frame_level 은 L2 또는 L3 이어야 합니다.');
|
||||
}
|
||||
final frameLevel = FrameLevelX.fromDb(frameLevelRaw.toUpperCase());
|
||||
if (frameLevel == null ||
|
||||
frameLevel == FrameLevel.l0 ||
|
||||
frameLevel == FrameLevel.l1) {
|
||||
return const ToolErr(
|
||||
'validation',
|
||||
'frame_level 은 L2 (조건부 긍정) 또는 L3 (정체성) 이어야 합니다. '
|
||||
'L0/L1 은 코끼리 회피 문제로 거부됩니다.',
|
||||
);
|
||||
}
|
||||
final framedTextRaw = args['framed_text'];
|
||||
if (framedTextRaw is! String) {
|
||||
return const ToolErr('validation', 'framed_text 가 누락됐습니다.');
|
||||
}
|
||||
final framedText = framedTextRaw.trim();
|
||||
if (framedText.isEmpty) {
|
||||
return const ToolErr('validation', 'framed_text 가 비어있습니다.');
|
||||
}
|
||||
if (framedText.length > 200) {
|
||||
return const ToolErr('validation', 'framed_text 는 200자 이하여야 합니다.');
|
||||
}
|
||||
|
||||
// 2. 카탈로그 lookup → habitType 결정.
|
||||
final item = await deps.catalog.byId(protocolId);
|
||||
if (item == null) {
|
||||
return ToolErr('not_found', '카탈로그에서 \'$protocolId\' 를 찾을 수 없습니다.');
|
||||
}
|
||||
final HabitType habitType;
|
||||
switch (item) {
|
||||
case ProtocolCatalogItem _:
|
||||
habitType = HabitType.build;
|
||||
case BreakCatalogItem _:
|
||||
habitType = HabitType.breakHabit;
|
||||
case DietCatalogItem _:
|
||||
habitType = HabitType.build;
|
||||
}
|
||||
|
||||
// 3. R7 회피 키워드.
|
||||
final hits = detectAvoidanceKeywords(framedText, deps.framePatterns);
|
||||
if (hits.isNotEmpty) {
|
||||
final first = hits.first;
|
||||
return ToolErr(
|
||||
'r7_avoidance',
|
||||
'\'${first.keyword}\' 같은 회피 표현이 감지됐어요. '
|
||||
'\'${first.source.l2Suggestion}\' 같은 긍정 표현으로 다시 시도해주세요.',
|
||||
);
|
||||
}
|
||||
|
||||
// 4. R3 quota.
|
||||
final count = await deps.habitDao
|
||||
.countActive(userId: deps.userId, type: habitType);
|
||||
final quota = judgeActiveHabitQuota(
|
||||
type: habitType,
|
||||
currentActiveCount: count,
|
||||
);
|
||||
if (!quota.allowed) {
|
||||
return ToolErr('r3_quota', quota.reason);
|
||||
}
|
||||
|
||||
// 5. Draft 빌드.
|
||||
final anchorWhen = _trimmedOrNull(args['anchor_when']);
|
||||
final anchorAfterWhat = _trimmedOrNull(args['anchor_after_what']);
|
||||
final doseText = _trimmedOrNull(args['dose_text']);
|
||||
final variants = doseText == null
|
||||
? const <VariantDraft>[]
|
||||
: [
|
||||
VariantDraft(
|
||||
label: '기본',
|
||||
doseText: doseText,
|
||||
isMinimum: false,
|
||||
sortOrder: 0,
|
||||
),
|
||||
];
|
||||
|
||||
final draft = HabitDraft(
|
||||
userId: deps.userId,
|
||||
type: habitType,
|
||||
title: item.title,
|
||||
protocolId: habitType == HabitType.build ? protocolId : null,
|
||||
breakProtocolId: habitType == HabitType.breakHabit ? protocolId : null,
|
||||
frameLevel: frameLevel,
|
||||
frameFramedText: framedText,
|
||||
anchorWhen: anchorWhen,
|
||||
anchorAfterWhat: anchorAfterWhat,
|
||||
startedAt: dateOnly(nowKst()),
|
||||
variants: variants,
|
||||
);
|
||||
|
||||
// 6. Insert (R8 XOR assert 는 dao 내부).
|
||||
try {
|
||||
final habitId = await deps.habitDao.insertWithVariants(draft);
|
||||
return ToolOk({
|
||||
'habit_id': habitId,
|
||||
'title': item.title,
|
||||
'type': habitType.dbValue,
|
||||
'frame_level': frameLevel.dbValue,
|
||||
});
|
||||
} on AssertionError catch (e) {
|
||||
return ToolErr('r8_xor', 'R8 XOR 위반: ${e.message}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<ToolResult> _listActiveHabitsHandler(
|
||||
Map<String, dynamic> args, ToolDeps deps) async {
|
||||
final habits = await deps.habitDao.activeHabitsForUser(deps.userId);
|
||||
final items = habits
|
||||
.map((h) => {
|
||||
'id': h.id,
|
||||
'title': h.title,
|
||||
'type': h.type,
|
||||
'frame_level': h.frameLevel,
|
||||
'framed_text': h.frameFramedText,
|
||||
'started_at': h.startedAt,
|
||||
if (h.protocolId != null) 'protocol_id': h.protocolId,
|
||||
if (h.breakProtocolId != null) 'break_protocol_id': h.breakProtocolId,
|
||||
})
|
||||
.toList();
|
||||
final buildCount = habits.where((h) => h.type == 'build').length;
|
||||
final breakCount = habits.where((h) => h.type == 'break').length;
|
||||
return ToolOk({
|
||||
'count': habits.length,
|
||||
'build_count': buildCount,
|
||||
'break_count': breakCount,
|
||||
'build_quota_remaining': kMaxActiveBuild - buildCount,
|
||||
'break_quota_remaining': kMaxActiveBreak - breakCount,
|
||||
'items': items,
|
||||
});
|
||||
}
|
||||
|
||||
String? _trimmedOrNull(dynamic v) {
|
||||
if (v is! String) return null;
|
||||
final t = v.trim();
|
||||
return t.isEmpty ? null : t;
|
||||
}
|
||||
54
app/lib/ai/tools/tool_definition.dart
Normal file
54
app/lib/ai/tools/tool_definition.dart
Normal file
@@ -0,0 +1,54 @@
|
||||
import '../../data/catalog/catalog_repository.dart';
|
||||
import '../../data/db/daos/habit_dao.dart';
|
||||
import '../../data/db/daos/tracker_dao.dart';
|
||||
import '../../domain/models/frame_pattern.dart';
|
||||
import 'tool_envelope.dart';
|
||||
|
||||
/// Shared dependencies for every tool handler.
|
||||
///
|
||||
/// Frame patterns are passed in pre-loaded (memo'd at provider level) so the
|
||||
/// R7 avoidance check doesn't reparse seed JSON on every tool call.
|
||||
class ToolDeps {
|
||||
final HabitDao habitDao;
|
||||
final TrackerDao trackerDao;
|
||||
final CatalogRepository catalog;
|
||||
final List<FramePatternModel> framePatterns;
|
||||
final String userId;
|
||||
|
||||
const ToolDeps({
|
||||
required this.habitDao,
|
||||
required this.trackerDao,
|
||||
required this.catalog,
|
||||
required this.framePatterns,
|
||||
required this.userId,
|
||||
});
|
||||
}
|
||||
|
||||
typedef ToolHandler =
|
||||
Future<ToolResult> Function(Map<String, dynamic> args, ToolDeps deps);
|
||||
|
||||
/// Single tool the model can call.
|
||||
///
|
||||
/// `parametersSchema` follows the draft-07 JSON Schema shape that
|
||||
/// flutter_gemma 0.16.5's `Tool.parameters` expects — see ADR-0005 (Dart is
|
||||
/// the schema source-of-truth).
|
||||
class ToolDefinition {
|
||||
final String name;
|
||||
final String description;
|
||||
final Map<String, dynamic> parametersSchema;
|
||||
final bool isDestructive;
|
||||
final ToolHandler handler;
|
||||
|
||||
/// Optional summariser used by `ConfirmGate` to render destructive args in
|
||||
/// a sentence rather than raw JSON. Read-only tools leave this null.
|
||||
final String Function(Map<String, dynamic> args)? summarize;
|
||||
|
||||
const ToolDefinition({
|
||||
required this.name,
|
||||
required this.description,
|
||||
required this.parametersSchema,
|
||||
required this.handler,
|
||||
this.isDestructive = false,
|
||||
this.summarize,
|
||||
});
|
||||
}
|
||||
104
app/lib/ai/tools/tool_dispatcher.dart
Normal file
104
app/lib/ai/tools/tool_dispatcher.dart
Normal file
@@ -0,0 +1,104 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'confirm_gate.dart';
|
||||
import 'tool_definition.dart';
|
||||
import 'tool_envelope.dart';
|
||||
import 'tool_registry.dart';
|
||||
|
||||
/// Routes a single `FunctionCallResponse` from the LLM to the matching
|
||||
/// handler. See design `fn-tool_dispatcher.md`.
|
||||
///
|
||||
/// `dispatch` never throws — every failure path returns a `ToolResult`.
|
||||
class ToolDispatcher {
|
||||
final ToolRegistry registry;
|
||||
final ConfirmGate confirmGate;
|
||||
|
||||
ToolDispatcher({
|
||||
required this.registry,
|
||||
ConfirmGate? confirmGate,
|
||||
}) : confirmGate = confirmGate ?? const ConfirmGate();
|
||||
|
||||
Future<ToolResult> dispatch({
|
||||
required String toolName,
|
||||
required Map<String, dynamic> rawArgs,
|
||||
required BuildContext? confirmContext,
|
||||
required ToolDeps deps,
|
||||
}) async {
|
||||
// 1. Lookup.
|
||||
final tool = registry.byName(toolName);
|
||||
if (tool == null) {
|
||||
return ToolErr('unknown_tool', '알 수 없는 도구: $toolName');
|
||||
}
|
||||
|
||||
// 2. Validate against schema.
|
||||
final validation = _validateArgs(tool.parametersSchema, rawArgs);
|
||||
if (validation != null) {
|
||||
return ToolErr('validation', '인자 오류: $validation');
|
||||
}
|
||||
|
||||
// 3. Destructive → Confirm gate.
|
||||
if (tool.isDestructive) {
|
||||
if (confirmContext == null) {
|
||||
return const ToolCancelled();
|
||||
}
|
||||
final ok = await confirmGate.show(confirmContext, tool, rawArgs);
|
||||
if (!ok) return const ToolCancelled();
|
||||
}
|
||||
|
||||
// 4. Run handler.
|
||||
try {
|
||||
return await tool.handler(rawArgs, deps);
|
||||
} catch (e) {
|
||||
return ToolErr('handler_error', '도구 실행 실패: ${e.runtimeType}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal JSON-schema-ish validator covering only what our tools use:
|
||||
/// - object root with `properties` + optional `required`
|
||||
/// - per-property `type` ∈ {string, integer, number, boolean, object, array}
|
||||
///
|
||||
/// Returns null on success, a short error message on failure. Extra keys are
|
||||
/// allowed (model hallucination tolerated; logged at call site if needed).
|
||||
String? _validateArgs(Map<String, dynamic> schema, Map<String, dynamic> args) {
|
||||
final required = schema['required'];
|
||||
if (required is List) {
|
||||
for (final field in required) {
|
||||
if (field is String && !args.containsKey(field)) {
|
||||
return '필수 필드 \'$field\' 가 없습니다.';
|
||||
}
|
||||
}
|
||||
}
|
||||
final props = schema['properties'];
|
||||
if (props is! Map) return null;
|
||||
for (final entry in args.entries) {
|
||||
final propSchema = props[entry.key];
|
||||
if (propSchema is! Map) continue; // unknown key — tolerate
|
||||
final expected = propSchema['type'];
|
||||
if (expected is! String) continue;
|
||||
final v = entry.value;
|
||||
if (!_matchesType(expected, v)) {
|
||||
return '\'${entry.key}\' 타입 불일치 (기대=$expected, 실제=${v.runtimeType})';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
bool _matchesType(String expected, dynamic v) {
|
||||
switch (expected) {
|
||||
case 'string':
|
||||
return v is String;
|
||||
case 'integer':
|
||||
return v is int;
|
||||
case 'number':
|
||||
return v is num;
|
||||
case 'boolean':
|
||||
return v is bool;
|
||||
case 'object':
|
||||
return v is Map;
|
||||
case 'array':
|
||||
return v is List;
|
||||
default:
|
||||
return true; // unknown type — passthrough
|
||||
}
|
||||
}
|
||||
63
app/lib/ai/tools/tool_envelope.dart
Normal file
63
app/lib/ai/tools/tool_envelope.dart
Normal file
@@ -0,0 +1,63 @@
|
||||
import 'dart:convert';
|
||||
|
||||
/// Tool execution result. See design fn-tool_dispatcher.md §4.
|
||||
///
|
||||
/// Always JSON-serialisable so the model can consume it. `toJson()` returns a
|
||||
/// shape with a stable `status` discriminator — easier for the LLM to parse
|
||||
/// than relying on key presence.
|
||||
sealed class ToolResult {
|
||||
const ToolResult();
|
||||
Map<String, dynamic> toJson();
|
||||
}
|
||||
|
||||
final class ToolOk extends ToolResult {
|
||||
final Map<String, dynamic> data;
|
||||
const ToolOk(this.data);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => {'status': 'ok', 'data': data};
|
||||
}
|
||||
|
||||
final class ToolErr extends ToolResult {
|
||||
final String code;
|
||||
final String reason;
|
||||
const ToolErr(this.code, this.reason);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() =>
|
||||
{'status': 'error', 'code': code, 'reason': reason};
|
||||
}
|
||||
|
||||
final class ToolCancelled extends ToolResult {
|
||||
const ToolCancelled();
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() =>
|
||||
{'status': 'cancelled', 'reason': 'user did not confirm'};
|
||||
}
|
||||
|
||||
/// Encode a [ToolResult] to a JSON string ≤ [maxBytes].
|
||||
///
|
||||
/// Tool result token budget (ADR-0005 / OQ-2): keep model context bounded.
|
||||
/// If serialised payload exceeds [maxBytes], we replace the tail of the data
|
||||
/// field with a truncation hint instead of letting the LLM blow its window.
|
||||
String encodeToolResult(ToolResult result, {int maxBytes = 2048}) {
|
||||
final encoded = jsonEncode(result.toJson());
|
||||
if (encoded.length <= maxBytes) return encoded;
|
||||
|
||||
// Truncate strategy: only ToolOk has unbounded payload. Replace data with
|
||||
// a hint pointing at follow-up tools. Errors/cancellations are always small.
|
||||
if (result is ToolOk) {
|
||||
final hint = {
|
||||
'status': 'ok',
|
||||
'data': {
|
||||
'_truncated': true,
|
||||
'_hint': '결과가 ${encoded.length} 바이트로 잘렸습니다. '
|
||||
'구체 ID 가 필요하면 query_protocol 같은 단건 조회 도구를 사용하세요.',
|
||||
},
|
||||
};
|
||||
return jsonEncode(hint);
|
||||
}
|
||||
// Defensive: if some future ToolResult adds bulk, fall back to hard cut.
|
||||
return encoded.substring(0, maxBytes);
|
||||
}
|
||||
33
app/lib/ai/tools/tool_registry.dart
Normal file
33
app/lib/ai/tools/tool_registry.dart
Normal file
@@ -0,0 +1,33 @@
|
||||
import 'catalog_tools.dart';
|
||||
import 'habit_tools.dart';
|
||||
import 'tool_definition.dart';
|
||||
import 'tracker_tools.dart';
|
||||
|
||||
/// Static registry of all tools exposed to the LLM.
|
||||
///
|
||||
/// Order is the order surfaced to the model (`flutter_gemma` preserves the
|
||||
/// list). Read-only tools first, then destructive — mirrors a "look before
|
||||
/// you leap" prompt bias.
|
||||
final List<ToolDefinition> kAllTools = [
|
||||
// read-only
|
||||
searchCatalogTool,
|
||||
queryProtocolTool,
|
||||
listActiveHabitsTool,
|
||||
getStreakTool,
|
||||
// destructive (confirm gate)
|
||||
addHabitTool,
|
||||
logTrackerEntryTool,
|
||||
];
|
||||
|
||||
class ToolRegistry {
|
||||
final Map<String, ToolDefinition> _byName;
|
||||
|
||||
ToolRegistry(List<ToolDefinition> tools)
|
||||
: _byName = {for (final t in tools) t.name: t};
|
||||
|
||||
factory ToolRegistry.defaults() => ToolRegistry(kAllTools);
|
||||
|
||||
ToolDefinition? byName(String name) => _byName[name];
|
||||
|
||||
Iterable<ToolDefinition> get all => _byName.values;
|
||||
}
|
||||
153
app/lib/ai/tools/tracker_tools.dart
Normal file
153
app/lib/ai/tools/tracker_tools.dart
Normal file
@@ -0,0 +1,153 @@
|
||||
import '../../core/time.dart';
|
||||
import '../../data/db/daos/tracker_dao.dart';
|
||||
import '../../domain/models/tracker_entry.dart';
|
||||
import '../../domain/streak/compute_streak.dart';
|
||||
import 'tool_definition.dart';
|
||||
import 'tool_envelope.dart';
|
||||
|
||||
final ToolDefinition logTrackerEntryTool = ToolDefinition(
|
||||
name: 'log_tracker_entry',
|
||||
description: '습관의 하루 체크인을 기록한다. value 는 done (완료) 또는 blank (의도적 공란).',
|
||||
parametersSchema: const {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'habit_id': {'type': 'string'},
|
||||
'value': {
|
||||
'type': 'string',
|
||||
'description': 'done 또는 blank.',
|
||||
},
|
||||
'date': {
|
||||
'type': 'string',
|
||||
'description': 'YYYY-MM-DD. 생략하면 오늘.',
|
||||
},
|
||||
'note': {'type': 'string'},
|
||||
},
|
||||
'required': ['habit_id', 'value'],
|
||||
},
|
||||
// R5: done 만 destructive (블랭크는 의도적 공란 — 확인 없이 통과).
|
||||
// 실 mutation 가시성을 위해 done 만 모달.
|
||||
isDestructive: true,
|
||||
summarize: (args) {
|
||||
final v = args['value'];
|
||||
final d = args['date'] ?? '오늘';
|
||||
return '습관 ${args['habit_id']} 의 $d 기록을 '
|
||||
'\'${v == 'done' ? '완료' : '공란'}\' 으로 저장합니다.';
|
||||
},
|
||||
handler: _logTrackerEntryHandler,
|
||||
);
|
||||
|
||||
final ToolDefinition getStreakTool = ToolDefinition(
|
||||
name: 'get_streak',
|
||||
description: '특정 habit_id 의 스트릭(연속일수) 와 5-tier 보상 등급을 계산해서 반환한다. '
|
||||
'기록 없는 날은 패널티 아니지만 명시적 blank 는 패널티.',
|
||||
parametersSchema: const {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'habit_id': {'type': 'string'},
|
||||
},
|
||||
'required': ['habit_id'],
|
||||
},
|
||||
handler: _getStreakHandler,
|
||||
);
|
||||
|
||||
Future<ToolResult> _logTrackerEntryHandler(
|
||||
Map<String, dynamic> args, ToolDeps deps) async {
|
||||
final habitId = args['habit_id'];
|
||||
final value = args['value'];
|
||||
if (habitId is! String || habitId.isEmpty) {
|
||||
return const ToolErr('validation', 'habit_id 는 비어있지 않은 문자열이어야 합니다.');
|
||||
}
|
||||
if (value is! String || (value != 'done' && value != 'blank')) {
|
||||
return const ToolErr('validation', 'value 는 done 또는 blank 이어야 합니다.');
|
||||
}
|
||||
final date = (args['date'] is String && (args['date'] as String).isNotEmpty)
|
||||
? args['date'] as String
|
||||
: dateOnly(nowKst());
|
||||
if (!_isValidDate(date)) {
|
||||
return const ToolErr('validation', 'date 는 YYYY-MM-DD 형식이어야 합니다.');
|
||||
}
|
||||
|
||||
// habit_id 가 실 사용자 소유인지 확인.
|
||||
final habits = await deps.habitDao.activeHabitsForUser(deps.userId);
|
||||
final owned = habits.any((h) => h.id == habitId);
|
||||
if (!owned) {
|
||||
return ToolErr('not_found', '활성 습관 \'$habitId\' 를 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
// 같은 (habit, date) 가 이미 있으면 덮어쓰기 대신 안내 — OQ-7 (no UNIQUE
|
||||
// constraint, 핸들러 레벨 dedup). 의도된 재기록은 사용자가 별도 액션.
|
||||
final existing = await deps.trackerDao.entriesForHabit(habitId);
|
||||
final same = existing.where((e) => e.date == date).toList();
|
||||
if (same.isNotEmpty) {
|
||||
return ToolErr(
|
||||
'duplicate',
|
||||
'$date 에 이미 \'${same.first.value}\' 로 기록되어 있습니다. '
|
||||
'덮어쓰려면 기존 항목을 삭제 후 다시 시도해주세요.',
|
||||
);
|
||||
}
|
||||
|
||||
final id = await deps.trackerDao.recordCheckIn(TrackerEntryDraft(
|
||||
habitId: habitId,
|
||||
date: date,
|
||||
value: value,
|
||||
note: args['note'] is String ? args['note'] as String : null,
|
||||
));
|
||||
return ToolOk({
|
||||
'entry_id': id,
|
||||
'habit_id': habitId,
|
||||
'date': date,
|
||||
'value': value,
|
||||
});
|
||||
}
|
||||
|
||||
Future<ToolResult> _getStreakHandler(
|
||||
Map<String, dynamic> args, ToolDeps deps) async {
|
||||
final habitId = args['habit_id'];
|
||||
if (habitId is! String || habitId.isEmpty) {
|
||||
return const ToolErr('validation', 'habit_id 는 비어있지 않은 문자열이어야 합니다.');
|
||||
}
|
||||
final habits = await deps.habitDao.activeHabitsForUser(deps.userId);
|
||||
final habit = habits.where((h) => h.id == habitId).firstOrNull;
|
||||
if (habit == null) {
|
||||
return ToolErr('not_found', '활성 습관 \'$habitId\' 를 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
final rows = await deps.trackerDao.entriesForHabit(habitId);
|
||||
final entries = rows
|
||||
.map((r) => TrackerEntryModel(
|
||||
id: r.id,
|
||||
habitId: r.habitId,
|
||||
date: r.date,
|
||||
value: r.value == 'done' ? TrackerValue.done : TrackerValue.blank,
|
||||
))
|
||||
.toList();
|
||||
final state = computeStreak(
|
||||
entries: entries,
|
||||
asOf: nowKst(),
|
||||
habitStartedAt: habit.startedAt,
|
||||
);
|
||||
return ToolOk({
|
||||
'habit_id': habitId,
|
||||
'current_streak': state.currentStreak,
|
||||
'longest_streak': state.longestStreak,
|
||||
'done_count_30d': state.doneCountInWindow30,
|
||||
'done_count_phase42': state.doneCountInPhase42,
|
||||
'tier': state.currentTier.dbValue,
|
||||
'never_miss_twice_broken': state.neverMissTwiceBroken,
|
||||
});
|
||||
}
|
||||
|
||||
bool _isValidDate(String s) {
|
||||
if (s.length != 10) return false;
|
||||
try {
|
||||
final parts = s.split('-');
|
||||
if (parts.length != 3) return false;
|
||||
final y = int.parse(parts[0]);
|
||||
final m = int.parse(parts[1]);
|
||||
final d = int.parse(parts[2]);
|
||||
final dt = DateTime(y, m, d);
|
||||
return dt.year == y && dt.month == m && dt.day == d;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
58
app/lib/data/ai/device_capabilities.dart
Normal file
58
app/lib/data/ai/device_capabilities.dart
Normal file
@@ -0,0 +1,58 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Minimum RAM (bytes) required for on-device Gemma 4 E2B inference.
|
||||
///
|
||||
/// 4 GiB matches Planner AC-6 of #218. The Gemma 4 E2B weights alone are
|
||||
/// ~2.4GB; adding KV-cache + Flutter runtime + OS headroom puts us at 4GB
|
||||
/// total as the practical floor below which AC-7 cold-start budgets fail.
|
||||
const int kAiMinRamBytes = 4 * 1024 * 1024 * 1024;
|
||||
|
||||
/// Abstraction over the platform-channel RAM query, so tests can inject a
|
||||
/// fake without touching MethodChannel.
|
||||
abstract class DeviceCapabilities {
|
||||
/// Returns total physical RAM in bytes, or `null` if unknown / unsupported
|
||||
/// (non-Android host, channel error). Callers must treat `null` as "do
|
||||
/// not enable the AI gate" (fail-closed).
|
||||
Future<int?> totalRamBytes();
|
||||
|
||||
/// Convenience: `true` iff [totalRamBytes] returns ≥ [kAiMinRamBytes].
|
||||
/// `null` from [totalRamBytes] → `false` (fail-closed).
|
||||
Future<bool> meetsAiMinRam() async {
|
||||
final bytes = await totalRamBytes();
|
||||
if (bytes == null) return false;
|
||||
return bytes >= kAiMinRamBytes;
|
||||
}
|
||||
}
|
||||
|
||||
/// Real implementation. Calls `MainActivity.kt` over a MethodChannel.
|
||||
class PlatformDeviceCapabilities implements DeviceCapabilities {
|
||||
PlatformDeviceCapabilities({MethodChannel? channel})
|
||||
: _channel = channel ??
|
||||
const MethodChannel('life_helper/device_caps');
|
||||
|
||||
final MethodChannel _channel;
|
||||
|
||||
@override
|
||||
Future<int?> totalRamBytes() async {
|
||||
// Channel is Android-only — return null on iOS/host tests rather than
|
||||
// throwing MissingPluginException.
|
||||
if (!Platform.isAndroid) return null;
|
||||
try {
|
||||
final v = await _channel.invokeMethod<int>('totalMemoryBytes');
|
||||
return v;
|
||||
} on PlatformException {
|
||||
return null;
|
||||
} on MissingPluginException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> meetsAiMinRam() async {
|
||||
final bytes = await totalRamBytes();
|
||||
if (bytes == null) return false;
|
||||
return bytes >= kAiMinRamBytes;
|
||||
}
|
||||
}
|
||||
@@ -1,36 +1,92 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_gemma/flutter_gemma.dart';
|
||||
|
||||
import '../../ai/tools/tool_definition.dart' as tools;
|
||||
import 'llm_service.dart';
|
||||
|
||||
/// Stub for the real `flutter_gemma` integration.
|
||||
/// HuggingFace access token injected at build time via
|
||||
/// `--dart-define=HF_TOKEN=hf_xxx`. Empty string is permitted —
|
||||
/// flutter_gemma will only need it for the initial network download,
|
||||
/// which our `ModelLifecycle` handles separately; activation from a
|
||||
/// local file path generally does not require the token.
|
||||
const String _hfToken = String.fromEnvironment('HF_TOKEN', defaultValue: '');
|
||||
|
||||
/// One-shot guard so [FlutterGemma.initialize] runs at most once per
|
||||
/// isolate. Re-init is unsupported by the underlying plugin.
|
||||
bool _initialized = false;
|
||||
|
||||
/// Real on-device LLM backend using flutter_gemma 0.16.5 + Gemma 4 E2B.
|
||||
///
|
||||
/// Wired up only after OQ-1 (exact model URL + SHA + flutter_gemma API
|
||||
/// surface) is confirmed in Developer phase. Today this throws
|
||||
/// `UnimplementedError` from every method — the rest of the system
|
||||
/// (suggestFrame, ModelLifecycle, Riverpod providers) is built against the
|
||||
/// `LlmService` abstract above and runs end-to-end with `MockLlmService`.
|
||||
/// Wired into the existing #215 pipeline: `ModelLifecycle` downloads &
|
||||
/// SHA-verifies the .litertlm file, then [load] registers that file with
|
||||
/// flutter_gemma as the active model. [generateStructured] opens a
|
||||
/// short-lived chat with a single [Tool] (Gemma 4 native function
|
||||
/// calling) and returns the first matching [FunctionCallResponse]'s args.
|
||||
///
|
||||
/// When the package is added, replace the bodies with calls into
|
||||
/// FlutterGemma.init / generateWithFunctionCalling per the package docs.
|
||||
/// Existing tests + UI hooks remain unchanged.
|
||||
/// Function-calling design notes (see fn-gemma_llm_service.md §B v2):
|
||||
/// - Gemma 4 SDK injects the tool declaration via its chat template, so
|
||||
/// we pass [Tool] to `createChat(tools: ...)` rather than appending a
|
||||
/// schema instruction to the prompt (double-wrap risk).
|
||||
/// - `ToolChoice.required` forces the model to emit a function call.
|
||||
class GemmaLlmService implements LlmService {
|
||||
final String modelPath;
|
||||
|
||||
GemmaLlmService({required this.modelPath});
|
||||
|
||||
InferenceModel? _model;
|
||||
bool _loaded = false;
|
||||
Future<void>? _loadingFuture;
|
||||
|
||||
@override
|
||||
bool get isLoaded => _loaded;
|
||||
|
||||
/// #311 AC7: concurrent-call guard. If a load is already in-flight (e.g.
|
||||
/// `ChatScreen` warm-up + a racing `userTurn` lazy load), return the same
|
||||
/// Future so native init runs at most once per process.
|
||||
/// See `docs/design/311-llm-warmup/fn-concurrent_load_guard.md`.
|
||||
@override
|
||||
Future<void> load() async {
|
||||
throw UnimplementedError(
|
||||
'GemmaLlmService.load: pending OQ-1 (model URL + flutter_gemma).',
|
||||
);
|
||||
Future<void> load() {
|
||||
if (_loaded) return Future.value();
|
||||
final existing = _loadingFuture;
|
||||
if (existing != null) return existing;
|
||||
final future = _doLoad();
|
||||
_loadingFuture = future;
|
||||
return future.whenComplete(() {
|
||||
_loadingFuture = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _doLoad() async {
|
||||
if (!await File(modelPath).exists()) {
|
||||
throw FileSystemException('model file missing', modelPath);
|
||||
}
|
||||
if (!_initialized) {
|
||||
await FlutterGemma.initialize(huggingFaceToken: _hfToken);
|
||||
_initialized = true;
|
||||
}
|
||||
await FlutterGemma.installModel(
|
||||
modelType: ModelType.gemma4,
|
||||
fileType: ModelFileType.litertlm,
|
||||
).fromFile(modelPath).install();
|
||||
final model = await FlutterGemma.getActiveModel(maxTokens: 2048);
|
||||
_model = model;
|
||||
_loaded = true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> unload() async {
|
||||
final m = _model;
|
||||
_model = null;
|
||||
_loaded = false;
|
||||
if (m != null) {
|
||||
try {
|
||||
await m.close();
|
||||
} catch (_) {
|
||||
// Best-effort cleanup — runtime may already be torn down.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -38,8 +94,177 @@ class GemmaLlmService implements LlmService {
|
||||
String prompt,
|
||||
Map<String, dynamic> schema,
|
||||
) async {
|
||||
throw UnimplementedError(
|
||||
'GemmaLlmService.generateStructured: pending OQ-1.',
|
||||
if (!_loaded || _model == null) {
|
||||
throw StateError('LlmService not loaded');
|
||||
}
|
||||
final fnName = schema['name'];
|
||||
final fnParams = schema['parameters'];
|
||||
if (fnName is! String || fnName.isEmpty) {
|
||||
throw ArgumentError('schema.name missing');
|
||||
}
|
||||
if (fnParams is! Map) {
|
||||
throw ArgumentError('schema.parameters missing');
|
||||
}
|
||||
final fnDesc = (schema['description'] as String?) ?? '';
|
||||
final tool = Tool(
|
||||
name: fnName,
|
||||
description: fnDesc,
|
||||
parameters: Map<String, dynamic>.from(fnParams),
|
||||
);
|
||||
|
||||
final chat = await _model!.createChat(
|
||||
modelType: ModelType.gemma4,
|
||||
supportsFunctionCalls: true,
|
||||
toolChoice: ToolChoice.required,
|
||||
tools: [tool],
|
||||
);
|
||||
try {
|
||||
await chat.addQueryChunk(Message.text(text: prompt, isUser: true));
|
||||
final stream = chat.generateChatResponseAsync();
|
||||
return await collectFunctionCall(stream, fnName);
|
||||
} finally {
|
||||
try {
|
||||
await chat.close();
|
||||
} catch (_) {
|
||||
// Native session close failure is non-fatal — log + continue.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<LlmChatSession> startChat({
|
||||
required List<tools.ToolDefinition> tools,
|
||||
}) async {
|
||||
if (!_loaded || _model == null) {
|
||||
throw StateError('LlmService not loaded');
|
||||
}
|
||||
final gemmaTools = tools
|
||||
.map((t) => Tool(
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
parameters: Map<String, dynamic>.from(t.parametersSchema),
|
||||
))
|
||||
.toList();
|
||||
final chat = await _model!.createChat(
|
||||
modelType: ModelType.gemma4,
|
||||
supportsFunctionCalls: true,
|
||||
// ToolChoice.auto = 모델이 자율 결정 (multi-tool + reply-only 모두 지원).
|
||||
toolChoice: ToolChoice.auto,
|
||||
tools: gemmaTools,
|
||||
);
|
||||
return _GemmaChatSession(chat);
|
||||
}
|
||||
}
|
||||
|
||||
class _GemmaChatSession implements LlmChatSession {
|
||||
final dynamic _chat;
|
||||
bool _closed = false;
|
||||
|
||||
_GemmaChatSession(this._chat);
|
||||
|
||||
@override
|
||||
Stream<LlmChatEvent> sendUser(String text) {
|
||||
if (_closed) {
|
||||
throw StateError('LlmChatSession is closed');
|
||||
}
|
||||
return _run(Message.text(text: text, isUser: true));
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<LlmChatEvent> sendToolResult({
|
||||
required String toolName,
|
||||
required Map<String, dynamic> result,
|
||||
}) {
|
||||
if (_closed) {
|
||||
throw StateError('LlmChatSession is closed');
|
||||
}
|
||||
return _run(Message.toolResponse(toolName: toolName, response: result));
|
||||
}
|
||||
|
||||
Stream<LlmChatEvent> _run(Message msg) async* {
|
||||
await _chat.addQueryChunk(msg);
|
||||
final Stream<ModelResponse> stream = _chat.generateChatResponseAsync();
|
||||
await for (final event in stream) {
|
||||
if (event is TextResponse) {
|
||||
yield LlmTextChunk(event.token);
|
||||
} else if (event is FunctionCallResponse) {
|
||||
yield LlmFunctionCall(
|
||||
event.name,
|
||||
Map<String, dynamic>.from(event.args),
|
||||
);
|
||||
return; // model hands control back to caller for tool exec
|
||||
} else if (event is ParallelFunctionCallResponse &&
|
||||
event.calls.isNotEmpty) {
|
||||
// ADR-0005: parallel calls collapsed to first — sequential dispatch.
|
||||
final first = event.calls.first;
|
||||
yield LlmFunctionCall(
|
||||
first.name,
|
||||
Map<String, dynamic>.from(first.args),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// ThinkingResponse / other: skip.
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
if (_closed) return;
|
||||
_closed = true;
|
||||
try {
|
||||
await _chat.close();
|
||||
} catch (_) {
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts the first `FunctionCallResponse(name == expectedName)` from
|
||||
/// a flutter_gemma response stream. `TextResponse` / `ThinkingResponse`
|
||||
/// events are skipped. A mismatched name throws fast.
|
||||
///
|
||||
/// File-private under `_collectFunctionCall` from [GemmaLlmService];
|
||||
/// exposed as a top-level via `@visibleForTesting` so unit tests can
|
||||
/// feed synthetic streams (see fn-spec §D, 8 test cases).
|
||||
@visibleForTesting
|
||||
Future<Map<String, dynamic>> collectFunctionCall(
|
||||
Stream<ModelResponse> stream,
|
||||
String expectedName,
|
||||
) async {
|
||||
Map<String, dynamic>? result;
|
||||
String? wrongName;
|
||||
try {
|
||||
await for (final event in stream) {
|
||||
if (event is FunctionCallResponse) {
|
||||
if (event.name == expectedName) {
|
||||
result = Map<String, dynamic>.from(event.args);
|
||||
break;
|
||||
} else {
|
||||
wrongName = event.name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (event is ParallelFunctionCallResponse && event.calls.isNotEmpty) {
|
||||
final first = event.calls.first;
|
||||
if (first.name == expectedName) {
|
||||
result = Map<String, dynamic>.from(first.args);
|
||||
} else {
|
||||
wrongName = first.name;
|
||||
}
|
||||
break;
|
||||
}
|
||||
// TextResponse / ThinkingResponse: skip.
|
||||
}
|
||||
} catch (_) {
|
||||
// Discard raw error to avoid leaking prompt content in logs/crash
|
||||
// reports — the caller surfaces a generic message.
|
||||
throw const FormatException('stream error');
|
||||
}
|
||||
if (wrongName != null) {
|
||||
throw FormatException('unexpected function: $wrongName');
|
||||
}
|
||||
if (result == null) {
|
||||
throw const FormatException('no function call emitted');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import '../../ai/tools/tool_definition.dart';
|
||||
|
||||
/// Abstract LLM backend.
|
||||
///
|
||||
/// Concrete impls: `GemmaLlmService` (flutter_gemma) and `MockLlmService` (tests).
|
||||
@@ -7,6 +9,7 @@
|
||||
/// - [generateStructured] returns a parsed JSON map matching the schema.
|
||||
/// On schema/parse failure throws [FormatException].
|
||||
/// - [unload] is idempotent.
|
||||
/// - [startChat] opens a multi-turn chat session for tool calling (#260).
|
||||
abstract class LlmService {
|
||||
bool get isLoaded;
|
||||
|
||||
@@ -20,6 +23,45 @@ abstract class LlmService {
|
||||
String prompt,
|
||||
Map<String, dynamic> schema,
|
||||
);
|
||||
|
||||
/// Opens a chat session that supports multi-turn user input + tool result
|
||||
/// submission with the supplied [tools]. See ADR-0005.
|
||||
Future<LlmChatSession> startChat({
|
||||
required List<ToolDefinition> tools,
|
||||
});
|
||||
}
|
||||
|
||||
/// Streaming chat session for the tool-calling loop.
|
||||
///
|
||||
/// Lifecycle: created by [LlmService.startChat], lives for a single chat
|
||||
/// screen, must be [close]d when the user dismisses the screen. Each
|
||||
/// `send*` call returns a stream of [LlmChatEvent]s until the model yields
|
||||
/// control (text done or a function call requested).
|
||||
abstract class LlmChatSession {
|
||||
Stream<LlmChatEvent> sendUser(String text);
|
||||
|
||||
Stream<LlmChatEvent> sendToolResult({
|
||||
required String toolName,
|
||||
required Map<String, dynamic> result,
|
||||
});
|
||||
|
||||
Future<void> close();
|
||||
}
|
||||
|
||||
/// Events emitted by [LlmChatSession]. See ADR-0005 §C.
|
||||
sealed class LlmChatEvent {
|
||||
const LlmChatEvent();
|
||||
}
|
||||
|
||||
final class LlmTextChunk extends LlmChatEvent {
|
||||
final String text;
|
||||
const LlmTextChunk(this.text);
|
||||
}
|
||||
|
||||
final class LlmFunctionCall extends LlmChatEvent {
|
||||
final String name;
|
||||
final Map<String, dynamic> args;
|
||||
const LlmFunctionCall(this.name, this.args);
|
||||
}
|
||||
|
||||
/// Programmable stub for tests. Use [enqueueResponse] / [enqueueError].
|
||||
@@ -31,11 +73,48 @@ class MockLlmService implements LlmService {
|
||||
Map<String, dynamic>? lastSchema;
|
||||
Duration responseDelay = Duration.zero;
|
||||
|
||||
/// #311 test helpers. Simulate cold-load latency / failure so the warm-up
|
||||
/// controller can be exercised. Mirrors the Gemma path:
|
||||
/// - `loadDelay > 0` → load completes after the delay
|
||||
/// - `loadThrows` → load throws this error
|
||||
/// - `loadCount` → observed by concurrent-load tests
|
||||
Duration loadDelay = Duration.zero;
|
||||
Object? loadThrows;
|
||||
int loadCount = 0;
|
||||
Future<void>? _loadingFuture;
|
||||
|
||||
/// Queues consumed by [startChat] in order. Each entry is the event list
|
||||
/// returned for a single `send*` call.
|
||||
final List<List<LlmChatEvent>> chatScript = [];
|
||||
int chatStartCount = 0;
|
||||
MockLlmChatSession? lastChat;
|
||||
|
||||
@override
|
||||
bool get isLoaded => _loaded;
|
||||
|
||||
/// #311 AC7: same concurrent-call guard as [GemmaLlmService]. Repeated
|
||||
/// in-flight `load()` calls share a single Future, so test assertions on
|
||||
/// `loadCount` reflect the number of native-init attempts (1), not the
|
||||
/// number of callers.
|
||||
@override
|
||||
Future<void> load() async {
|
||||
Future<void> load() {
|
||||
if (_loaded) return Future.value();
|
||||
final existing = _loadingFuture;
|
||||
if (existing != null) return existing;
|
||||
final future = _doLoad();
|
||||
_loadingFuture = future;
|
||||
return future.whenComplete(() {
|
||||
_loadingFuture = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _doLoad() async {
|
||||
loadCount += 1;
|
||||
if (loadDelay > Duration.zero) {
|
||||
await Future<void>.delayed(loadDelay);
|
||||
}
|
||||
final err = loadThrows;
|
||||
if (err != null) throw err;
|
||||
_loaded = true;
|
||||
}
|
||||
|
||||
@@ -52,6 +131,12 @@ class MockLlmService implements LlmService {
|
||||
_queue.add(_Response.error(error));
|
||||
}
|
||||
|
||||
/// Enqueue one batch of events that will be emitted on the next
|
||||
/// `sendUser` or `sendToolResult` call. Items are streamed in order.
|
||||
void enqueueChatEvents(List<LlmChatEvent> events) {
|
||||
chatScript.add(events);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> generateStructured(
|
||||
String prompt,
|
||||
@@ -73,6 +158,61 @@ class MockLlmService implements LlmService {
|
||||
if (r.error != null) throw r.error!;
|
||||
return r.value!;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<LlmChatSession> startChat({
|
||||
required List<ToolDefinition> tools,
|
||||
}) async {
|
||||
if (!_loaded) {
|
||||
throw StateError('LlmService not loaded');
|
||||
}
|
||||
chatStartCount += 1;
|
||||
final session = MockLlmChatSession(chatScript);
|
||||
lastChat = session;
|
||||
return session;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mock chat session that replays pre-queued events from [MockLlmService].
|
||||
class MockLlmChatSession implements LlmChatSession {
|
||||
final List<List<LlmChatEvent>> _script;
|
||||
int sendCount = 0;
|
||||
final List<String> userInputs = [];
|
||||
final List<(String, Map<String, dynamic>)> toolResults = [];
|
||||
bool closed = false;
|
||||
|
||||
MockLlmChatSession(this._script);
|
||||
|
||||
Stream<LlmChatEvent> _emitNext() async* {
|
||||
sendCount += 1;
|
||||
if (_script.isEmpty) {
|
||||
throw StateError('MockLlmChatSession: no queued events');
|
||||
}
|
||||
final batch = _script.removeAt(0);
|
||||
for (final ev in batch) {
|
||||
yield ev;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<LlmChatEvent> sendUser(String text) {
|
||||
userInputs.add(text);
|
||||
return _emitNext();
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<LlmChatEvent> sendToolResult({
|
||||
required String toolName,
|
||||
required Map<String, dynamic> result,
|
||||
}) {
|
||||
toolResults.add((toolName, result));
|
||||
return _emitNext();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
class _Response {
|
||||
|
||||
@@ -94,6 +94,44 @@ class ModelLifecycle {
|
||||
return p.join(dir.path, config.filename);
|
||||
}
|
||||
|
||||
/// Lightweight ready estimate for warm-up gating (#311).
|
||||
///
|
||||
/// Skips the SHA-256 re-hash that [checkAvailability] performs — for a
|
||||
/// ~2.4GB model file the hash is wall-clock-noticeable on every screen
|
||||
/// mount. Returns `ready` iff:
|
||||
/// - opt_in is true
|
||||
/// - download_state is not in-progress
|
||||
/// - meta_kv has both ai_model_path and ai_model_sha256
|
||||
/// - the file exists on disk
|
||||
///
|
||||
/// Tampering/disk-corruption detection is left to [checkAvailability]'s
|
||||
/// cold path (SettingsScreen). The trade-off is documented in
|
||||
/// `docs/design/311-llm-warmup/README.md` §11 R4.
|
||||
Future<ModelAvailability> quickCheck() async {
|
||||
try {
|
||||
final optIn = await meta.find(AiMetaKeys.optIn);
|
||||
if (optIn != 'true') return ModelAvailability.missing;
|
||||
|
||||
final state = await meta.find(AiMetaKeys.downloadState);
|
||||
if (state == 'downloading' || state == 'paused') {
|
||||
return ModelAvailability.downloading;
|
||||
}
|
||||
|
||||
final pathStr = await meta.find(AiMetaKeys.modelPath);
|
||||
if (pathStr == null) return ModelAvailability.missing;
|
||||
|
||||
final expected = await meta.find(AiMetaKeys.modelSha);
|
||||
if (expected == null) return ModelAvailability.corrupt;
|
||||
|
||||
final file = File(pathStr);
|
||||
if (!file.existsSync()) return ModelAvailability.missing;
|
||||
|
||||
return ModelAvailability.ready;
|
||||
} catch (_) {
|
||||
return ModelAvailability.corrupt;
|
||||
}
|
||||
}
|
||||
|
||||
Future<ModelAvailability> checkAvailability() async {
|
||||
try {
|
||||
final optIn = await meta.find(AiMetaKeys.optIn);
|
||||
@@ -233,21 +271,37 @@ class ModelLifecycle {
|
||||
/// opt-out: delete model file + clear all ai_* meta keys (except opt_in
|
||||
/// which the caller toggles). Returns freed bytes (0 if nothing existed).
|
||||
/// Idempotent.
|
||||
///
|
||||
/// F2 hardening (#218): per-file try/catch so a single OS-level delete
|
||||
/// failure (locked file, permission flake) does not abort the whole
|
||||
/// purge — meta keys still get cleared and the orphan file becomes a
|
||||
/// background storage concern rather than a stuck "opt-out failed"
|
||||
/// state. The freed-bytes count only reflects successful deletes.
|
||||
Future<int> purge() async {
|
||||
int freed = 0;
|
||||
final pathStr = await meta.find(AiMetaKeys.modelPath);
|
||||
if (pathStr != null) {
|
||||
final f = File(pathStr);
|
||||
if (f.existsSync()) {
|
||||
freed += await f.length();
|
||||
await f.delete();
|
||||
try {
|
||||
final f = File(pathStr);
|
||||
if (f.existsSync()) {
|
||||
final size = await f.length();
|
||||
await f.delete();
|
||||
freed += size;
|
||||
}
|
||||
} catch (_) {
|
||||
// Best-effort; leave orphan file, continue purging meta.
|
||||
}
|
||||
}
|
||||
final tempPath = '${await _modelPath()}.tmp';
|
||||
final temp = File(tempPath);
|
||||
if (temp.existsSync()) {
|
||||
freed += await temp.length();
|
||||
await temp.delete();
|
||||
try {
|
||||
final tempPath = '${await _modelPath()}.tmp';
|
||||
final temp = File(tempPath);
|
||||
if (temp.existsSync()) {
|
||||
final size = await temp.length();
|
||||
await temp.delete();
|
||||
freed += size;
|
||||
}
|
||||
} catch (_) {
|
||||
// Same as above — best-effort cleanup of the .tmp partial.
|
||||
}
|
||||
for (final k in [
|
||||
AiMetaKeys.modelPath,
|
||||
@@ -255,7 +309,12 @@ class ModelLifecycle {
|
||||
AiMetaKeys.downloadState,
|
||||
AiMetaKeys.downloadBytes,
|
||||
]) {
|
||||
await meta.remove(k);
|
||||
try {
|
||||
await meta.remove(k);
|
||||
} catch (_) {
|
||||
// Meta is a single sqlite table; failures here are rare.
|
||||
// Swallow so the loop completes even if one key errors.
|
||||
}
|
||||
}
|
||||
return freed;
|
||||
}
|
||||
|
||||
133
app/lib/data/catalog/catalog_repository.dart
Normal file
133
app/lib/data/catalog/catalog_repository.dart
Normal file
@@ -0,0 +1,133 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import '../../domain/catalog/catalog_item.dart';
|
||||
import '../../domain/catalog/display_category.dart';
|
||||
import '../db/app_database.dart';
|
||||
|
||||
/// 3 source (Protocols / BreakProtocols / DietPatterns) → 단일 `List<CatalogItem>`.
|
||||
///
|
||||
/// 본 이슈 (#226) 의 핵심 변환 한 점. 본 함수는 fn-catalog_repository.md 의 알고리즘대로.
|
||||
class CatalogRepository {
|
||||
CatalogRepository(this._db);
|
||||
|
||||
final AppDatabase _db;
|
||||
|
||||
/// 47 항목 (protocols 34 + break 8 + diet 5) 을 displayCategory 기준 정렬해 반환.
|
||||
Future<List<CatalogItem>> all() async {
|
||||
final protocolRows = await _db.select(_db.protocols).get();
|
||||
final breakRows = await _db.select(_db.breakProtocols).get();
|
||||
final dietRows = await _db.select(_db.dietPatterns).get();
|
||||
|
||||
final items = <CatalogItem>[];
|
||||
|
||||
for (final p in protocolRows) {
|
||||
final dc = DisplayCategory.fromProtocolCategory(p.category);
|
||||
if (dc == null) {
|
||||
throw StateError(
|
||||
'unknown protocol category "${p.category}" for id=${p.id}');
|
||||
}
|
||||
items.add(ProtocolCatalogItem(
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
titleEn: p.titleEn,
|
||||
summary: _summary(p.what, fallback: p.title),
|
||||
displayCategory: dc,
|
||||
evidenceStrength: p.evidenceStrength,
|
||||
referenceIds: _decodeIds(p.referenceIdsJson),
|
||||
what: p.what,
|
||||
whenText: p.whenText,
|
||||
dose: p.dose,
|
||||
why: p.why,
|
||||
how: _decodeList(p.howJson),
|
||||
checkText: p.checkText,
|
||||
caution: p.caution,
|
||||
defaultAnchor: _decodeAnchor(p.defaultAnchorJson),
|
||||
minDoseForStart: p.minDoseForStart,
|
||||
sourceDoc: p.sourceDoc,
|
||||
));
|
||||
}
|
||||
|
||||
for (final b in breakRows) {
|
||||
items.add(BreakCatalogItem(
|
||||
id: b.id,
|
||||
title: b.title,
|
||||
titleEn: null,
|
||||
summary: _summary(b.hubermanSummary, fallback: b.title),
|
||||
evidenceStrength: null,
|
||||
referenceIds: _decodeIds(b.referenceIdsJson),
|
||||
breakCategory: b.category,
|
||||
hubermanSummary: b.hubermanSummary,
|
||||
phases: _decodeList(b.phasesJson),
|
||||
defaultCommonFrames: _decodeList(b.defaultCommonFramesJson),
|
||||
tools: _decodeList(b.toolsJson),
|
||||
medicalWarning: b.medicalWarning,
|
||||
));
|
||||
}
|
||||
|
||||
for (final d in dietRows) {
|
||||
items.add(DietCatalogItem(
|
||||
id: d.id,
|
||||
title: d.name,
|
||||
titleEn: null,
|
||||
summary: _summary(d.core, fallback: d.name),
|
||||
evidenceStrength: d.evidenceStrength,
|
||||
referenceIds: _decodeIds(d.referenceIdsJson),
|
||||
name: d.name,
|
||||
core: d.core,
|
||||
strengths: _decodeList(d.strengthsJson),
|
||||
weaknesses: _decodeList(d.weaknessesJson),
|
||||
koreanContextFit: d.koreanContextFit,
|
||||
starterLevers: _decodeList(d.starterLeversJson),
|
||||
medicalWarning: d.medicalWarning,
|
||||
linkedProtocolIds: _decodeIds(d.linkedProtocolIdsJson),
|
||||
));
|
||||
}
|
||||
|
||||
items.sort((a, b) {
|
||||
final c = a.displayCategory.index - b.displayCategory.index;
|
||||
return c != 0 ? c : a.id.compareTo(b.id);
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/// 단건 조회. Preview 화면 진입 시.
|
||||
Future<CatalogItem?> byId(String id) async {
|
||||
final all_ = await all();
|
||||
for (final item in all_) {
|
||||
if (item.id == id) return item;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// reference id 리스트 → References 테이블 매칭. 미매칭 항목은 결과에서 누락.
|
||||
Future<List<ReferenceRow>> referencesByIds(List<String> ids) async {
|
||||
if (ids.isEmpty) return const [];
|
||||
return (_db.select(_db.references)..where((t) => t.id.isIn(ids))).get();
|
||||
}
|
||||
}
|
||||
|
||||
/// `what` 의 첫 문장을 추출. 비어있으면 `fallback` 사용. 60자 초과 시 절단.
|
||||
String _summary(String what, {required String fallback, int max = 60}) {
|
||||
final firstSentence = what.split(RegExp(r'[.!?。!?]')).first.trim();
|
||||
final s = firstSentence.isEmpty ? fallback : firstSentence;
|
||||
return s.length <= max ? s : '${s.substring(0, max - 1)}…';
|
||||
}
|
||||
|
||||
List<String> _decodeIds(String? jsonStr) {
|
||||
if (jsonStr == null) return const [];
|
||||
final decoded = jsonDecode(jsonStr);
|
||||
return decoded is List ? decoded.cast<String>() : const [];
|
||||
}
|
||||
|
||||
List<String> _decodeList(String? jsonStr) {
|
||||
if (jsonStr == null) return const [];
|
||||
final decoded = jsonDecode(jsonStr);
|
||||
return decoded is List ? decoded.map((e) => e.toString()).toList() : const [];
|
||||
}
|
||||
|
||||
Map<String, dynamic>? _decodeAnchor(String? jsonStr) {
|
||||
if (jsonStr == null) return null;
|
||||
final decoded = jsonDecode(jsonStr);
|
||||
return decoded is Map<String, dynamic> ? decoded : null;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import 'package:drift/native.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../../core/constants.dart';
|
||||
import 'tables/catalog_tables.dart';
|
||||
import 'tables/user_tables.dart';
|
||||
|
||||
@@ -42,7 +43,7 @@ class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase.memory() : super(NativeDatabase.memory());
|
||||
|
||||
@override
|
||||
int get schemaVersion => 1;
|
||||
int get schemaVersion => 2;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
@@ -51,8 +52,16 @@ class AppDatabase extends _$AppDatabase {
|
||||
await _createIndexes(m);
|
||||
},
|
||||
onUpgrade: (m, from, to) async {
|
||||
// Phase 1 only has v1. Reaching here is a bug.
|
||||
assert(false, 'Phase 1 has no upgrade path. from=$from to=$to');
|
||||
// v1 → v2 (#226): Protocols.category CHECK 6 → 7 신 카테고리.
|
||||
// Read-only catalog 의 첫 마이그레이션. DROP + reseed 패턴 (ADR-0004).
|
||||
// user 테이블 (Habits, Phases, ...) 무변화.
|
||||
if (from == 1 && to >= 2) {
|
||||
await migrateV1ToV2(m, this);
|
||||
}
|
||||
if (from > to || to > schemaVersion) {
|
||||
assert(false,
|
||||
'Unknown upgrade path: from=$from to=$to (schemaVersion=$schemaVersion)');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -142,3 +151,18 @@ Future<File> appDatabaseFile() async {
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
return File(p.join(dir.path, 'life_helper.sqlite'));
|
||||
}
|
||||
|
||||
/// v1 → v2 마이그레이션. fn-migration_v1_to_v2.md 참고.
|
||||
///
|
||||
/// - Protocols 테이블 DROP + CREATE (v2 CHECK 적용) + 인덱스 재생성.
|
||||
/// - `kSeededV1Flag` 클리어 → 다음 부팅이 SeedImporter.importIfNeeded() 호출 → 새 JSON 재시드.
|
||||
/// - user 테이블 (Habits, Phases, TrackerEntries, ...) 무변화.
|
||||
///
|
||||
/// `onUpgrade` 에서 dispatch. 테스트는 직접 호출.
|
||||
Future<void> migrateV1ToV2(Migrator m, AppDatabase db) async {
|
||||
await m.deleteTable(db.protocols.actualTableName);
|
||||
await m.createTable(db.protocols);
|
||||
await m.createIndex(Index('IDX_protocols_category',
|
||||
'CREATE INDEX IDX_protocols_category ON protocols(category)'));
|
||||
await (db.delete(db.metaKv)..where((t) => t.key.equals(kSeededV1Flag))).go();
|
||||
}
|
||||
|
||||
@@ -27,7 +27,8 @@ class $ProtocolsTable extends Protocols
|
||||
aliasedName,
|
||||
false,
|
||||
check: () => const CustomExpression<bool>(
|
||||
"category IN ('health','meditation','motivation','habit','learning','diet')",
|
||||
"category IN ('light_circadian','sleep','movement','nutrition',"
|
||||
"'focus_cognition','recovery_stress','emotion_relationship')",
|
||||
),
|
||||
type: DriftSqlType.string,
|
||||
requiredDuringInsert: true,
|
||||
@@ -15402,7 +15403,7 @@ final class $$UsersTableReferences
|
||||
_$AppDatabase db,
|
||||
) => MultiTypedResultKey.fromTable(
|
||||
db.phases,
|
||||
aliasName: $_aliasNameGenerator(db.users.id, db.phases.userId),
|
||||
aliasName: 'users__id__phases__user_id',
|
||||
);
|
||||
|
||||
$$PhasesTableProcessedTableManager get phasesRefs {
|
||||
@@ -15421,7 +15422,7 @@ final class $$UsersTableReferences
|
||||
_$AppDatabase db,
|
||||
) => MultiTypedResultKey.fromTable(
|
||||
db.habits,
|
||||
aliasName: $_aliasNameGenerator(db.users.id, db.habits.userId),
|
||||
aliasName: 'users__id__habits__user_id',
|
||||
);
|
||||
|
||||
$$HabitsTableProcessedTableManager get habitsRefs {
|
||||
@@ -15439,7 +15440,7 @@ final class $$UsersTableReferences
|
||||
static MultiTypedResultKey<$ReflectionsTable, List<Reflection>>
|
||||
_reflectionsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable(
|
||||
db.reflections,
|
||||
aliasName: $_aliasNameGenerator(db.users.id, db.reflections.userId),
|
||||
aliasName: 'users__id__reflections__user_id',
|
||||
);
|
||||
|
||||
$$ReflectionsTableProcessedTableManager get reflectionsRefs {
|
||||
@@ -15909,7 +15910,7 @@ final class $$PhasesTableReferences
|
||||
$$PhasesTableReferences(super.$_db, super.$_table, super.$_typedResult);
|
||||
|
||||
static $UsersTable _userIdTable(_$AppDatabase db) =>
|
||||
db.users.createAlias($_aliasNameGenerator(db.phases.userId, db.users.id));
|
||||
db.users.createAlias('phases__user_id__users__id');
|
||||
|
||||
$$UsersTableProcessedTableManager get userId {
|
||||
final $_column = $_itemColumn<String>('user_id')!;
|
||||
@@ -15929,7 +15930,7 @@ final class $$PhasesTableReferences
|
||||
_$AppDatabase db,
|
||||
) => MultiTypedResultKey.fromTable(
|
||||
db.habits,
|
||||
aliasName: $_aliasNameGenerator(db.phases.id, db.habits.phaseId),
|
||||
aliasName: 'phases__id__habits__phase_id',
|
||||
);
|
||||
|
||||
$$HabitsTableProcessedTableManager get habitsRefs {
|
||||
@@ -15948,10 +15949,7 @@ final class $$PhasesTableReferences
|
||||
_rewardDeclarationsRefsTable(_$AppDatabase db) =>
|
||||
MultiTypedResultKey.fromTable(
|
||||
db.rewardDeclarations,
|
||||
aliasName: $_aliasNameGenerator(
|
||||
db.phases.id,
|
||||
db.rewardDeclarations.phaseId,
|
||||
),
|
||||
aliasName: 'phases__id__reward_declarations__phase_id',
|
||||
);
|
||||
|
||||
$$RewardDeclarationsTableProcessedTableManager get rewardDeclarationsRefs {
|
||||
@@ -15971,7 +15969,7 @@ final class $$PhasesTableReferences
|
||||
static MultiTypedResultKey<$ReflectionsTable, List<Reflection>>
|
||||
_reflectionsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable(
|
||||
db.reflections,
|
||||
aliasName: $_aliasNameGenerator(db.phases.id, db.reflections.phaseId),
|
||||
aliasName: 'phases__id__reflections__phase_id',
|
||||
);
|
||||
|
||||
$$ReflectionsTableProcessedTableManager get reflectionsRefs {
|
||||
@@ -16618,7 +16616,7 @@ final class $$HabitsTableReferences
|
||||
$$HabitsTableReferences(super.$_db, super.$_table, super.$_typedResult);
|
||||
|
||||
static $UsersTable _userIdTable(_$AppDatabase db) =>
|
||||
db.users.createAlias($_aliasNameGenerator(db.habits.userId, db.users.id));
|
||||
db.users.createAlias('habits__user_id__users__id');
|
||||
|
||||
$$UsersTableProcessedTableManager get userId {
|
||||
final $_column = $_itemColumn<String>('user_id')!;
|
||||
@@ -16634,9 +16632,8 @@ final class $$HabitsTableReferences
|
||||
);
|
||||
}
|
||||
|
||||
static $PhasesTable _phaseIdTable(_$AppDatabase db) => db.phases.createAlias(
|
||||
$_aliasNameGenerator(db.habits.phaseId, db.phases.id),
|
||||
);
|
||||
static $PhasesTable _phaseIdTable(_$AppDatabase db) =>
|
||||
db.phases.createAlias('habits__phase_id__phases__id');
|
||||
|
||||
$$PhasesTableProcessedTableManager? get phaseId {
|
||||
final $_column = $_itemColumn<String>('phase_id');
|
||||
@@ -16656,10 +16653,7 @@ final class $$HabitsTableReferences
|
||||
_habitDoseVariantsRefsTable(_$AppDatabase db) =>
|
||||
MultiTypedResultKey.fromTable(
|
||||
db.habitDoseVariants,
|
||||
aliasName: $_aliasNameGenerator(
|
||||
db.habits.id,
|
||||
db.habitDoseVariants.habitId,
|
||||
),
|
||||
aliasName: 'habits__id__habit_dose_variants__habit_id',
|
||||
);
|
||||
|
||||
$$HabitDoseVariantsTableProcessedTableManager get habitDoseVariantsRefs {
|
||||
@@ -16679,7 +16673,7 @@ final class $$HabitsTableReferences
|
||||
static MultiTypedResultKey<$IfThenRulesTable, List<IfThenRule>>
|
||||
_ifThenRulesRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable(
|
||||
db.ifThenRules,
|
||||
aliasName: $_aliasNameGenerator(db.habits.id, db.ifThenRules.habitId),
|
||||
aliasName: 'habits__id__if_then_rules__habit_id',
|
||||
);
|
||||
|
||||
$$IfThenRulesTableProcessedTableManager get ifThenRulesRefs {
|
||||
@@ -16697,7 +16691,7 @@ final class $$HabitsTableReferences
|
||||
static MultiTypedResultKey<$TrackerEntriesTable, List<TrackerEntry>>
|
||||
_trackerEntriesRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable(
|
||||
db.trackerEntries,
|
||||
aliasName: $_aliasNameGenerator(db.habits.id, db.trackerEntries.habitId),
|
||||
aliasName: 'habits__id__tracker_entries__habit_id',
|
||||
);
|
||||
|
||||
$$TrackerEntriesTableProcessedTableManager get trackerEntriesRefs {
|
||||
@@ -16715,7 +16709,7 @@ final class $$HabitsTableReferences
|
||||
static MultiTypedResultKey<$LapseLogsTable, List<LapseLog>>
|
||||
_lapseLogsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable(
|
||||
db.lapseLogs,
|
||||
aliasName: $_aliasNameGenerator(db.habits.id, db.lapseLogs.habitId),
|
||||
aliasName: 'habits__id__lapse_logs__habit_id',
|
||||
);
|
||||
|
||||
$$LapseLogsTableProcessedTableManager get lapseLogsRefs {
|
||||
@@ -16734,7 +16728,7 @@ final class $$HabitsTableReferences
|
||||
_$AppDatabase db,
|
||||
) => MultiTypedResultKey.fromTable(
|
||||
db.urgeLogs,
|
||||
aliasName: $_aliasNameGenerator(db.habits.id, db.urgeLogs.habitId),
|
||||
aliasName: 'habits__id__urge_logs__habit_id',
|
||||
);
|
||||
|
||||
$$UrgeLogsTableProcessedTableManager get urgeLogsRefs {
|
||||
@@ -16753,10 +16747,7 @@ final class $$HabitsTableReferences
|
||||
_rewardDeclarationsRefsTable(_$AppDatabase db) =>
|
||||
MultiTypedResultKey.fromTable(
|
||||
db.rewardDeclarations,
|
||||
aliasName: $_aliasNameGenerator(
|
||||
db.habits.id,
|
||||
db.rewardDeclarations.habitId,
|
||||
),
|
||||
aliasName: 'habits__id__reward_declarations__habit_id',
|
||||
);
|
||||
|
||||
$$RewardDeclarationsTableProcessedTableManager get rewardDeclarationsRefs {
|
||||
@@ -17909,9 +17900,8 @@ final class $$HabitDoseVariantsTableReferences
|
||||
super.$_typedResult,
|
||||
);
|
||||
|
||||
static $HabitsTable _habitIdTable(_$AppDatabase db) => db.habits.createAlias(
|
||||
$_aliasNameGenerator(db.habitDoseVariants.habitId, db.habits.id),
|
||||
);
|
||||
static $HabitsTable _habitIdTable(_$AppDatabase db) =>
|
||||
db.habits.createAlias('habit_dose_variants__habit_id__habits__id');
|
||||
|
||||
$$HabitsTableProcessedTableManager get habitId {
|
||||
final $_column = $_itemColumn<String>('habit_id')!;
|
||||
@@ -17930,10 +17920,7 @@ final class $$HabitDoseVariantsTableReferences
|
||||
static MultiTypedResultKey<$TrackerEntriesTable, List<TrackerEntry>>
|
||||
_trackerEntriesRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable(
|
||||
db.trackerEntries,
|
||||
aliasName: $_aliasNameGenerator(
|
||||
db.habitDoseVariants.variantId,
|
||||
db.trackerEntries.variantId,
|
||||
),
|
||||
aliasName: 'habit_dose_variants__variant_id__tracker_entries__variant_id',
|
||||
);
|
||||
|
||||
$$TrackerEntriesTableProcessedTableManager get trackerEntriesRefs {
|
||||
@@ -18391,9 +18378,8 @@ final class $$IfThenRulesTableReferences
|
||||
extends BaseReferences<_$AppDatabase, $IfThenRulesTable, IfThenRule> {
|
||||
$$IfThenRulesTableReferences(super.$_db, super.$_table, super.$_typedResult);
|
||||
|
||||
static $HabitsTable _habitIdTable(_$AppDatabase db) => db.habits.createAlias(
|
||||
$_aliasNameGenerator(db.ifThenRules.habitId, db.habits.id),
|
||||
);
|
||||
static $HabitsTable _habitIdTable(_$AppDatabase db) =>
|
||||
db.habits.createAlias('if_then_rules__habit_id__habits__id');
|
||||
|
||||
$$HabitsTableProcessedTableManager get habitId {
|
||||
final $_column = $_itemColumn<String>('habit_id')!;
|
||||
@@ -18761,9 +18747,8 @@ final class $$TrackerEntriesTableReferences
|
||||
super.$_typedResult,
|
||||
);
|
||||
|
||||
static $HabitsTable _habitIdTable(_$AppDatabase db) => db.habits.createAlias(
|
||||
$_aliasNameGenerator(db.trackerEntries.habitId, db.habits.id),
|
||||
);
|
||||
static $HabitsTable _habitIdTable(_$AppDatabase db) =>
|
||||
db.habits.createAlias('tracker_entries__habit_id__habits__id');
|
||||
|
||||
$$HabitsTableProcessedTableManager get habitId {
|
||||
final $_column = $_itemColumn<String>('habit_id')!;
|
||||
@@ -18781,10 +18766,7 @@ final class $$TrackerEntriesTableReferences
|
||||
|
||||
static $HabitDoseVariantsTable _variantIdTable(_$AppDatabase db) =>
|
||||
db.habitDoseVariants.createAlias(
|
||||
$_aliasNameGenerator(
|
||||
db.trackerEntries.variantId,
|
||||
db.habitDoseVariants.variantId,
|
||||
),
|
||||
'tracker_entries__variant_id__habit_dose_variants__variant_id',
|
||||
);
|
||||
|
||||
$$HabitDoseVariantsTableProcessedTableManager? get variantId {
|
||||
@@ -19255,9 +19237,8 @@ final class $$LapseLogsTableReferences
|
||||
extends BaseReferences<_$AppDatabase, $LapseLogsTable, LapseLog> {
|
||||
$$LapseLogsTableReferences(super.$_db, super.$_table, super.$_typedResult);
|
||||
|
||||
static $HabitsTable _habitIdTable(_$AppDatabase db) => db.habits.createAlias(
|
||||
$_aliasNameGenerator(db.lapseLogs.habitId, db.habits.id),
|
||||
);
|
||||
static $HabitsTable _habitIdTable(_$AppDatabase db) =>
|
||||
db.habits.createAlias('lapse_logs__habit_id__habits__id');
|
||||
|
||||
$$HabitsTableProcessedTableManager get habitId {
|
||||
final $_column = $_itemColumn<String>('habit_id')!;
|
||||
@@ -19655,9 +19636,8 @@ final class $$UrgeLogsTableReferences
|
||||
extends BaseReferences<_$AppDatabase, $UrgeLogsTable, UrgeLog> {
|
||||
$$UrgeLogsTableReferences(super.$_db, super.$_table, super.$_typedResult);
|
||||
|
||||
static $HabitsTable _habitIdTable(_$AppDatabase db) => db.habits.createAlias(
|
||||
$_aliasNameGenerator(db.urgeLogs.habitId, db.habits.id),
|
||||
);
|
||||
static $HabitsTable _habitIdTable(_$AppDatabase db) =>
|
||||
db.habits.createAlias('urge_logs__habit_id__habits__id');
|
||||
|
||||
$$HabitsTableProcessedTableManager get habitId {
|
||||
final $_column = $_itemColumn<String>('habit_id')!;
|
||||
@@ -20074,9 +20054,8 @@ final class $$RewardDeclarationsTableReferences
|
||||
super.$_typedResult,
|
||||
);
|
||||
|
||||
static $PhasesTable _phaseIdTable(_$AppDatabase db) => db.phases.createAlias(
|
||||
$_aliasNameGenerator(db.rewardDeclarations.phaseId, db.phases.id),
|
||||
);
|
||||
static $PhasesTable _phaseIdTable(_$AppDatabase db) =>
|
||||
db.phases.createAlias('reward_declarations__phase_id__phases__id');
|
||||
|
||||
$$PhasesTableProcessedTableManager get phaseId {
|
||||
final $_column = $_itemColumn<String>('phase_id')!;
|
||||
@@ -20092,9 +20071,8 @@ final class $$RewardDeclarationsTableReferences
|
||||
);
|
||||
}
|
||||
|
||||
static $HabitsTable _habitIdTable(_$AppDatabase db) => db.habits.createAlias(
|
||||
$_aliasNameGenerator(db.rewardDeclarations.habitId, db.habits.id),
|
||||
);
|
||||
static $HabitsTable _habitIdTable(_$AppDatabase db) =>
|
||||
db.habits.createAlias('reward_declarations__habit_id__habits__id');
|
||||
|
||||
$$HabitsTableProcessedTableManager get habitId {
|
||||
final $_column = $_itemColumn<String>('habit_id')!;
|
||||
@@ -20113,10 +20091,7 @@ final class $$RewardDeclarationsTableReferences
|
||||
static MultiTypedResultKey<$RewardClaimsTable, List<RewardClaim>>
|
||||
_rewardClaimsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable(
|
||||
db.rewardClaims,
|
||||
aliasName: $_aliasNameGenerator(
|
||||
db.rewardDeclarations.id,
|
||||
db.rewardClaims.declarationId,
|
||||
),
|
||||
aliasName: 'reward_declarations__id__reward_claims__declaration_id',
|
||||
);
|
||||
|
||||
$$RewardClaimsTableProcessedTableManager get rewardClaimsRefs {
|
||||
@@ -20710,13 +20685,9 @@ final class $$RewardClaimsTableReferences
|
||||
extends BaseReferences<_$AppDatabase, $RewardClaimsTable, RewardClaim> {
|
||||
$$RewardClaimsTableReferences(super.$_db, super.$_table, super.$_typedResult);
|
||||
|
||||
static $RewardDeclarationsTable _declarationIdTable(_$AppDatabase db) =>
|
||||
db.rewardDeclarations.createAlias(
|
||||
$_aliasNameGenerator(
|
||||
db.rewardClaims.declarationId,
|
||||
db.rewardDeclarations.id,
|
||||
),
|
||||
);
|
||||
static $RewardDeclarationsTable _declarationIdTable(_$AppDatabase db) => db
|
||||
.rewardDeclarations
|
||||
.createAlias('reward_claims__declaration_id__reward_declarations__id');
|
||||
|
||||
$$RewardDeclarationsTableProcessedTableManager get declarationId {
|
||||
final $_column = $_itemColumn<String>('declaration_id')!;
|
||||
@@ -21070,9 +21041,8 @@ final class $$ReflectionsTableReferences
|
||||
extends BaseReferences<_$AppDatabase, $ReflectionsTable, Reflection> {
|
||||
$$ReflectionsTableReferences(super.$_db, super.$_table, super.$_typedResult);
|
||||
|
||||
static $UsersTable _userIdTable(_$AppDatabase db) => db.users.createAlias(
|
||||
$_aliasNameGenerator(db.reflections.userId, db.users.id),
|
||||
);
|
||||
static $UsersTable _userIdTable(_$AppDatabase db) =>
|
||||
db.users.createAlias('reflections__user_id__users__id');
|
||||
|
||||
$$UsersTableProcessedTableManager get userId {
|
||||
final $_column = $_itemColumn<String>('user_id')!;
|
||||
@@ -21088,9 +21058,8 @@ final class $$ReflectionsTableReferences
|
||||
);
|
||||
}
|
||||
|
||||
static $PhasesTable _phaseIdTable(_$AppDatabase db) => db.phases.createAlias(
|
||||
$_aliasNameGenerator(db.reflections.phaseId, db.phases.id),
|
||||
);
|
||||
static $PhasesTable _phaseIdTable(_$AppDatabase db) =>
|
||||
db.phases.createAlias('reflections__phase_id__phases__id');
|
||||
|
||||
$$PhasesTableProcessedTableManager? get phaseId {
|
||||
final $_column = $_itemColumn<String>('phase_id');
|
||||
|
||||
@@ -9,4 +9,21 @@ mixin _$HabitDaoMixin on DatabaseAccessor<AppDatabase> {
|
||||
$HabitsTable get habits => attachedDatabase.habits;
|
||||
$HabitDoseVariantsTable get habitDoseVariants =>
|
||||
attachedDatabase.habitDoseVariants;
|
||||
HabitDaoManager get managers => HabitDaoManager(this);
|
||||
}
|
||||
|
||||
class HabitDaoManager {
|
||||
final _$HabitDaoMixin _db;
|
||||
HabitDaoManager(this._db);
|
||||
$$UsersTableTableManager get users =>
|
||||
$$UsersTableTableManager(_db.attachedDatabase, _db.users);
|
||||
$$PhasesTableTableManager get phases =>
|
||||
$$PhasesTableTableManager(_db.attachedDatabase, _db.phases);
|
||||
$$HabitsTableTableManager get habits =>
|
||||
$$HabitsTableTableManager(_db.attachedDatabase, _db.habits);
|
||||
$$HabitDoseVariantsTableTableManager get habitDoseVariants =>
|
||||
$$HabitDoseVariantsTableTableManager(
|
||||
_db.attachedDatabase,
|
||||
_db.habitDoseVariants,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,4 +5,12 @@ part of 'meta_dao.dart';
|
||||
// ignore_for_file: type=lint
|
||||
mixin _$MetaDaoMixin on DatabaseAccessor<AppDatabase> {
|
||||
$MetaKvTable get metaKv => attachedDatabase.metaKv;
|
||||
MetaDaoManager get managers => MetaDaoManager(this);
|
||||
}
|
||||
|
||||
class MetaDaoManager {
|
||||
final _$MetaDaoMixin _db;
|
||||
MetaDaoManager(this._db);
|
||||
$$MetaKvTableTableManager get metaKv =>
|
||||
$$MetaKvTableTableManager(_db.attachedDatabase, _db.metaKv);
|
||||
}
|
||||
|
||||
@@ -10,4 +10,26 @@ mixin _$TrackerDaoMixin on DatabaseAccessor<AppDatabase> {
|
||||
$HabitDoseVariantsTable get habitDoseVariants =>
|
||||
attachedDatabase.habitDoseVariants;
|
||||
$TrackerEntriesTable get trackerEntries => attachedDatabase.trackerEntries;
|
||||
TrackerDaoManager get managers => TrackerDaoManager(this);
|
||||
}
|
||||
|
||||
class TrackerDaoManager {
|
||||
final _$TrackerDaoMixin _db;
|
||||
TrackerDaoManager(this._db);
|
||||
$$UsersTableTableManager get users =>
|
||||
$$UsersTableTableManager(_db.attachedDatabase, _db.users);
|
||||
$$PhasesTableTableManager get phases =>
|
||||
$$PhasesTableTableManager(_db.attachedDatabase, _db.phases);
|
||||
$$HabitsTableTableManager get habits =>
|
||||
$$HabitsTableTableManager(_db.attachedDatabase, _db.habits);
|
||||
$$HabitDoseVariantsTableTableManager get habitDoseVariants =>
|
||||
$$HabitDoseVariantsTableTableManager(
|
||||
_db.attachedDatabase,
|
||||
_db.habitDoseVariants,
|
||||
);
|
||||
$$TrackerEntriesTableTableManager get trackerEntries =>
|
||||
$$TrackerEntriesTableTableManager(
|
||||
_db.attachedDatabase,
|
||||
_db.trackerEntries,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@ import 'package:drift/drift.dart';
|
||||
class Protocols extends Table {
|
||||
TextColumn get id => text()();
|
||||
TextColumn get category => text().check(const CustomExpression<bool>(
|
||||
"category IN ('health','meditation','motivation','habit','learning','diet')"))();
|
||||
"category IN ('light_circadian','sleep','movement','nutrition',"
|
||||
"'focus_cognition','recovery_stress','emotion_relationship')"))();
|
||||
TextColumn get title => text()();
|
||||
TextColumn get titleEn => text().nullable()();
|
||||
TextColumn get what => text()();
|
||||
|
||||
166
app/lib/domain/catalog/catalog_item.dart
Normal file
166
app/lib/domain/catalog/catalog_item.dart
Normal file
@@ -0,0 +1,166 @@
|
||||
import 'display_category.dart';
|
||||
|
||||
/// 갤러리 UI 가 소비하는 통합 카탈로그 항목.
|
||||
///
|
||||
/// 3 source (ProtocolsTable / BreakProtocolsTable / DietPatternsTable) 를
|
||||
/// 단일 sealed 계층으로 통합. 카드/필터링은 공통 필드만 보면 충분.
|
||||
sealed class CatalogItem {
|
||||
String get id;
|
||||
String get title;
|
||||
String? get titleEn;
|
||||
|
||||
/// 카드용 1줄 요약 (≤ 60자).
|
||||
String get summary;
|
||||
|
||||
DisplayCategory get displayCategory;
|
||||
|
||||
/// 'strong_rct' / 'meta_analysis' / 'observational' / 'mechanistic' / 'expert_opinion' / null.
|
||||
/// DietPattern 은 'strong'/'moderate'/'mixed'/'weak'.
|
||||
String? get evidenceStrength;
|
||||
|
||||
List<String> get referenceIds;
|
||||
}
|
||||
|
||||
/// Protocols 테이블 1:1 매핑.
|
||||
final class ProtocolCatalogItem implements CatalogItem {
|
||||
ProtocolCatalogItem({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.titleEn,
|
||||
required this.summary,
|
||||
required this.displayCategory,
|
||||
required this.evidenceStrength,
|
||||
required this.referenceIds,
|
||||
required this.what,
|
||||
required this.whenText,
|
||||
required this.dose,
|
||||
required this.why,
|
||||
required this.how,
|
||||
required this.checkText,
|
||||
required this.caution,
|
||||
required this.defaultAnchor,
|
||||
required this.minDoseForStart,
|
||||
required this.sourceDoc,
|
||||
});
|
||||
|
||||
@override
|
||||
final String id;
|
||||
@override
|
||||
final String title;
|
||||
@override
|
||||
final String? titleEn;
|
||||
@override
|
||||
final String summary;
|
||||
@override
|
||||
final DisplayCategory displayCategory;
|
||||
@override
|
||||
final String? evidenceStrength;
|
||||
@override
|
||||
final List<String> referenceIds;
|
||||
|
||||
final String what;
|
||||
final String whenText;
|
||||
final String dose;
|
||||
final String why;
|
||||
final List<String> how;
|
||||
final String checkText;
|
||||
final String? caution;
|
||||
final Map<String, dynamic>? defaultAnchor;
|
||||
final String? minDoseForStart;
|
||||
final String? sourceDoc;
|
||||
}
|
||||
|
||||
/// BreakProtocols 테이블 1:1 매핑. displayCategory 는 항상 breakHabit.
|
||||
final class BreakCatalogItem implements CatalogItem {
|
||||
BreakCatalogItem({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.titleEn,
|
||||
required this.summary,
|
||||
required this.evidenceStrength,
|
||||
required this.referenceIds,
|
||||
required this.breakCategory,
|
||||
required this.hubermanSummary,
|
||||
required this.phases,
|
||||
required this.defaultCommonFrames,
|
||||
required this.tools,
|
||||
required this.medicalWarning,
|
||||
});
|
||||
|
||||
@override
|
||||
final String id;
|
||||
@override
|
||||
final String title;
|
||||
@override
|
||||
final String? titleEn;
|
||||
@override
|
||||
final String summary;
|
||||
@override
|
||||
DisplayCategory get displayCategory => DisplayCategory.breakHabit;
|
||||
@override
|
||||
final String? evidenceStrength;
|
||||
@override
|
||||
final List<String> referenceIds;
|
||||
|
||||
/// 원본 break 카테고리 (alcohol / nicotine / ...). 카드 sub-tag 로 활용.
|
||||
final String breakCategory;
|
||||
final String hubermanSummary;
|
||||
final List<String> phases;
|
||||
final List<String> defaultCommonFrames;
|
||||
final List<String> tools;
|
||||
final String? medicalWarning;
|
||||
}
|
||||
|
||||
/// DietPatterns 테이블 1:1 매핑. displayCategory 는 항상 nutrition.
|
||||
final class DietCatalogItem implements CatalogItem {
|
||||
DietCatalogItem({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.titleEn,
|
||||
required this.summary,
|
||||
required this.evidenceStrength,
|
||||
required this.referenceIds,
|
||||
required this.name,
|
||||
required this.core,
|
||||
required this.strengths,
|
||||
required this.weaknesses,
|
||||
required this.koreanContextFit,
|
||||
required this.starterLevers,
|
||||
required this.medicalWarning,
|
||||
required this.linkedProtocolIds,
|
||||
});
|
||||
|
||||
@override
|
||||
final String id;
|
||||
@override
|
||||
final String title;
|
||||
@override
|
||||
final String? titleEn;
|
||||
@override
|
||||
final String summary;
|
||||
@override
|
||||
DisplayCategory get displayCategory => DisplayCategory.nutrition;
|
||||
@override
|
||||
final String? evidenceStrength;
|
||||
@override
|
||||
final List<String> referenceIds;
|
||||
|
||||
final String name;
|
||||
final String core;
|
||||
final List<String> strengths;
|
||||
final List<String> weaknesses;
|
||||
final String? koreanContextFit;
|
||||
final List<String> starterLevers;
|
||||
final String? medicalWarning;
|
||||
final List<String> linkedProtocolIds;
|
||||
}
|
||||
|
||||
/// 그룹핑 헬퍼. 빈 카테고리 키는 결과 map 에 미포함.
|
||||
Map<DisplayCategory, List<CatalogItem>> groupByCategory(
|
||||
List<CatalogItem> items) {
|
||||
final result = <DisplayCategory, List<CatalogItem>>{};
|
||||
for (final item in items) {
|
||||
result.putIfAbsent(item.displayCategory, () => []).add(item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
35
app/lib/domain/catalog/display_category.dart
Normal file
35
app/lib/domain/catalog/display_category.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// UI 노출용 카테고리. Protocol DB 의 source `category` 와 직교.
|
||||
///
|
||||
/// 7개는 `protocols.category` 와 1:1 매핑, `breakHabit` 는 BreakProtocols 전용.
|
||||
/// DietPatterns 는 항상 `nutrition`.
|
||||
enum DisplayCategory {
|
||||
lightCircadian('빛/일주기', Icons.wb_sunny, 'light_circadian'),
|
||||
sleep('수면', Icons.bedtime, 'sleep'),
|
||||
movement('운동/신체', Icons.fitness_center, 'movement'),
|
||||
nutrition('영양', Icons.restaurant, 'nutrition'),
|
||||
focusCognition('집중/인지', Icons.psychology, 'focus_cognition'),
|
||||
recoveryStress('회복/스트레스', Icons.spa, 'recovery_stress'),
|
||||
emotionRelationship('감정/관계', Icons.favorite, 'emotion_relationship'),
|
||||
breakHabit('없애기', Icons.block, null);
|
||||
|
||||
const DisplayCategory(this.label, this.icon, this.protocolKey);
|
||||
|
||||
/// 사용자 노출 라벨 (한국어).
|
||||
final String label;
|
||||
|
||||
/// 카드/칩 아이콘.
|
||||
final IconData icon;
|
||||
|
||||
/// `protocols.category` snake_case 값. `breakHabit` 만 null (별도 source).
|
||||
final String? protocolKey;
|
||||
|
||||
/// `protocols.category` 문자열 → enum. 미매칭 시 null.
|
||||
static DisplayCategory? fromProtocolCategory(String raw) {
|
||||
for (final c in values) {
|
||||
if (c.protocolKey == raw) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'ai/tools/tool_definition.dart' as tools;
|
||||
import 'data/ai/gemma_llm_service.dart';
|
||||
import 'data/ai/llm_service.dart';
|
||||
import 'data/ai/model_lifecycle.dart';
|
||||
import 'data/db/daos/meta_dao.dart';
|
||||
import 'state/ai_providers.dart';
|
||||
import 'state/providers.dart';
|
||||
import 'ui/screens/habit_list_screen.dart';
|
||||
@@ -12,15 +16,76 @@ Future<void> main() async {
|
||||
runApp(ProviderScope(
|
||||
overrides: [
|
||||
appDatabaseProvider.overrideWithValue(db),
|
||||
// OQ-1 pending: production-ready GemmaLlmService is wired here once
|
||||
// model URL + SHA are pinned and flutter_gemma is added. Until then,
|
||||
// MockLlmService keeps the app graceful (suggestFrame returns []).
|
||||
llmServiceProvider.overrideWithValue(MockLlmService()),
|
||||
// #218: real GemmaLlmService when model file is on disk + verified,
|
||||
// MockLlmService otherwise. The provider is read lazily by the frame
|
||||
// suggestion flow, so the resolution is dynamic per call.
|
||||
llmServiceProvider.overrideWith((ref) {
|
||||
return _LazyLlmService(
|
||||
lifecycle: ref.watch(modelLifecycleProvider),
|
||||
meta: ref.watch(metaDaoProvider),
|
||||
);
|
||||
}),
|
||||
],
|
||||
child: const LifeHelperApp(),
|
||||
));
|
||||
}
|
||||
|
||||
/// Adapter that lazily resolves between [GemmaLlmService] (when the
|
||||
/// model file exists + meta is intact) and [MockLlmService] (fallback,
|
||||
/// graceful empty candidates). Keeps the rest of the app unaware of
|
||||
/// the difference — `suggestFrame` only sees [LlmService].
|
||||
class _LazyLlmService implements LlmService {
|
||||
_LazyLlmService({required this.lifecycle, required this.meta});
|
||||
final ModelLifecycle lifecycle;
|
||||
final MetaDao meta;
|
||||
LlmService? _delegate;
|
||||
|
||||
Future<LlmService> _resolve() async {
|
||||
final avail = await lifecycle.checkAvailability();
|
||||
final path = await meta.find(AiMetaKeys.modelPath);
|
||||
final wantGemma = avail == ModelAvailability.ready && path != null;
|
||||
// Re-resolve every call so opt-in / opt-out state changes are reflected
|
||||
// without an app restart. Repeat-resolve of the same kind reuses the
|
||||
// cached instance (Gemma's flutter_gemma installModel is idempotent;
|
||||
// Mock has no setup), but the kind itself flips when availability does.
|
||||
final keep = _delegate != null &&
|
||||
(wantGemma == (_delegate is GemmaLlmService)) &&
|
||||
(!wantGemma ||
|
||||
(_delegate as GemmaLlmService).modelPath == path);
|
||||
if (!keep) {
|
||||
_delegate = wantGemma
|
||||
? GemmaLlmService(modelPath: path)
|
||||
: MockLlmService();
|
||||
}
|
||||
return _delegate!;
|
||||
}
|
||||
|
||||
@override
|
||||
bool get isLoaded => _delegate?.isLoaded ?? false;
|
||||
|
||||
@override
|
||||
Future<void> load() async => (await _resolve()).load();
|
||||
|
||||
@override
|
||||
Future<void> unload() async {
|
||||
final d = _delegate;
|
||||
if (d != null) await d.unload();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> generateStructured(
|
||||
String prompt,
|
||||
Map<String, dynamic> schema,
|
||||
) async =>
|
||||
(await _resolve()).generateStructured(prompt, schema);
|
||||
|
||||
@override
|
||||
Future<LlmChatSession> startChat({
|
||||
required List<tools.ToolDefinition> tools,
|
||||
}) async =>
|
||||
(await _resolve()).startChat(tools: tools);
|
||||
}
|
||||
|
||||
class LifeHelperApp extends StatelessWidget {
|
||||
const LifeHelperApp({super.key});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../data/ai/device_capabilities.dart';
|
||||
import '../data/ai/llm_service.dart';
|
||||
import '../data/ai/model_lifecycle.dart';
|
||||
import '../data/db/app_database.dart' as drift;
|
||||
@@ -10,19 +11,37 @@ import '../domain/ai/suggest_frame.dart';
|
||||
import '../domain/models/frame_pattern.dart';
|
||||
import 'providers.dart';
|
||||
|
||||
/// Default config for the on-device Gemma model (#215).
|
||||
/// OQ-1: URL + SHA-256 pinned in Developer phase. Until then, downloads are
|
||||
/// disabled (AI toggle is gated behind these constants being real).
|
||||
const _kModelUrlPlaceholder =
|
||||
'https://example.invalid/gemma4-e2b-q4.bin'; // OQ-1
|
||||
const _kModelShaPlaceholder = 'PENDING_OQ_1';
|
||||
/// Gemma 4 E2B instruction-tuned LiteRT-LM checkpoint (#218 OQ-1 resolved).
|
||||
/// Hosted on HuggingFace `litert-community/gemma-4-E2B-it-litert-lm`.
|
||||
/// File ≈ 2.41GB; SHA-256 pinned for integrity check.
|
||||
///
|
||||
/// Tests / placeholder builds may override `modelLifecycleProvider` with
|
||||
/// fixture URLs. Production builds optionally inject a private mirror via
|
||||
/// `--dart-define=GEMMA_MODEL_URL=...` (see main.dart).
|
||||
const _kModelUrl =
|
||||
'https://huggingface.co/litert-community/gemma-4-E2B-it-litert-lm/resolve/main/gemma-4-E2B-it.litertlm';
|
||||
const _kModelSha256 =
|
||||
'181938105e0eefd105961417e8da75903eacda102c4fce9ce90f50b97139a63c';
|
||||
|
||||
/// #218 AC-6: device-capability gate. RAM < 4GB → AI feature disabled.
|
||||
/// Default implementation calls the `life_helper/device_caps` MethodChannel
|
||||
/// (Android). Override in tests with a `_FakeDeviceCapabilities`.
|
||||
final deviceCapabilitiesProvider = Provider<DeviceCapabilities>((ref) {
|
||||
return PlatformDeviceCapabilities();
|
||||
});
|
||||
|
||||
/// `true` iff the device has ≥ 4GB RAM. Default `false` (fail-closed) while
|
||||
/// the platform call is in flight or on unsupported hosts (iOS / test).
|
||||
final deviceMeetsAiRamProvider = FutureProvider<bool>((ref) async {
|
||||
return ref.watch(deviceCapabilitiesProvider).meetsAiMinRam();
|
||||
});
|
||||
|
||||
final modelLifecycleProvider = Provider<ModelLifecycle>((ref) {
|
||||
return ModelLifecycle(
|
||||
meta: ref.watch(metaDaoProvider),
|
||||
config: ModelConfig(
|
||||
url: Uri.parse(_kModelUrlPlaceholder),
|
||||
expectedSha256: _kModelShaPlaceholder,
|
||||
url: Uri.parse(_kModelUrl),
|
||||
expectedSha256: _kModelSha256,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
37
app/lib/state/catalog_providers.dart
Normal file
37
app/lib/state/catalog_providers.dart
Normal file
@@ -0,0 +1,37 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../data/catalog/catalog_repository.dart';
|
||||
import '../data/db/app_database.dart';
|
||||
import '../domain/catalog/catalog_item.dart';
|
||||
import '../domain/catalog/display_category.dart';
|
||||
import 'providers.dart';
|
||||
|
||||
final catalogRepositoryProvider = Provider<CatalogRepository>((ref) {
|
||||
return CatalogRepository(ref.watch(appDatabaseProvider));
|
||||
});
|
||||
|
||||
/// 갤러리 진입 시 1회 로드. seed 가 끝난 가정 (bootstrap 이 보장).
|
||||
final catalogItemsProvider = FutureProvider<List<CatalogItem>>((ref) async {
|
||||
// bootstrap 가 끝난 후에만 의미 있음.
|
||||
await ref.watch(bootstrapProvider.future);
|
||||
return ref.watch(catalogRepositoryProvider).all();
|
||||
});
|
||||
|
||||
/// 빈 카테고리 키는 결과에 미포함 — 갤러리는 결과 key 만 칩으로 렌더.
|
||||
final groupedByCategoryProvider =
|
||||
FutureProvider<Map<DisplayCategory, List<CatalogItem>>>((ref) async {
|
||||
final items = await ref.watch(catalogItemsProvider.future);
|
||||
return groupByCategory(items);
|
||||
});
|
||||
|
||||
/// Preview 화면용. id → 단건. 미매칭 시 null.
|
||||
final catalogItemByIdProvider =
|
||||
FutureProvider.family<CatalogItem?, String>((ref, id) async {
|
||||
return ref.watch(catalogRepositoryProvider).byId(id);
|
||||
});
|
||||
|
||||
/// reference id 리스트 → ReferenceRow 들. ids 비면 빈 리스트.
|
||||
final referencesByIdsProvider =
|
||||
FutureProvider.family<List<ReferenceRow>, List<String>>((ref, ids) async {
|
||||
return ref.watch(catalogRepositoryProvider).referencesByIds(ids);
|
||||
});
|
||||
262
app/lib/state/chat_providers.dart
Normal file
262
app/lib/state/chat_providers.dart
Normal file
@@ -0,0 +1,262 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../ai/tools/tool_definition.dart';
|
||||
import '../ai/tools/tool_dispatcher.dart';
|
||||
import '../ai/tools/tool_envelope.dart';
|
||||
import '../ai/tools/tool_registry.dart';
|
||||
import '../core/constants.dart';
|
||||
import '../data/ai/llm_service.dart';
|
||||
import 'ai_providers.dart';
|
||||
import 'catalog_providers.dart';
|
||||
import 'providers.dart';
|
||||
|
||||
/// Multi-turn safety cap. ADR-0005 §C — guards against tool-call loops.
|
||||
const int kChatMaxTurns = 4;
|
||||
|
||||
/// Soft warning threshold for chat history bloat (OQ-2).
|
||||
const int kChatSoftHistoryLimit = 8;
|
||||
|
||||
sealed class ChatMessage {
|
||||
const ChatMessage();
|
||||
}
|
||||
|
||||
final class UserChatMessage extends ChatMessage {
|
||||
final String text;
|
||||
const UserChatMessage(this.text);
|
||||
}
|
||||
|
||||
final class ModelChatMessage extends ChatMessage {
|
||||
final String text;
|
||||
const ModelChatMessage(this.text);
|
||||
}
|
||||
|
||||
final class ToolCallChatMessage extends ChatMessage {
|
||||
final String name;
|
||||
final Map<String, dynamic> args;
|
||||
final ToolResult result;
|
||||
const ToolCallChatMessage(this.name, this.args, this.result);
|
||||
}
|
||||
|
||||
final class SystemChatMessage extends ChatMessage {
|
||||
final String text;
|
||||
const SystemChatMessage(this.text);
|
||||
}
|
||||
|
||||
class ChatSessionState {
|
||||
final List<ChatMessage> messages;
|
||||
final bool isStreaming;
|
||||
final String? streamingText;
|
||||
final String? error;
|
||||
|
||||
const ChatSessionState({
|
||||
this.messages = const [],
|
||||
this.isStreaming = false,
|
||||
this.streamingText,
|
||||
this.error,
|
||||
});
|
||||
|
||||
ChatSessionState copyWith({
|
||||
List<ChatMessage>? messages,
|
||||
bool? isStreaming,
|
||||
String? streamingText,
|
||||
String? error,
|
||||
bool clearStreamingText = false,
|
||||
bool clearError = false,
|
||||
}) {
|
||||
return ChatSessionState(
|
||||
messages: messages ?? this.messages,
|
||||
isStreaming: isStreaming ?? this.isStreaming,
|
||||
streamingText: clearStreamingText
|
||||
? null
|
||||
: (streamingText ?? this.streamingText),
|
||||
error: clearError ? null : (error ?? this.error),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final toolRegistryProvider = Provider<ToolRegistry>((ref) {
|
||||
return ToolRegistry.defaults();
|
||||
});
|
||||
|
||||
final toolDepsProvider = FutureProvider<ToolDeps>((ref) async {
|
||||
// bootstrap 가 끝나야 seed 가 채워진 framePatterns 를 신뢰할 수 있음.
|
||||
await ref.watch(bootstrapProvider.future);
|
||||
final framePatterns = await ref.watch(framePatternsProvider.future);
|
||||
return ToolDeps(
|
||||
habitDao: ref.watch(habitDaoProvider),
|
||||
trackerDao: ref.watch(trackerDaoProvider),
|
||||
catalog: ref.watch(catalogRepositoryProvider),
|
||||
framePatterns: framePatterns,
|
||||
userId: kLocalDefaultUserId,
|
||||
);
|
||||
});
|
||||
|
||||
final toolDispatcherProvider = Provider<ToolDispatcher>((ref) {
|
||||
return ToolDispatcher(registry: ref.watch(toolRegistryProvider));
|
||||
});
|
||||
|
||||
class ChatSessionController extends StateNotifier<ChatSessionState> {
|
||||
ChatSessionController({
|
||||
required this.llm,
|
||||
required this.dispatcher,
|
||||
required this.deps,
|
||||
required this.tools,
|
||||
}) : super(const ChatSessionState());
|
||||
|
||||
final LlmService llm;
|
||||
final ToolDispatcher dispatcher;
|
||||
final ToolDeps deps;
|
||||
final List<ToolDefinition> tools;
|
||||
|
||||
LlmChatSession? _session;
|
||||
|
||||
Future<void> userTurn(String text, BuildContext context) async {
|
||||
final trimmed = text.trim();
|
||||
if (trimmed.isEmpty) return;
|
||||
if (state.isStreaming) return;
|
||||
|
||||
state = state.copyWith(
|
||||
messages: [...state.messages, UserChatMessage(trimmed)],
|
||||
isStreaming: true,
|
||||
streamingText: '',
|
||||
clearError: true,
|
||||
);
|
||||
|
||||
try {
|
||||
// 1회 lazy load.
|
||||
if (!llm.isLoaded) {
|
||||
await llm.load();
|
||||
}
|
||||
_session ??= await llm.startChat(tools: tools);
|
||||
|
||||
String? pendingToolName;
|
||||
Map<String, dynamic>? pendingToolResult;
|
||||
Stream<LlmChatEvent> Function() nextStream = () =>
|
||||
_session!.sendUser(trimmed);
|
||||
|
||||
for (var turn = 0; turn < kChatMaxTurns; turn++) {
|
||||
var accumulated = '';
|
||||
LlmFunctionCall? toolCall;
|
||||
|
||||
await for (final event in nextStream()) {
|
||||
if (event is LlmTextChunk) {
|
||||
accumulated += event.text;
|
||||
if (!mounted) return;
|
||||
state = state.copyWith(streamingText: accumulated);
|
||||
} else if (event is LlmFunctionCall) {
|
||||
toolCall = event;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (toolCall == null) {
|
||||
// 자연어 응답으로 종료.
|
||||
if (!mounted) return;
|
||||
state = state.copyWith(
|
||||
messages: [
|
||||
...state.messages,
|
||||
ModelChatMessage(accumulated),
|
||||
],
|
||||
isStreaming: false,
|
||||
clearStreamingText: true,
|
||||
);
|
||||
_maybeWarnHistory();
|
||||
return;
|
||||
}
|
||||
|
||||
// Tool 처리.
|
||||
if (!mounted) return;
|
||||
final result = await dispatcher.dispatch(
|
||||
toolName: toolCall.name,
|
||||
rawArgs: toolCall.args,
|
||||
confirmContext: context.mounted ? context : null,
|
||||
deps: deps,
|
||||
);
|
||||
if (!mounted) return;
|
||||
state = state.copyWith(
|
||||
messages: [
|
||||
...state.messages,
|
||||
ToolCallChatMessage(toolCall.name, toolCall.args, result),
|
||||
],
|
||||
streamingText: '',
|
||||
);
|
||||
|
||||
pendingToolName = toolCall.name;
|
||||
// ADR-0005 / OQ-2: hard-cap tool result at 2KB so LLM context window
|
||||
// can't be blown by a runaway ToolOk payload. encodeToolResult applies
|
||||
// truncate-with-hint when needed; jsonDecode round-trips back to a Map
|
||||
// because the chat session API expects Map<String, dynamic>.
|
||||
final capped = jsonDecode(encodeToolResult(result)) as Map<String, dynamic>;
|
||||
pendingToolResult = capped;
|
||||
final capturedName = pendingToolName;
|
||||
final capturedResult = pendingToolResult;
|
||||
nextStream = () => _session!.sendToolResult(
|
||||
toolName: capturedName,
|
||||
result: capturedResult,
|
||||
);
|
||||
}
|
||||
|
||||
// MAX_TURNS 초과 안전 종료.
|
||||
if (!mounted) return;
|
||||
state = state.copyWith(
|
||||
isStreaming: false,
|
||||
clearStreamingText: true,
|
||||
error: '도구 호출 루프가 너무 길어 중단했습니다.',
|
||||
);
|
||||
} catch (e, st) {
|
||||
if (!mounted) return;
|
||||
// 개발 단계 (#342) — 실 단말 진단을 위해 release 빌드에서도 full
|
||||
// message + stack 노출. 사용자 친화 메시지로 다시 좁히는 작업은
|
||||
// #342 종료 후 follow-up.
|
||||
final detail = 'LLM 응답 실패: ${e.runtimeType}\n$e\n\n--- STACK ---\n$st';
|
||||
state = state.copyWith(
|
||||
isStreaming: false,
|
||||
clearStreamingText: true,
|
||||
error: detail,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void clear() {
|
||||
state = const ChatSessionState();
|
||||
}
|
||||
|
||||
void _maybeWarnHistory() {
|
||||
final turnCount = state.messages
|
||||
.whereType<UserChatMessage>()
|
||||
.length;
|
||||
if (turnCount == kChatSoftHistoryLimit) {
|
||||
state = state.copyWith(
|
||||
messages: [
|
||||
...state.messages,
|
||||
const SystemChatMessage(
|
||||
'대화가 길어졌어요. 다시 시작하면 모델이 더 빠르게 답할 수 있어요. (오른쪽 위 ↻ 버튼)',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_session?.close();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
final chatSessionControllerProvider = StateNotifierProvider.autoDispose<
|
||||
ChatSessionController, ChatSessionState>((ref) {
|
||||
final llm = ref.watch(llmServiceProvider);
|
||||
final dispatcher = ref.watch(toolDispatcherProvider);
|
||||
final deps = ref.watch(toolDepsProvider).requireValue;
|
||||
final tools = ref.watch(toolRegistryProvider).all.toList(growable: false);
|
||||
return ChatSessionController(
|
||||
llm: llm,
|
||||
dispatcher: dispatcher,
|
||||
deps: deps,
|
||||
tools: tools,
|
||||
);
|
||||
});
|
||||
135
app/lib/state/chat_warmup_provider.dart
Normal file
135
app/lib/state/chat_warmup_provider.dart
Normal file
@@ -0,0 +1,135 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../data/ai/llm_service.dart';
|
||||
import '../data/ai/model_lifecycle.dart';
|
||||
import 'ai_providers.dart';
|
||||
|
||||
/// State machine for ChatScreen LLM warm-up (#311).
|
||||
///
|
||||
/// See `docs/design/311-llm-warmup/README.md` §6 / fn-chat_warmup_controller.md.
|
||||
sealed class ChatWarmupState {
|
||||
const ChatWarmupState();
|
||||
}
|
||||
|
||||
final class ChatWarmupIdle extends ChatWarmupState {
|
||||
const ChatWarmupIdle();
|
||||
}
|
||||
|
||||
final class ChatWarmupLoading extends ChatWarmupState {
|
||||
const ChatWarmupLoading();
|
||||
}
|
||||
|
||||
final class ChatWarmupReady extends ChatWarmupState {
|
||||
const ChatWarmupReady();
|
||||
}
|
||||
|
||||
/// Warm-up was not attempted because [ModelLifecycle.quickCheck] returned
|
||||
/// something other than `ready` (download incomplete, opt-out, corrupt).
|
||||
/// UI behaves as if warm-up didn't exist; the first user send falls back to
|
||||
/// the existing lazy `userTurn` path.
|
||||
final class ChatWarmupUnavailable extends ChatWarmupState {
|
||||
const ChatWarmupUnavailable();
|
||||
}
|
||||
|
||||
/// `kind` discriminates the retry copy: `fileMissing` is a settings-level
|
||||
/// recovery; `runtime` is a transient retry.
|
||||
enum ChatWarmupFailureKind { fileMissing, runtime }
|
||||
|
||||
final class ChatWarmupFailed extends ChatWarmupState {
|
||||
final String message;
|
||||
final ChatWarmupFailureKind kind;
|
||||
const ChatWarmupFailed(this.message, this.kind);
|
||||
}
|
||||
|
||||
/// Drives `LlmService.load()` on ChatScreen mount so the first user send
|
||||
/// doesn't pay native-init latency. AC1-AC12 (12개) 모두 본 controller 가
|
||||
/// 흡수한다 (UI binding 은 chat_screen.dart 가 본 state 를 watch).
|
||||
class ChatWarmupController extends StateNotifier<ChatWarmupState> {
|
||||
ChatWarmupController({
|
||||
required this.llm,
|
||||
required this.lifecycle,
|
||||
}) : super(const ChatWarmupIdle());
|
||||
|
||||
final LlmService llm;
|
||||
final ModelLifecycle lifecycle;
|
||||
bool _disposed = false;
|
||||
|
||||
/// Idempotent. Re-entrant guard via the Loading state — duplicate `start`
|
||||
/// calls during an in-flight load do nothing (the running future will set
|
||||
/// the final state). External callers use [retry] instead.
|
||||
Future<void> start() async {
|
||||
if (state is ChatWarmupLoading) return;
|
||||
|
||||
// AC11 / UX R4: fast path. Skip Loading entirely if the underlying
|
||||
// service is already loaded — prevents 1-frame label flicker on
|
||||
// ChatScreen re-entry.
|
||||
if (llm.isLoaded) {
|
||||
_safeSet(const ChatWarmupReady());
|
||||
return;
|
||||
}
|
||||
|
||||
final availability = await lifecycle.quickCheck();
|
||||
if (_disposed) return;
|
||||
if (availability != ModelAvailability.ready) {
|
||||
_safeSet(const ChatWarmupUnavailable());
|
||||
return;
|
||||
}
|
||||
|
||||
_safeSet(const ChatWarmupLoading());
|
||||
try {
|
||||
await llm.load();
|
||||
} catch (e) {
|
||||
if (_disposed) return;
|
||||
final kind = e is FileSystemException
|
||||
? ChatWarmupFailureKind.fileMissing
|
||||
: ChatWarmupFailureKind.runtime;
|
||||
_safeSet(ChatWarmupFailed(_messageFor(kind), kind));
|
||||
return;
|
||||
}
|
||||
if (_disposed) return;
|
||||
_safeSet(const ChatWarmupReady());
|
||||
}
|
||||
|
||||
Future<void> retry() async {
|
||||
if (_disposed) return;
|
||||
_safeSet(const ChatWarmupIdle());
|
||||
await start();
|
||||
}
|
||||
|
||||
/// AC6 / AC12: state는 disposed 인스턴스에는 더 이상 쓰지 않는다.
|
||||
/// StateNotifier 의 setter 는 disposed 시 throw 하므로 가드 필수.
|
||||
void _safeSet(ChatWarmupState s) {
|
||||
if (_disposed) return;
|
||||
state = s;
|
||||
}
|
||||
|
||||
/// UX R5 / AC12: 메시지는 **상태**만 기술. "다시 시도해주세요" 같은
|
||||
/// 명령형은 [다시 시도] 버튼이 담당하므로 본 문안에 넣지 않는다.
|
||||
String _messageFor(ChatWarmupFailureKind kind) {
|
||||
switch (kind) {
|
||||
case ChatWarmupFailureKind.fileMissing:
|
||||
return 'AI 모델 파일을 찾을 수 없어요.';
|
||||
case ChatWarmupFailureKind.runtime:
|
||||
return 'AI 를 시작하지 못했어요.';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// autoDispose: ChatScreen 이 pop 되면 controller 도 dispose → mount race 안전.
|
||||
final chatWarmupProvider =
|
||||
StateNotifierProvider.autoDispose<ChatWarmupController, ChatWarmupState>(
|
||||
(ref) {
|
||||
return ChatWarmupController(
|
||||
llm: ref.watch(llmServiceProvider),
|
||||
lifecycle: ref.watch(modelLifecycleProvider),
|
||||
);
|
||||
},
|
||||
);
|
||||
44
app/lib/ui/labels.dart
Normal file
44
app/lib/ui/labels.dart
Normal file
@@ -0,0 +1,44 @@
|
||||
import '../domain/models/habit.dart';
|
||||
import '../domain/streak/compute_streak.dart';
|
||||
|
||||
/// UI 한국어 라벨 매핑. domain enum 의 `dbValue` 는 DB 직렬화용이므로
|
||||
/// 사용자에게 그대로 노출하면 'build', 'L3', 'T0' 같은 raw 식별자가
|
||||
/// 그대로 보인다. 본 헬퍼는 그걸 한국어 표현으로 바꾼다.
|
||||
|
||||
String habitTypeLabel(HabitType t) {
|
||||
switch (t) {
|
||||
case HabitType.build:
|
||||
return '만들기';
|
||||
case HabitType.breakHabit:
|
||||
return '없애기';
|
||||
}
|
||||
}
|
||||
|
||||
/// Drift row (raw db String) 에서 직접 매핑. 'build' / 'break' 외의 값은
|
||||
/// 그대로 노출해 invariant 위반을 가시화.
|
||||
String habitTypeLabelFromDb(String dbValue) {
|
||||
switch (dbValue) {
|
||||
case 'build':
|
||||
return '만들기';
|
||||
case 'break':
|
||||
return '없애기';
|
||||
default:
|
||||
return dbValue;
|
||||
}
|
||||
}
|
||||
|
||||
/// 5-Tier Reward Ladder (T0 새싹 → T4 6주 완주). milestone 누적 보상.
|
||||
String rewardTierLabel(RewardTier t) {
|
||||
switch (t) {
|
||||
case RewardTier.t0:
|
||||
return '🌱 새싹';
|
||||
case RewardTier.t1:
|
||||
return '🥉 3회 도전';
|
||||
case RewardTier.t2:
|
||||
return '🥈 7일 형성';
|
||||
case RewardTier.t3:
|
||||
return '🥇 30일 정착';
|
||||
case RewardTier.t4:
|
||||
return '🏆 6주 완주';
|
||||
}
|
||||
}
|
||||
447
app/lib/ui/screens/chat_screen.dart
Normal file
447
app/lib/ui/screens/chat_screen.dart
Normal file
@@ -0,0 +1,447 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../ai/tools/tool_envelope.dart';
|
||||
import '../../state/chat_providers.dart';
|
||||
import '../../state/chat_warmup_provider.dart';
|
||||
import 'settings_screen.dart';
|
||||
|
||||
/// AI chat surface (#260). Multi-turn tool calling powered by Gemma 4 +
|
||||
/// in-process tool runtime. ConfirmGate modals appear on destructive
|
||||
/// tool calls (add_habit, log_tracker_entry).
|
||||
class ChatScreen extends ConsumerStatefulWidget {
|
||||
const ChatScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ChatScreen> createState() => _ChatScreenState();
|
||||
}
|
||||
|
||||
class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
final _textCtrl = TextEditingController();
|
||||
final _scrollCtrl = ScrollController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// #311 AC1: ChatScreen mount → background warm-up. depsAsync.data 가
|
||||
// resolve 되기 전에는 toolDepsProvider 도 미준비라 send 자체가 막혀
|
||||
// 있으므로, 그 사이에 native init 만 먼저 끝낸다.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
ref.read(chatWarmupProvider.notifier).start();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_textCtrl.dispose();
|
||||
_scrollCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _scrollToBottom() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!_scrollCtrl.hasClients) return;
|
||||
_scrollCtrl.animateTo(
|
||||
_scrollCtrl.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _send() async {
|
||||
final text = _textCtrl.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
_textCtrl.clear();
|
||||
await ref
|
||||
.read(chatSessionControllerProvider.notifier)
|
||||
.userTurn(text, context);
|
||||
_scrollToBottom();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final depsAsync = ref.watch(toolDepsProvider);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('AI 코치'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
tooltip: '새 대화 (이전 기록 비우기)',
|
||||
onPressed: () {
|
||||
ref.read(chatSessionControllerProvider.notifier).clear();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
// Android edge-to-edge: 시스템 nav bar (3-button / gesture handle) 가
|
||||
// 입력창을 가리지 않도록 SafeArea 로 감싼다. AppBar 가 이미 top inset
|
||||
// 을 처리하므로 top 만 false.
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
child: depsAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(child: Text('초기화 실패: $e')),
|
||||
data: (_) => _buildBody(context),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(BuildContext context) {
|
||||
final state = ref.watch(chatSessionControllerProvider);
|
||||
final warmup = ref.watch(chatWarmupProvider);
|
||||
_scrollToBottom();
|
||||
|
||||
// #311 AC3 / UX R3: warmup 중에는 hintText 만 교체. 입력창 자체는
|
||||
// enabled (사용자가 미리 타이핑 가능 — AC9).
|
||||
final isWarming = warmup is ChatWarmupLoading;
|
||||
final hintText = isWarming
|
||||
? 'AI 준비 중… 첫 시작은 몇 초 걸려요'
|
||||
: '습관 추가, 기록, 카탈로그 질문…';
|
||||
|
||||
// AC10: warmup ready 이고 streaming 중이 아닐 때 send 활성. 빈 텍스트는
|
||||
// _send() 가 early-return 하므로 별도 gating 불필요 (rebuild race 회피).
|
||||
final canSend = !state.isStreaming && !isWarming;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (warmup is ChatWarmupFailed) _WarmupErrorBanner(warmup: warmup),
|
||||
if (state.error != null)
|
||||
// #342 dev — 단말에서 원인 진단을 위해 stack 까지 노출되는 케이스를
|
||||
// 위해 multi-line + scrollable + selectable. 높이는 화면의 1/3 까지만.
|
||||
Container(
|
||||
width: double.infinity,
|
||||
color: theme.colorScheme.errorContainer,
|
||||
padding: const EdgeInsets.all(12),
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height / 3,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: SelectableText(
|
||||
state.error!,
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: state.messages.isEmpty && state.streamingText == null
|
||||
? _EmptyChatHint(onPickPrompt: (p) {
|
||||
_textCtrl.text = p;
|
||||
_textCtrl.selection = TextSelection.fromPosition(
|
||||
TextPosition(offset: p.length),
|
||||
);
|
||||
})
|
||||
: ListView.builder(
|
||||
controller: _scrollCtrl,
|
||||
padding: const EdgeInsets.all(12),
|
||||
itemCount: state.messages.length +
|
||||
(state.streamingText != null &&
|
||||
state.streamingText!.isNotEmpty
|
||||
? 1
|
||||
: 0),
|
||||
itemBuilder: (context, i) {
|
||||
if (i < state.messages.length) {
|
||||
return _MessageBubble(message: state.messages[i]);
|
||||
}
|
||||
return _MessageBubble(
|
||||
message: ModelChatMessage(state.streamingText ?? ''),
|
||||
streaming: true,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _textCtrl,
|
||||
enabled: !state.isStreaming,
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
maxLines: 4,
|
||||
minLines: 1,
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (_) => canSend ? _send() : null,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (state.isStreaming || isWarming)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
child: SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
)
|
||||
else
|
||||
IconButton.filled(
|
||||
onPressed: canSend ? _send : null,
|
||||
icon: const Icon(Icons.send),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// #311 AC5 / UX R5+R6: 실패 메시지는 상태만 기술, 행동은 버튼이 담당.
|
||||
/// fileMissing 은 retry 로 회복 불가 — 설정 화면으로 유도해 재다운로드 경로를 연다.
|
||||
/// runtime 은 일시적일 수 있으므로 [다시 시도] (in-place retry).
|
||||
class _WarmupErrorBanner extends ConsumerWidget {
|
||||
final ChatWarmupFailed warmup;
|
||||
const _WarmupErrorBanner({required this.warmup});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
final isFileMissing =
|
||||
warmup.kind == ChatWarmupFailureKind.fileMissing;
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
color: theme.colorScheme.errorContainer,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
warmup.message,
|
||||
style: TextStyle(color: theme.colorScheme.onErrorContainer),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: OutlinedButton(
|
||||
onPressed: () {
|
||||
if (isFileMissing) {
|
||||
// SettingsScreen pop 후 자동 retry — 사용자가 거기서 다시
|
||||
// 다운로드를 완료했다면 ChatScreen 으로 돌아오자마자 회복.
|
||||
// 다운로드 안 했으면 다시 Failed 로 떨어져 같은 배너 노출.
|
||||
Navigator.of(context)
|
||||
.push(MaterialPageRoute(
|
||||
builder: (_) => const SettingsScreen(),
|
||||
))
|
||||
.then((_) {
|
||||
if (!context.mounted) return;
|
||||
ref.read(chatWarmupProvider.notifier).retry();
|
||||
});
|
||||
} else {
|
||||
ref.read(chatWarmupProvider.notifier).retry();
|
||||
}
|
||||
},
|
||||
child: Text(isFileMissing ? '설정으로 가기' : '다시 시도'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 첫 진입 시 빈 대화창 안내. 사용자가 무엇을 물어볼 수 있는지
|
||||
/// 예시 prompt 4개 + tool 카테고리 짧은 안내. tap 하면 입력창에 채워지고
|
||||
/// 사용자가 보내기만 누르면 됨 (자동 send X — 사용자가 문구 수정 여지).
|
||||
class _EmptyChatHint extends StatelessWidget {
|
||||
final ValueChanged<String> onPickPrompt;
|
||||
const _EmptyChatHint({required this.onPickPrompt});
|
||||
|
||||
static const _examples = [
|
||||
'아침 햇빛 받기 습관 추가해줘',
|
||||
'오늘 운동 했어',
|
||||
'내 스트릭 보여줘',
|
||||
'수면 프로토콜 알려줘',
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.smart_toy_outlined,
|
||||
size: 48,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'AI 코치',
|
||||
style: theme.textTheme.titleLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'습관 추가·기록 · 카탈로그 검색 · 스트릭 조회를 도와드려요.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'예시',
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
..._examples.map((p) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: OutlinedButton(
|
||||
onPressed: () => onPickPrompt(p),
|
||||
style: OutlinedButton.styleFrom(
|
||||
alignment: Alignment.centerLeft,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16, vertical: 12),
|
||||
),
|
||||
child: Text(p),
|
||||
),
|
||||
)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Human-friendly Korean labels for the 6 tools registered in
|
||||
/// `ToolRegistry.defaults()`. Falls back to the raw tool name for any
|
||||
/// future tool that hasn't been mapped yet — better to show the raw id
|
||||
/// than to silently drop the message.
|
||||
const Map<String, String> _kToolKoreanLabels = {
|
||||
'search_catalog': '카탈로그 검색',
|
||||
'query_protocol': '프로토콜 상세',
|
||||
'list_active_habits': '활성 습관 조회',
|
||||
'get_streak': '스트릭 조회',
|
||||
'add_habit': '습관 추가',
|
||||
'log_tracker_entry': '체크 기록',
|
||||
};
|
||||
|
||||
class _MessageBubble extends StatelessWidget {
|
||||
final ChatMessage message;
|
||||
final bool streaming;
|
||||
const _MessageBubble({required this.message, this.streaming = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
switch (message) {
|
||||
case UserChatMessage m:
|
||||
return _bubble(
|
||||
context,
|
||||
align: Alignment.centerRight,
|
||||
color: theme.colorScheme.primaryContainer,
|
||||
textColor: theme.colorScheme.onPrimaryContainer,
|
||||
text: m.text,
|
||||
);
|
||||
case ModelChatMessage m:
|
||||
// Streaming cursor uses primary so it stays discoverable in both
|
||||
// light and dark themes (default onSurface low-contrasted with the
|
||||
// surfaceContainerHighest bubble in dark mode).
|
||||
return _bubble(
|
||||
context,
|
||||
align: Alignment.centerLeft,
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
textColor: theme.colorScheme.onSurface,
|
||||
richText: streaming
|
||||
? TextSpan(
|
||||
children: [
|
||||
TextSpan(text: m.text),
|
||||
TextSpan(
|
||||
text: '▍',
|
||||
style: TextStyle(color: theme.colorScheme.primary),
|
||||
),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
text: m.text,
|
||||
);
|
||||
case ToolCallChatMessage m:
|
||||
final label = _kToolKoreanLabels[m.name] ?? m.name;
|
||||
return Align(
|
||||
alignment: Alignment.center,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||
),
|
||||
child: Text(
|
||||
'🛠 $label → ${_toolResultLabel(m.result)}',
|
||||
style: theme.textTheme.bodySmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
case SystemChatMessage m:
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Text(
|
||||
m.text,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.outline,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _bubble(
|
||||
BuildContext context, {
|
||||
required Alignment align,
|
||||
required Color color,
|
||||
required Color textColor,
|
||||
required String text,
|
||||
TextSpan? richText,
|
||||
}) {
|
||||
return Align(
|
||||
alignment: align,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.of(context).size.width * 0.75,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: richText != null
|
||||
? Text.rich(richText, style: TextStyle(color: textColor))
|
||||
: Text(text, style: TextStyle(color: textColor)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _toolResultLabel(ToolResult r) {
|
||||
switch (r) {
|
||||
case ToolOk _:
|
||||
return 'OK';
|
||||
case ToolErr e:
|
||||
return '오류: ${e.code}';
|
||||
case ToolCancelled _:
|
||||
return '취소됨';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,7 @@ class _CheckInScreenState extends ConsumerState<CheckInScreen> {
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('오늘 (${_ymd(nowKst())})',
|
||||
Text('오늘 · ${_koreanDate(nowKst())}',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
textAlign: TextAlign.center),
|
||||
const SizedBox(height: 32),
|
||||
@@ -80,3 +80,9 @@ String _ymd(DateTime d) =>
|
||||
'${d.year.toString().padLeft(4, '0')}-'
|
||||
'${d.month.toString().padLeft(2, '0')}-'
|
||||
'${d.day.toString().padLeft(2, '0')}';
|
||||
|
||||
/// 사용자 노출용 한국식 날짜 — '6월 15일 (월)'. DB 저장은 _ymd 가 담당.
|
||||
String _koreanDate(DateTime d) {
|
||||
const weekdays = ['월', '화', '수', '목', '금', '토', '일'];
|
||||
return '${d.month}월 ${d.day}일 (${weekdays[d.weekday - 1]})';
|
||||
}
|
||||
|
||||
@@ -94,9 +94,8 @@ class _HabitCreateScreenState extends ConsumerState<HabitCreateScreen> {
|
||||
DropdownButtonFormField<HabitType>(
|
||||
initialValue: _type,
|
||||
items: const [
|
||||
DropdownMenuItem(value: HabitType.build, child: Text('만들기 (build)')),
|
||||
DropdownMenuItem(
|
||||
value: HabitType.breakHabit, child: Text('없애기 (break)')),
|
||||
DropdownMenuItem(value: HabitType.build, child: Text('만들기')),
|
||||
DropdownMenuItem(value: HabitType.breakHabit, child: Text('없애기')),
|
||||
],
|
||||
onChanged: (v) => setState(() => _type = v ?? HabitType.build),
|
||||
decoration: const InputDecoration(labelText: '타입'),
|
||||
@@ -105,11 +104,16 @@ class _HabitCreateScreenState extends ConsumerState<HabitCreateScreen> {
|
||||
DropdownButtonFormField<FrameLevel>(
|
||||
initialValue: _level,
|
||||
items: const [
|
||||
DropdownMenuItem(value: FrameLevel.l2, child: Text('L2 · 조건부 긍정')),
|
||||
DropdownMenuItem(value: FrameLevel.l3, child: Text('L3 · 정체성')),
|
||||
DropdownMenuItem(
|
||||
value: FrameLevel.l2, child: Text('조건부 행동 (예: 아침에 햇빛 받기)')),
|
||||
DropdownMenuItem(
|
||||
value: FrameLevel.l3, child: Text('정체성 (예: 나는 일찍 자는 사람)')),
|
||||
],
|
||||
onChanged: (v) => setState(() => _level = v ?? FrameLevel.l2),
|
||||
decoration: const InputDecoration(labelText: '프레임 레벨'),
|
||||
decoration: const InputDecoration(
|
||||
labelText: '표현 방식',
|
||||
helperText: '문구를 어떻게 적을지 — 행동 위주 vs 정체성 위주',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../state/ai_providers.dart';
|
||||
import '../../state/providers.dart';
|
||||
import '../labels.dart';
|
||||
import 'chat_screen.dart';
|
||||
import 'check_in_screen.dart';
|
||||
import 'habit_create_screen.dart';
|
||||
import 'protocol_gallery_screen.dart';
|
||||
import 'settings_screen.dart';
|
||||
import 'streak_screen.dart';
|
||||
|
||||
@@ -14,11 +18,27 @@ class HabitListScreen extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final boot = ref.watch(bootstrapProvider);
|
||||
final habitsAsync = ref.watch(activeHabitsProvider);
|
||||
final aiOptIn = ref.watch(aiSettingsProvider).valueOrNull ?? false;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('습관'),
|
||||
actions: [
|
||||
if (aiOptIn)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.smart_toy_outlined),
|
||||
tooltip: 'AI 코치',
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => const ChatScreen(),
|
||||
));
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.search),
|
||||
tooltip: '카탈로그 탐색',
|
||||
onPressed: () => _openGallery(context),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings),
|
||||
tooltip: '설정',
|
||||
@@ -38,8 +58,25 @@ class HabitListScreen extends ConsumerWidget {
|
||||
error: (e, st) => Center(child: Text('로드 실패: $e')),
|
||||
data: (habits) {
|
||||
if (habits.isEmpty) {
|
||||
return const Center(
|
||||
child: Text('아직 습관이 없습니다. + 버튼으로 추가하세요.'),
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Text(
|
||||
'아직 습관이 없습니다.\n+ 버튼으로 추가하거나, 카탈로그에서 골라보세요.',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: () => _openGallery(context),
|
||||
icon: const Icon(Icons.search),
|
||||
label: const Text('🔍 카탈로그 탐색'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView.separated(
|
||||
@@ -49,8 +86,10 @@ class HabitListScreen extends ConsumerWidget {
|
||||
final h = habits[i];
|
||||
return ListTile(
|
||||
title: Text(h.title),
|
||||
// FrameLevel (L2/L3) 은 시스템 규약이라 사용자에게 노출
|
||||
// 가치 낮음 — type chip + framedText 만 표시.
|
||||
subtitle: Text(
|
||||
'${h.type} · ${h.frameLevel} · ${h.frameFramedText}',
|
||||
'${habitTypeLabelFromDb(h.type)} · ${h.frameFramedText}',
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
@@ -83,4 +122,10 @@ class HabitListScreen extends ConsumerWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openGallery(BuildContext context) {
|
||||
Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => const ProtocolGalleryScreen(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
79
app/lib/ui/screens/protocol_gallery_screen.dart
Normal file
79
app/lib/ui/screens/protocol_gallery_screen.dart
Normal file
@@ -0,0 +1,79 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../domain/catalog/catalog_item.dart';
|
||||
import '../../domain/catalog/display_category.dart';
|
||||
import '../../state/catalog_providers.dart';
|
||||
import '../widgets/catalog_card.dart';
|
||||
import '../widgets/category_chip_row.dart';
|
||||
import 'protocol_preview_screen.dart';
|
||||
|
||||
class ProtocolGalleryScreen extends ConsumerStatefulWidget {
|
||||
const ProtocolGalleryScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ProtocolGalleryScreen> createState() =>
|
||||
_ProtocolGalleryScreenState();
|
||||
}
|
||||
|
||||
class _ProtocolGalleryScreenState extends ConsumerState<ProtocolGalleryScreen> {
|
||||
DisplayCategory? _selected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final groupedAsync = ref.watch(groupedByCategoryProvider);
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('카탈로그 탐색')),
|
||||
body: groupedAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(child: Text('로드 실패: $e')),
|
||||
data: (grouped) {
|
||||
final categories = DisplayCategory.values
|
||||
.where((c) => grouped.containsKey(c))
|
||||
.toList();
|
||||
final items = _selected == null
|
||||
? grouped.values.expand((e) => e).toList()
|
||||
: (grouped[_selected] ?? const <CatalogItem>[]);
|
||||
// Sort within filtered view by id (consistent with repo sort).
|
||||
items.sort((a, b) {
|
||||
final c = a.displayCategory.index - b.displayCategory.index;
|
||||
return c != 0 ? c : a.id.compareTo(b.id);
|
||||
});
|
||||
return Column(
|
||||
children: [
|
||||
CategoryChipRow(
|
||||
categories: categories,
|
||||
selected: _selected,
|
||||
onSelect: (c) => setState(() => _selected = c),
|
||||
),
|
||||
Expanded(
|
||||
child: items.isEmpty
|
||||
? const Center(child: Text('항목이 없습니다.'))
|
||||
: GridView.builder(
|
||||
padding: const EdgeInsets.all(12),
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 240,
|
||||
mainAxisExtent: 160,
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 8,
|
||||
),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, i) => CatalogCard(
|
||||
item: items[i],
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) =>
|
||||
ProtocolPreviewScreen(item: items[i]),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
207
app/lib/ui/screens/protocol_preview_screen.dart
Normal file
207
app/lib/ui/screens/protocol_preview_screen.dart
Normal file
@@ -0,0 +1,207 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../domain/catalog/catalog_item.dart';
|
||||
import '../../state/catalog_providers.dart';
|
||||
import '../widgets/reference_expand_card.dart';
|
||||
|
||||
class ProtocolPreviewScreen extends ConsumerWidget {
|
||||
const ProtocolPreviewScreen({super.key, required this.item});
|
||||
|
||||
final CatalogItem item;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(item.title, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 96),
|
||||
children: [
|
||||
_Header(item: item),
|
||||
const SizedBox(height: 16),
|
||||
..._buildBody(context),
|
||||
const SizedBox(height: 24),
|
||||
_References(referenceIds: item.referenceIds),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: const _ImportFooter(),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildBody(BuildContext context) {
|
||||
return switch (item) {
|
||||
ProtocolCatalogItem p => [
|
||||
_section(context, '무엇 (What)', p.what),
|
||||
_section(context, '언제 (When)', p.whenText),
|
||||
_section(context, '도즈 (Dose)', p.dose),
|
||||
_section(context, '왜 (Why)', p.why),
|
||||
if (p.how.isNotEmpty) _howSection(context, p.how),
|
||||
_section(context, '체크 (Check)', p.checkText),
|
||||
if (p.caution != null) _section(context, '주의 (Caution)', p.caution!),
|
||||
if (p.defaultAnchor != null)
|
||||
_section(context, '기본 앵커', _anchorText(p.defaultAnchor!)),
|
||||
if (p.minDoseForStart != null)
|
||||
_section(context, '최소 도즈 (시작용)', p.minDoseForStart!),
|
||||
if (p.sourceDoc != null)
|
||||
_section(context, '출처 문서', p.sourceDoc!),
|
||||
],
|
||||
BreakCatalogItem b => [
|
||||
_section(context, '요약 (Huberman)', b.hubermanSummary),
|
||||
_section(context, '구분', b.breakCategory),
|
||||
if (b.phases.isNotEmpty)
|
||||
_section(context, '단계', b.phases.join(' / ')),
|
||||
if (b.defaultCommonFrames.isNotEmpty)
|
||||
_section(context, '기본 공통 프레임',
|
||||
b.defaultCommonFrames.join(', ')),
|
||||
if (b.tools.isNotEmpty)
|
||||
_section(context, '도구', b.tools.join(', ')),
|
||||
if (b.medicalWarning != null)
|
||||
_section(context, '의료 경고', b.medicalWarning!),
|
||||
],
|
||||
DietCatalogItem d => [
|
||||
_section(context, '핵심', d.core),
|
||||
if (d.strengths.isNotEmpty)
|
||||
_section(context, '강점', d.strengths.join('\n• ')),
|
||||
if (d.weaknesses.isNotEmpty)
|
||||
_section(context, '약점', d.weaknesses.join('\n• ')),
|
||||
if (d.koreanContextFit != null)
|
||||
_section(context, '한국 컨텍스트 적합도', d.koreanContextFit!),
|
||||
if (d.starterLevers.isNotEmpty)
|
||||
_section(context, '시작 레버', d.starterLevers.join(', ')),
|
||||
if (d.medicalWarning != null)
|
||||
_section(context, '의료 경고', d.medicalWarning!),
|
||||
if (d.linkedProtocolIds.isNotEmpty)
|
||||
_section(context, '연결 프로토콜', d.linkedProtocolIds.join(', ')),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
String _anchorText(Map<String, dynamic> m) {
|
||||
final when = m['when'] ?? '';
|
||||
final after = m['after_what'] ?? '';
|
||||
if (when == '' && after == '') return m.toString();
|
||||
return [if (when != '') 'when: $when', if (after != '') 'after: $after']
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
Widget _section(BuildContext context, String label, String body) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: Theme.of(context).textTheme.labelLarge),
|
||||
const SizedBox(height: 4),
|
||||
Text(body, style: Theme.of(context).textTheme.bodyMedium),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _howSection(BuildContext context, List<String> steps) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('어떻게 (How)', style: Theme.of(context).textTheme.labelLarge),
|
||||
const SizedBox(height: 4),
|
||||
for (var i = 0; i < steps.length; i++)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: Text('${i + 1}. ${steps[i]}',
|
||||
style: Theme.of(context).textTheme.bodyMedium),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Header extends StatelessWidget {
|
||||
const _Header({required this.item});
|
||||
final CatalogItem item;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final dc = item.displayCategory;
|
||||
return Row(
|
||||
children: [
|
||||
Icon(dc.icon, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(dc.label,
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: Colors.grey,
|
||||
)),
|
||||
if (item.titleEn != null)
|
||||
Text(item.titleEn!,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Colors.grey,
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (item.evidenceStrength != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: Text('근거: ${item.evidenceStrength!}',
|
||||
style: Theme.of(context).textTheme.bodySmall),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _References extends ConsumerWidget {
|
||||
const _References({required this.referenceIds});
|
||||
final List<String> referenceIds;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
if (referenceIds.isEmpty) return const SizedBox.shrink();
|
||||
final refsAsync = ref.watch(referencesByIdsProvider(referenceIds));
|
||||
return refsAsync.when(
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (_, _) => const SizedBox.shrink(),
|
||||
data: (refs) {
|
||||
if (refs.isEmpty) return const SizedBox.shrink();
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('참고 (${refs.length})',
|
||||
style: Theme.of(context).textTheme.titleSmall),
|
||||
const SizedBox(height: 8),
|
||||
for (final r in refs) ReferenceExpandCard(reference: r),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ImportFooter extends StatelessWidget {
|
||||
const _ImportFooter();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Tooltip(
|
||||
message: '다음 업데이트 예정',
|
||||
child: FilledButton.icon(
|
||||
onPressed: null,
|
||||
icon: const Icon(Icons.add_task),
|
||||
label: const Text('내 습관으로 (다음 업데이트 예정)'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -44,17 +44,25 @@ class _AiSection extends ConsumerWidget {
|
||||
final settings = ref.watch(aiSettingsProvider);
|
||||
final availability = ref.watch(modelAvailabilityProvider);
|
||||
final download = ref.watch(modelDownloadControllerProvider);
|
||||
final ramOk = ref.watch(deviceMeetsAiRamProvider);
|
||||
final optIn = settings.maybeWhen(data: (v) => v, orElse: () => false);
|
||||
// #218 AC-6: gate the toggle when device RAM < 4GB. Default fail-closed
|
||||
// (null → disabled) so the user can't trip download on an undersized
|
||||
// device while the platform call is in flight.
|
||||
final meetsRam = ramOk.maybeWhen(data: (v) => v, orElse: () => false);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SwitchListTile(
|
||||
title: const Text('AI 도움 켜기'),
|
||||
subtitle: const Text(
|
||||
'Gemma 4 E2B 모델 ≈ 1.5GB. 모든 처리는 단말에서 일어납니다.',
|
||||
subtitle: Text(
|
||||
meetsRam
|
||||
? 'Gemma 4 E2B 모델 ≈ 2.4GB. 모든 처리는 단말에서 일어납니다.'
|
||||
: '이 단말에서는 AI 도움을 사용할 수 없어요 (RAM 4GB 이상 필요)',
|
||||
),
|
||||
value: optIn,
|
||||
onChanged: (v) async {
|
||||
value: meetsRam && optIn,
|
||||
onChanged: meetsRam
|
||||
? (v) async {
|
||||
if (v) {
|
||||
final ok = await _confirmOptIn(context);
|
||||
if (ok != true) return;
|
||||
@@ -70,14 +78,15 @@ class _AiSection extends ConsumerWidget {
|
||||
SnackBar(content: Text('공간 확보됨 ${_fmtMB(freed)}')),
|
||||
);
|
||||
}
|
||||
},
|
||||
}
|
||||
: null,
|
||||
),
|
||||
availability.when(
|
||||
loading: () => const ListTile(title: Text('상태 확인 중...')),
|
||||
error: (e, _) => ListTile(title: Text('상태 오류: $e')),
|
||||
data: (a) => ListTile(
|
||||
title: const Text('모델 상태'),
|
||||
subtitle: Text(_describe(a)),
|
||||
subtitle: Text(_describe(a, meetsRam: meetsRam)),
|
||||
),
|
||||
),
|
||||
if (optIn && download != null)
|
||||
@@ -93,12 +102,16 @@ class _AiSection extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
String _describe(ModelAvailability a) {
|
||||
String _describe(ModelAvailability a, {required bool meetsRam}) {
|
||||
switch (a) {
|
||||
case ModelAvailability.ready:
|
||||
return '준비 완료 — 새 습관 만들기에서 ✨ AI 제안을 쓸 수 있어요';
|
||||
case ModelAvailability.missing:
|
||||
return '아직 받지 않았어요 — 위 토글을 켜면 다운로드합니다';
|
||||
// RAM 게이트로 토글이 비활성인 상태에서 "토글을 켜면" 안내는 모순.
|
||||
// 단말 미지원 메시지를 그대로 노출해서 사용자가 다음 행동을 알 수 있게.
|
||||
return meetsRam
|
||||
? '아직 받지 않았어요 — 위 토글을 켜면 다운로드합니다'
|
||||
: '이 단말은 모델을 받을 수 없어요 (RAM 4GB 이상 필요)';
|
||||
case ModelAvailability.corrupt:
|
||||
return '파일이 손상됐어요 — 끄고 다시 켜면 새로 받습니다';
|
||||
case ModelAvailability.downloading:
|
||||
@@ -125,9 +138,10 @@ class _AiSection extends ConsumerWidget {
|
||||
style: TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
_Bullet('파일 크기: 약 1.5GB'),
|
||||
_Bullet('파일 크기: 약 2.4GB'),
|
||||
_Bullet('WiFi 연결을 권장합니다'),
|
||||
_Bullet('모든 처리는 단말에서만 일어나며, 입력 텍스트는 외부로 전송되지 않습니다'),
|
||||
_Bullet('Gemma 이용약관(ai.google.dev/gemma/terms)에 동의합니다'),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
@@ -156,12 +170,12 @@ class _AiSection extends ConsumerWidget {
|
||||
Text('모델 파일이 단말에서 삭제됩니다.'),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'약 1.5GB 의 저장공간이 확보돼요.',
|
||||
'약 2.4GB 의 저장공간이 확보돼요.',
|
||||
style: TextStyle(fontSize: 13, color: Colors.grey),
|
||||
),
|
||||
SizedBox(height: 4),
|
||||
Text(
|
||||
'다시 켜면 다시 다운로드해야 합니다.',
|
||||
'다시 켜면 처음부터 다운로드합니다.',
|
||||
style: TextStyle(fontSize: 13, color: Colors.grey),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -5,6 +5,7 @@ import '../../core/time.dart';
|
||||
import '../../domain/models/tracker_entry.dart';
|
||||
import '../../domain/streak/compute_streak.dart';
|
||||
import '../../state/providers.dart';
|
||||
import '../labels.dart';
|
||||
|
||||
class StreakScreen extends ConsumerWidget {
|
||||
final String habitId;
|
||||
@@ -51,25 +52,43 @@ class StreakScreen extends ConsumerWidget {
|
||||
asOf: nowKst(),
|
||||
habitStartedAt: habit.startedAt as String,
|
||||
);
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(habit.title as String,
|
||||
style: Theme.of(context).textTheme.titleLarge),
|
||||
style: theme.textTheme.titleLarge),
|
||||
const SizedBox(height: 24),
|
||||
_Row('현재 스트릭', '${state.currentStreak}일'),
|
||||
// Hero — 핵심 동기 지표. 큰 숫자 + 티어 emoji 라벨로 위계 강조.
|
||||
Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
'${state.currentStreak}',
|
||||
style: theme.textTheme.displayLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
Text('일 연속 (현재 스트릭)',
|
||||
style: theme.textTheme.bodyMedium),
|
||||
const SizedBox(height: 4),
|
||||
Text(rewardTierLabel(state.currentTier),
|
||||
style: theme.textTheme.titleMedium),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 40),
|
||||
_Row('최장 스트릭', '${state.longestStreak}일'),
|
||||
_Row('최근 30일 / 완료', '${state.doneCountInWindow30}회'),
|
||||
_Row('Phase 42일 / 완료', '${state.doneCountInPhase42}회'),
|
||||
const Divider(height: 32),
|
||||
_Row('현재 티어', state.currentTier.dbValue),
|
||||
if (state.neverMissTwiceBroken)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 12),
|
||||
child: Text(
|
||||
'⚠ Never miss twice 발동 — 티어 강등',
|
||||
'⚠ 이틀 연속 빠졌어요. 한 단계 강등됐습니다.',
|
||||
style: TextStyle(color: Colors.redAccent),
|
||||
),
|
||||
),
|
||||
|
||||
105
app/lib/ui/widgets/catalog_card.dart
Normal file
105
app/lib/ui/widgets/catalog_card.dart
Normal file
@@ -0,0 +1,105 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../domain/catalog/catalog_item.dart';
|
||||
|
||||
class CatalogCard extends StatelessWidget {
|
||||
const CatalogCard({super.key, required this.item, required this.onTap});
|
||||
|
||||
final CatalogItem item;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final dc = item.displayCategory;
|
||||
return Semantics(
|
||||
label: '${dc.label} 카테고리. ${item.title}. ${item.summary}',
|
||||
button: true,
|
||||
child: Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(dc.icon, size: 18),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.title,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (item.titleEn != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
item.titleEn!,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Colors.grey,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.summary,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (item.evidenceStrength != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
_EvidenceBadge(strength: item.evidenceStrength!),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EvidenceBadge extends StatelessWidget {
|
||||
const _EvidenceBadge({required this.strength});
|
||||
|
||||
final String strength;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (label, color) = switch (strength) {
|
||||
'strong_rct' || 'strong' => ('근거 강함', Colors.green),
|
||||
'meta_analysis' => ('메타분석', Colors.teal),
|
||||
'moderate' => ('근거 중간', Colors.blue),
|
||||
'observational' => ('관찰연구', Colors.blueGrey),
|
||||
'mechanistic' => ('기전', Colors.orange),
|
||||
'expert_opinion' => ('전문가 의견', Colors.brown),
|
||||
'mixed' => ('근거 혼재', Colors.amber),
|
||||
'weak' => ('근거 약함', Colors.grey),
|
||||
_ => (strength, Colors.grey),
|
||||
};
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.15),
|
||||
border: Border.all(color: color),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(fontSize: 11, color: color),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
50
app/lib/ui/widgets/category_chip_row.dart
Normal file
50
app/lib/ui/widgets/category_chip_row.dart
Normal file
@@ -0,0 +1,50 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../domain/catalog/display_category.dart';
|
||||
|
||||
/// 가로 카테고리 칩. "전체" + 비어있지 않은 카테고리만 표시.
|
||||
///
|
||||
/// 선택 카테고리 = null → 전체 보기.
|
||||
class CategoryChipRow extends StatelessWidget {
|
||||
const CategoryChipRow({
|
||||
super.key,
|
||||
required this.categories,
|
||||
required this.selected,
|
||||
required this.onSelect,
|
||||
});
|
||||
|
||||
final List<DisplayCategory> categories;
|
||||
final DisplayCategory? selected;
|
||||
final ValueChanged<DisplayCategory?> onSelect;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 48,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 6),
|
||||
child: ChoiceChip(
|
||||
label: const Text('전체'),
|
||||
selected: selected == null,
|
||||
onSelected: (_) => onSelect(null),
|
||||
),
|
||||
),
|
||||
for (final c in categories)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 6),
|
||||
child: ChoiceChip(
|
||||
label: Text(c.label),
|
||||
avatar: Icon(c.icon, size: 16),
|
||||
selected: selected == c,
|
||||
onSelected: (_) => onSelect(c),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
71
app/lib/ui/widgets/reference_expand_card.dart
Normal file
71
app/lib/ui/widgets/reference_expand_card.dart
Normal file
@@ -0,0 +1,71 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../data/db/app_database.dart';
|
||||
|
||||
/// reference 1건을 펼치기 카드로 표시.
|
||||
///
|
||||
/// 본 이슈 (#226) 에선 url 표시만 (탭 시 launcher 호출 X — #FF1 이후).
|
||||
class ReferenceExpandCard extends StatelessWidget {
|
||||
const ReferenceExpandCard({super.key, required this.reference});
|
||||
|
||||
final ReferenceRow reference;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kindLabel = switch (reference.kind) {
|
||||
'paper' => '논문',
|
||||
'podcast_episode' => '팟캐스트',
|
||||
'book' => '서적',
|
||||
'url' => '웹',
|
||||
'korean_explainer' => '한국어 해설',
|
||||
_ => reference.kind,
|
||||
};
|
||||
return Card(
|
||||
child: ExpansionTile(
|
||||
title: Text(
|
||||
reference.title,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(kindLabel,
|
||||
style: Theme.of(context).textTheme.bodySmall),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (reference.year != null)
|
||||
_row(context, '연도', reference.year.toString()),
|
||||
if (reference.journal != null)
|
||||
_row(context, '저널', reference.journal!),
|
||||
if (reference.publisher != null)
|
||||
_row(context, '출판', reference.publisher!),
|
||||
if (reference.episodeNumber != null)
|
||||
_row(context, '에피소드',
|
||||
reference.episodeNumber.toString()),
|
||||
if (reference.doi != null) _row(context, 'DOI', reference.doi!),
|
||||
if (reference.url != null) _row(context, 'URL', reference.url!),
|
||||
if (reference.note != null)
|
||||
_row(context, '메모', reference.note!),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _row(BuildContext context, String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: Text.rich(TextSpan(children: [
|
||||
TextSpan(
|
||||
text: '$label: ',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
TextSpan(text: value),
|
||||
]), style: Theme.of(context).textTheme.bodySmall),
|
||||
);
|
||||
}
|
||||
}
|
||||
285
app/pubspec.lock
285
app/pubspec.lock
@@ -5,18 +5,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: _fe_analyzer_shared
|
||||
sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f
|
||||
sha256: "3b19a47f6ea7c2632760777c78174f47f6aec1e05f0cd611380d4593b8af1dbc"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "85.0.0"
|
||||
version: "96.0.0"
|
||||
analyzer:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: analyzer
|
||||
sha256: "974859dc0ff5f37bc4313244b3218c791810d03ab3470a579580279ba971a48d"
|
||||
sha256: "0c516bc4ad36a1a75759e54d5047cb9d15cded4459df01aa35a0b5ec7db2c2a0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.7.1"
|
||||
version: "10.2.0"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -33,6 +33,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.1"
|
||||
background_downloader:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: background_downloader
|
||||
sha256: aceacec2b2a72ec3a8862ab5895fcbbc71ab33765f3619d57963f3110dd268e3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.5.5"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -45,18 +53,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build
|
||||
sha256: "51dc711996cbf609b90cbe5b335bbce83143875a9d58e4b5c6d3c4f684d3dda7"
|
||||
sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.4"
|
||||
version: "4.0.6"
|
||||
build_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build_config
|
||||
sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33"
|
||||
sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
version: "1.3.0"
|
||||
build_daemon:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -65,30 +73,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.1"
|
||||
build_resolvers:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build_resolvers
|
||||
sha256: ee4257b3f20c0c90e72ed2b57ad637f694ccba48839a821e87db762548c22a62
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.4"
|
||||
build_runner:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: build_runner
|
||||
sha256: "382a4d649addbfb7ba71a3631df0ec6a45d5ab9b098638144faf27f02778eb53"
|
||||
sha256: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.4"
|
||||
build_runner_core:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build_runner_core
|
||||
sha256: "85fbbb1036d576d966332a3f5ce83f2ce66a40bea1a94ad2d5fc29a19a0d3792"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.1.2"
|
||||
version: "2.15.0"
|
||||
built_collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -149,18 +141,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: code_assets
|
||||
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
|
||||
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
code_builder:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: code_builder
|
||||
sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.11.1"
|
||||
version: "1.0.0"
|
||||
collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -193,30 +177,54 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.9"
|
||||
dart_sentencepiece_tokenizer:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dart_sentencepiece_tokenizer
|
||||
sha256: "85825632845cf6427ea0cd13dfba96b4341cf63525165155e5b9b97011239289"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.2"
|
||||
dart_style:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dart_style
|
||||
sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb"
|
||||
sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.1"
|
||||
version: "3.1.7"
|
||||
device_info_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: device_info_plus
|
||||
sha256: a7fd703482b391a87d60b6061d04dfdeab07826b96f9abd8f5ed98068acc0074
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.1.2"
|
||||
device_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: device_info_plus_platform_interface
|
||||
sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.3"
|
||||
drift:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: drift
|
||||
sha256: "540cf382a3bfa99b76e51514db5b0ebcd81ce3679b7c1c9cb9478ff3735e47a1"
|
||||
sha256: "6cc0b623c0e83f7080524d8396e9301b1d78b9c66a4fdceeb0f798211303254c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.28.2"
|
||||
version: "2.34.0"
|
||||
drift_dev:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: drift_dev
|
||||
sha256: "68c138e884527d2bd61df2ade276c3a144df84d1adeb0ab8f3196b5afe021bd4"
|
||||
sha256: "9cfff1576b49725da0d32c040651a41ae195e8c4af8d8da301593e41d7abc2f7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.28.0"
|
||||
version: "2.34.0"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -254,6 +262,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_gemma:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_gemma
|
||||
sha256: "984960b54bbc0ff7e36cf568a02652b1d4bc016d6c75575b027fb6102fab48c3"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.16.5"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -275,30 +291,11 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
freezed:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: freezed
|
||||
sha256: "59a584c24b3acdc5250bb856d0d3e9c0b798ed14a4af1ddb7dc1c7b41df91c9c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.8"
|
||||
freezed_annotation:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: freezed_annotation
|
||||
sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.4"
|
||||
frontend_server_client:
|
||||
flutter_web_plugins:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: frontend_server_client
|
||||
sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
glob:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -319,10 +316,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: hooks
|
||||
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
|
||||
sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
version: "1.0.3"
|
||||
http:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -371,14 +368,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
js:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: js
|
||||
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.2"
|
||||
json_annotation:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -391,10 +380,18 @@ packages:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: json_serializable
|
||||
sha256: c50ef5fc083d5b5e12eef489503ba3bf5ccc899e487d691584699b4bdefeea8c
|
||||
sha256: "5b89c1e32ae3840bb20a1b3434e3a590173ad3cb605896fb0f60487ce2f8104e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.9.5"
|
||||
version: "6.11.4"
|
||||
large_file_handler:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: large_file_handler
|
||||
sha256: "1657db12b4591242b186c23eee437997be91e655ab0d803fd74829074460757f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.4.0"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -427,6 +424,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
local_hnsw:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: local_hnsw
|
||||
sha256: "7fb0988e3f850121774d9dfe94068f843d87908d6b1e9ed5039710d8d9ae3f7a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -467,14 +472,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
mutex:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: mutex
|
||||
sha256: "8827da25de792088eb33e572115a5eb0d61d61a3c01acbc8bcbe76ed78f1a1f2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
native_toolchain_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: native_toolchain_c
|
||||
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.17.6"
|
||||
objective_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: objective_c
|
||||
sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed"
|
||||
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.4.1"
|
||||
version: "9.3.0"
|
||||
package_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -603,6 +624,62 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.1"
|
||||
shared_preferences:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences
|
||||
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.5"
|
||||
shared_preferences_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_android
|
||||
sha256: "93ae5884a9df5d3bb696825bceb3a17590754548b5d740eba51500afc8d088f5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.26"
|
||||
shared_preferences_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_foundation
|
||||
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.6"
|
||||
shared_preferences_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_linux
|
||||
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
shared_preferences_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_platform_interface
|
||||
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
shared_preferences_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_web
|
||||
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.3"
|
||||
shared_preferences_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_windows
|
||||
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
shelf:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -628,18 +705,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_gen
|
||||
sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b"
|
||||
sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
version: "4.2.3"
|
||||
source_helper:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_helper
|
||||
sha256: a447acb083d3a5ef17f983dd36201aeea33fedadb3228fa831f2f0c92f0f3aca
|
||||
sha256: "4227d54ceefd0bb8ca4c8fcb96e1719dc53f1ee1b6e2ca9d7a6069da160e4eae"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.7"
|
||||
version: "1.3.12"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -652,10 +729,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqlite3
|
||||
sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2"
|
||||
sha256: "9488c7d2cdb1091c91cacf7e207cff81b28bff8e366f042bad3afe7d34afe189"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.9.4"
|
||||
version: "3.3.2"
|
||||
sqlite3_flutter_libs:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -668,10 +745,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqlparser
|
||||
sha256: "57090342af1ce32bb499aa641f4ecdd2d6231b9403cea537ac059e803cc20d67"
|
||||
sha256: "40bdddb306a727be9ce510bd2d2b9a6c9db6c586d846ef7b22e3990a2b24f02d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.41.2"
|
||||
version: "0.44.5"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -728,14 +805,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.11"
|
||||
timing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: timing
|
||||
sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -752,6 +821,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.1"
|
||||
uuid:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: uuid
|
||||
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.5.3"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -800,6 +877,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.3"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32
|
||||
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.15.0"
|
||||
win32_registry:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32_registry
|
||||
sha256: "21ec76dfc731550fd3e2ce7a33a9ea90b828fdf19a5c3bcf556fa992cfa99852"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.5"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -818,4 +911,4 @@ packages:
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.12.2 <4.0.0"
|
||||
flutter: ">=3.38.4"
|
||||
flutter: ">=3.44.0"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: life_helper
|
||||
description: "Huberman + Atomic Habits + Tiny Habits + If-Then. Local-first habit/checklist/todo."
|
||||
publish_to: 'none'
|
||||
version: 0.2.0+2
|
||||
version: 0.4.2+6
|
||||
|
||||
environment:
|
||||
sdk: ^3.12.2
|
||||
@@ -21,18 +21,20 @@ dependencies:
|
||||
path: ^1.9.0
|
||||
|
||||
# Models / serialization
|
||||
freezed_annotation: ^2.4.0
|
||||
json_annotation: ^4.9.0
|
||||
|
||||
# IDs
|
||||
ulid: ^2.0.0
|
||||
|
||||
# AI / on-device LLM (#215)
|
||||
# flutter_gemma 는 OQ-1 (정확한 모델 URL + SHA) 확정 후 추가.
|
||||
# v1은 LlmService 추상 + ModelLifecycle (파일/SHA/메타 관리) + Mock 까지 구현.
|
||||
# AI / on-device LLM (#215, #218)
|
||||
# #218 (v0.3.0): flutter_gemma 0.16.5 + Gemma 4 E2B (HF litert-community).
|
||||
flutter_gemma: ^0.16.5
|
||||
crypto: ^3.0.0
|
||||
http: ^1.2.0
|
||||
|
||||
# Device info — RAM gate for AI opt-in (#218 AC-6)
|
||||
device_info_plus: ^10.1.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
@@ -41,7 +43,6 @@ dev_dependencies:
|
||||
# Codegen
|
||||
drift_dev: ^2.18.0
|
||||
build_runner: ^2.4.0
|
||||
freezed: ^2.5.0
|
||||
json_serializable: ^6.8.0
|
||||
|
||||
flutter:
|
||||
|
||||
48
app/test/ai/tools/_tool_test_helpers.dart
Normal file
48
app/test/ai/tools/_tool_test_helpers.dart
Normal file
@@ -0,0 +1,48 @@
|
||||
import 'package:drift/drift.dart' as drift;
|
||||
import 'package:life_helper/ai/tools/tool_definition.dart';
|
||||
import 'package:life_helper/core/constants.dart';
|
||||
import 'package:life_helper/core/time.dart';
|
||||
import 'package:life_helper/data/catalog/catalog_repository.dart';
|
||||
import 'package:life_helper/data/db/app_database.dart';
|
||||
import 'package:life_helper/data/db/daos/habit_dao.dart';
|
||||
import 'package:life_helper/data/db/daos/tracker_dao.dart';
|
||||
import 'package:life_helper/data/seed/seed_importer.dart';
|
||||
import 'package:life_helper/domain/models/frame_pattern.dart';
|
||||
|
||||
import '../../data/seed/test_seeds.dart';
|
||||
|
||||
/// Tool tests share a tiny in-memory bootstrap. Returns the assembled
|
||||
/// [ToolDeps] plus the underlying [AppDatabase] so callers can close it
|
||||
/// in tearDown.
|
||||
Future<({AppDatabase db, ToolDeps deps})> bootstrapToolDeps() async {
|
||||
final db = AppDatabase.memory();
|
||||
// default user (seed importer doesn't insert users — bootstrap does).
|
||||
await db.into(db.users).insert(UsersCompanion.insert(
|
||||
id: kLocalDefaultUserId,
|
||||
displayName: const drift.Value('Test'),
|
||||
createdAt: nowKst().toIso8601String(),
|
||||
));
|
||||
await SeedImporter(db, loadAsset: testStubLoader).importIfNeeded();
|
||||
final patterns = await db.select(db.framePatterns).get();
|
||||
final framePatterns = patterns
|
||||
.map((r) => FramePatternModel(
|
||||
id: r.id,
|
||||
domain: r.domain,
|
||||
avoidanceKeyword: r.avoidanceKeyword,
|
||||
l0Example: r.l0Example,
|
||||
l1SimpleReplace: r.l1SimpleReplace,
|
||||
l2Suggestion: r.l2Suggestion,
|
||||
l3Identity: r.l3Identity,
|
||||
))
|
||||
.toList();
|
||||
return (
|
||||
db: db,
|
||||
deps: ToolDeps(
|
||||
habitDao: HabitDao(db),
|
||||
trackerDao: TrackerDao(db),
|
||||
catalog: CatalogRepository(db),
|
||||
framePatterns: framePatterns,
|
||||
userId: kLocalDefaultUserId,
|
||||
),
|
||||
);
|
||||
}
|
||||
82
app/test/ai/tools/catalog_tools_test.dart
Normal file
82
app/test/ai/tools/catalog_tools_test.dart
Normal file
@@ -0,0 +1,82 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:life_helper/ai/tools/catalog_tools.dart';
|
||||
import 'package:life_helper/ai/tools/tool_envelope.dart';
|
||||
|
||||
import '_tool_test_helpers.dart';
|
||||
|
||||
void main() {
|
||||
group('search_catalog', () {
|
||||
test('전체 검색 (인자 없음)', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final r = await searchCatalogTool.handler({}, ctx.deps);
|
||||
expect(r, isA<ToolOk>());
|
||||
final data = (r as ToolOk).data;
|
||||
expect(data['count'], greaterThan(0));
|
||||
// stub 3 items: protocol + break + diet
|
||||
expect(data['count'], 3);
|
||||
final items = data['items'] as List;
|
||||
expect(items.first.containsKey('id'), true);
|
||||
expect(items.first.containsKey('summary'), true);
|
||||
});
|
||||
|
||||
test('카테고리 필터', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final r = await searchCatalogTool
|
||||
.handler({'category': 'breakHabit'}, ctx.deps);
|
||||
expect(r, isA<ToolOk>());
|
||||
final items = ((r as ToolOk).data['items'] as List);
|
||||
expect(items.length, 1);
|
||||
expect((items.first as Map)['category'], 'breakHabit');
|
||||
});
|
||||
|
||||
test('잘못된 카테고리 → validation 에러', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final r = await searchCatalogTool
|
||||
.handler({'category': 'no_such'}, ctx.deps);
|
||||
expect(r, isA<ToolErr>());
|
||||
expect((r as ToolErr).code, 'validation');
|
||||
});
|
||||
|
||||
test('limit 범위 검증', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final r = await searchCatalogTool.handler({'limit': 99}, ctx.deps);
|
||||
expect(r, isA<ToolErr>());
|
||||
expect((r as ToolErr).code, 'validation');
|
||||
});
|
||||
});
|
||||
|
||||
group('query_protocol', () {
|
||||
test('정상 조회 → kind 분기', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final r = await queryProtocolTool
|
||||
.handler({'id': 'morning_sunlight'}, ctx.deps);
|
||||
expect(r, isA<ToolOk>());
|
||||
final data = (r as ToolOk).data;
|
||||
expect(data['kind'], 'protocol');
|
||||
expect(data['what'], isNotNull);
|
||||
});
|
||||
|
||||
test('break 항목 → kind=break', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final r =
|
||||
await queryProtocolTool.handler({'id': 'alcohol'}, ctx.deps);
|
||||
expect(r, isA<ToolOk>());
|
||||
expect((r as ToolOk).data['kind'], 'break');
|
||||
});
|
||||
|
||||
test('미존재 id → not_found', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final r =
|
||||
await queryProtocolTool.handler({'id': 'no_such'}, ctx.deps);
|
||||
expect(r, isA<ToolErr>());
|
||||
expect((r as ToolErr).code, 'not_found');
|
||||
});
|
||||
});
|
||||
}
|
||||
130
app/test/ai/tools/habit_tools_test.dart
Normal file
130
app/test/ai/tools/habit_tools_test.dart
Normal file
@@ -0,0 +1,130 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:life_helper/ai/tools/habit_tools.dart';
|
||||
import 'package:life_helper/ai/tools/tool_envelope.dart';
|
||||
import 'package:life_helper/core/constants.dart';
|
||||
import 'package:life_helper/core/time.dart';
|
||||
import 'package:life_helper/data/db/daos/habit_dao.dart';
|
||||
import 'package:life_helper/domain/models/habit.dart';
|
||||
|
||||
import '_tool_test_helpers.dart';
|
||||
|
||||
void main() {
|
||||
group('add_habit', () {
|
||||
test('정상 build → ToolOk', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final r = await addHabitTool.handler({
|
||||
'protocol_id': 'morning_sunlight',
|
||||
'frame_level': 'L2',
|
||||
'framed_text': '아침에 햇빛 보기',
|
||||
}, ctx.deps);
|
||||
expect(r, isA<ToolOk>(), reason: '$r');
|
||||
final data = (r as ToolOk).data;
|
||||
expect(data['type'], 'build');
|
||||
expect(data['frame_level'], 'L2');
|
||||
expect(data['habit_id'], isNotEmpty);
|
||||
});
|
||||
|
||||
test('정상 break → type=break', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final r = await addHabitTool.handler({
|
||||
'protocol_id': 'alcohol',
|
||||
'frame_level': 'L3',
|
||||
'framed_text': '맑은 정신을 즐긴다',
|
||||
}, ctx.deps);
|
||||
expect(r, isA<ToolOk>(), reason: '$r');
|
||||
expect((r as ToolOk).data['type'], 'break');
|
||||
});
|
||||
|
||||
test('L0 프레임 → validation 거부', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final r = await addHabitTool.handler({
|
||||
'protocol_id': 'morning_sunlight',
|
||||
'frame_level': 'L0',
|
||||
'framed_text': '게으름 피우지 마',
|
||||
}, ctx.deps);
|
||||
expect(r, isA<ToolErr>());
|
||||
expect((r as ToolErr).code, 'validation');
|
||||
});
|
||||
|
||||
test('미존재 protocol_id → not_found', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final r = await addHabitTool.handler({
|
||||
'protocol_id': 'no_such',
|
||||
'frame_level': 'L2',
|
||||
'framed_text': '뭐든',
|
||||
}, ctx.deps);
|
||||
expect(r, isA<ToolErr>());
|
||||
expect((r as ToolErr).code, 'not_found');
|
||||
});
|
||||
|
||||
test('R7 회피 키워드 → r7_avoidance', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
// 시드 framePatterns 에 "술 끊기" avoidance keyword 존재.
|
||||
final r = await addHabitTool.handler({
|
||||
'protocol_id': 'alcohol',
|
||||
'frame_level': 'L2',
|
||||
'framed_text': '술 끊기 해야지',
|
||||
}, ctx.deps);
|
||||
expect(r, isA<ToolErr>());
|
||||
expect((r as ToolErr).code, 'r7_avoidance');
|
||||
});
|
||||
|
||||
test('R3 quota (build 3개) 초과', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
// 3개 사전 삽입.
|
||||
for (var i = 0; i < 3; i++) {
|
||||
await ctx.deps.habitDao.insertWithVariants(HabitDraft(
|
||||
userId: kLocalDefaultUserId,
|
||||
type: HabitType.build,
|
||||
title: 'pre_$i',
|
||||
protocolId: 'morning_sunlight',
|
||||
frameLevel: FrameLevel.l2,
|
||||
frameFramedText: 'pre$i',
|
||||
startedAt: dateOnly(nowKst()),
|
||||
));
|
||||
}
|
||||
final r = await addHabitTool.handler({
|
||||
'protocol_id': 'morning_sunlight',
|
||||
'frame_level': 'L2',
|
||||
'framed_text': '4번째 시도',
|
||||
}, ctx.deps);
|
||||
expect(r, isA<ToolErr>());
|
||||
expect((r as ToolErr).code, 'r3_quota');
|
||||
});
|
||||
});
|
||||
|
||||
group('list_active_habits', () {
|
||||
test('0개일 때', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final r = await listActiveHabitsTool.handler({}, ctx.deps);
|
||||
expect(r, isA<ToolOk>());
|
||||
final data = (r as ToolOk).data;
|
||||
expect(data['count'], 0);
|
||||
expect(data['build_quota_remaining'], 3);
|
||||
expect(data['break_quota_remaining'], 1);
|
||||
});
|
||||
|
||||
test('add_habit 후 1개', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
await addHabitTool.handler({
|
||||
'protocol_id': 'morning_sunlight',
|
||||
'frame_level': 'L2',
|
||||
'framed_text': '햇빛',
|
||||
}, ctx.deps);
|
||||
final r = await listActiveHabitsTool.handler({}, ctx.deps);
|
||||
expect(r, isA<ToolOk>());
|
||||
final data = (r as ToolOk).data;
|
||||
expect(data['count'], 1);
|
||||
expect(data['build_count'], 1);
|
||||
expect(data['build_quota_remaining'], 2);
|
||||
});
|
||||
});
|
||||
}
|
||||
114
app/test/ai/tools/tool_dispatcher_test.dart
Normal file
114
app/test/ai/tools/tool_dispatcher_test.dart
Normal file
@@ -0,0 +1,114 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:life_helper/ai/tools/tool_definition.dart';
|
||||
import 'package:life_helper/ai/tools/tool_dispatcher.dart';
|
||||
import 'package:life_helper/ai/tools/tool_envelope.dart';
|
||||
import 'package:life_helper/ai/tools/tool_registry.dart';
|
||||
|
||||
import '_tool_test_helpers.dart';
|
||||
|
||||
void main() {
|
||||
group('ToolDispatcher', () {
|
||||
test('unknown tool 이름', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final dispatcher =
|
||||
ToolDispatcher(registry: ToolRegistry.defaults());
|
||||
final r = await dispatcher.dispatch(
|
||||
toolName: 'no_such',
|
||||
rawArgs: const {},
|
||||
confirmContext: null,
|
||||
deps: ctx.deps,
|
||||
);
|
||||
expect(r, isA<ToolErr>());
|
||||
expect((r as ToolErr).code, 'unknown_tool');
|
||||
});
|
||||
|
||||
test('validation: required 없음', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final dispatcher =
|
||||
ToolDispatcher(registry: ToolRegistry.defaults());
|
||||
final r = await dispatcher.dispatch(
|
||||
toolName: 'query_protocol',
|
||||
rawArgs: const {},
|
||||
confirmContext: null,
|
||||
deps: ctx.deps,
|
||||
);
|
||||
expect(r, isA<ToolErr>());
|
||||
expect((r as ToolErr).code, 'validation');
|
||||
});
|
||||
|
||||
test('validation: 타입 불일치', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final dispatcher =
|
||||
ToolDispatcher(registry: ToolRegistry.defaults());
|
||||
final r = await dispatcher.dispatch(
|
||||
toolName: 'query_protocol',
|
||||
rawArgs: const {'id': 123},
|
||||
confirmContext: null,
|
||||
deps: ctx.deps,
|
||||
);
|
||||
expect(r, isA<ToolErr>());
|
||||
expect((r as ToolErr).code, 'validation');
|
||||
});
|
||||
|
||||
test('destructive + null confirmContext → ToolCancelled', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final dispatcher =
|
||||
ToolDispatcher(registry: ToolRegistry.defaults());
|
||||
final r = await dispatcher.dispatch(
|
||||
toolName: 'add_habit',
|
||||
rawArgs: const {
|
||||
'protocol_id': 'morning_sunlight',
|
||||
'frame_level': 'L2',
|
||||
'framed_text': '햇빛',
|
||||
},
|
||||
confirmContext: null,
|
||||
deps: ctx.deps,
|
||||
);
|
||||
expect(r, isA<ToolCancelled>());
|
||||
});
|
||||
|
||||
test('read-only normal 경로', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final dispatcher =
|
||||
ToolDispatcher(registry: ToolRegistry.defaults());
|
||||
final r = await dispatcher.dispatch(
|
||||
toolName: 'search_catalog',
|
||||
rawArgs: const {},
|
||||
confirmContext: null,
|
||||
deps: ctx.deps,
|
||||
);
|
||||
expect(r, isA<ToolOk>());
|
||||
});
|
||||
|
||||
test('handler 예외 → ToolErr(handler_error)', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final throwing = ToolDefinition(
|
||||
name: 'always_throws',
|
||||
description: 'test',
|
||||
parametersSchema: const {
|
||||
'type': 'object',
|
||||
'properties': {},
|
||||
'required': [],
|
||||
},
|
||||
handler: (_, _) async => throw StateError('boom'),
|
||||
);
|
||||
final dispatcher = ToolDispatcher(
|
||||
registry: ToolRegistry([throwing]),
|
||||
);
|
||||
final r = await dispatcher.dispatch(
|
||||
toolName: 'always_throws',
|
||||
rawArgs: const {},
|
||||
confirmContext: null,
|
||||
deps: ctx.deps,
|
||||
);
|
||||
expect(r, isA<ToolErr>());
|
||||
expect((r as ToolErr).code, 'handler_error');
|
||||
});
|
||||
});
|
||||
}
|
||||
66
app/test/ai/tools/tool_envelope_test.dart
Normal file
66
app/test/ai/tools/tool_envelope_test.dart
Normal file
@@ -0,0 +1,66 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:life_helper/ai/tools/tool_envelope.dart';
|
||||
|
||||
void main() {
|
||||
group('ToolResult', () {
|
||||
test('ToolOk JSON 형태', () {
|
||||
const r = ToolOk({'a': 1});
|
||||
expect(r.toJson(), {
|
||||
'status': 'ok',
|
||||
'data': {'a': 1}
|
||||
});
|
||||
});
|
||||
|
||||
test('ToolErr JSON 형태', () {
|
||||
const r = ToolErr('validation', '잘못된 인자');
|
||||
expect(r.toJson(), {
|
||||
'status': 'error',
|
||||
'code': 'validation',
|
||||
'reason': '잘못된 인자',
|
||||
});
|
||||
});
|
||||
|
||||
test('ToolCancelled JSON 형태', () {
|
||||
const r = ToolCancelled();
|
||||
expect(r.toJson(), {
|
||||
'status': 'cancelled',
|
||||
'reason': 'user did not confirm',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('encodeToolResult 2KB cap', () {
|
||||
test('payload 작으면 그대로', () {
|
||||
const r = ToolOk({'k': 'v'});
|
||||
final s = encodeToolResult(r);
|
||||
expect(jsonDecode(s), {
|
||||
'status': 'ok',
|
||||
'data': {'k': 'v'}
|
||||
});
|
||||
});
|
||||
|
||||
test('payload 2KB 초과 시 truncation hint 로 대체', () {
|
||||
final big = ToolOk({'blob': 'x' * 5000});
|
||||
final s = encodeToolResult(big);
|
||||
expect(s.length, lessThan(500));
|
||||
final decoded = jsonDecode(s) as Map<String, dynamic>;
|
||||
expect(decoded['status'], 'ok');
|
||||
final data = decoded['data'] as Map<String, dynamic>;
|
||||
expect(data['_truncated'], true);
|
||||
expect(data['_hint'], contains('query_protocol'));
|
||||
});
|
||||
|
||||
test('error/cancelled 는 작아서 그대로', () {
|
||||
expect(
|
||||
encodeToolResult(const ToolErr('e', 'r')).length,
|
||||
lessThan(100),
|
||||
);
|
||||
expect(
|
||||
encodeToolResult(const ToolCancelled()).length,
|
||||
lessThan(100),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
116
app/test/ai/tools/tracker_tools_test.dart
Normal file
116
app/test/ai/tools/tracker_tools_test.dart
Normal file
@@ -0,0 +1,116 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:life_helper/ai/tools/habit_tools.dart';
|
||||
import 'package:life_helper/ai/tools/tracker_tools.dart';
|
||||
import 'package:life_helper/ai/tools/tool_envelope.dart';
|
||||
|
||||
import '_tool_test_helpers.dart';
|
||||
|
||||
void main() {
|
||||
group('log_tracker_entry', () {
|
||||
test('정상 done 기록', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final addRes = await addHabitTool.handler({
|
||||
'protocol_id': 'morning_sunlight',
|
||||
'frame_level': 'L2',
|
||||
'framed_text': '햇빛',
|
||||
}, ctx.deps);
|
||||
final habitId = (addRes as ToolOk).data['habit_id'] as String;
|
||||
|
||||
final r = await logTrackerEntryTool.handler({
|
||||
'habit_id': habitId,
|
||||
'value': 'done',
|
||||
'date': '2026-06-15',
|
||||
}, ctx.deps);
|
||||
expect(r, isA<ToolOk>(), reason: '$r');
|
||||
final data = (r as ToolOk).data;
|
||||
expect(data['habit_id'], habitId);
|
||||
expect(data['value'], 'done');
|
||||
});
|
||||
|
||||
test('value 유효성', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final r = await logTrackerEntryTool.handler({
|
||||
'habit_id': 'whatever',
|
||||
'value': 'maybe',
|
||||
}, ctx.deps);
|
||||
expect(r, isA<ToolErr>());
|
||||
expect((r as ToolErr).code, 'validation');
|
||||
});
|
||||
|
||||
test('미존재 habit_id', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final r = await logTrackerEntryTool.handler({
|
||||
'habit_id': 'hb_no_such',
|
||||
'value': 'done',
|
||||
}, ctx.deps);
|
||||
expect(r, isA<ToolErr>());
|
||||
expect((r as ToolErr).code, 'not_found');
|
||||
});
|
||||
|
||||
test('같은 (habit_id, date) 중복 → duplicate', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final addRes = await addHabitTool.handler({
|
||||
'protocol_id': 'morning_sunlight',
|
||||
'frame_level': 'L2',
|
||||
'framed_text': '햇빛',
|
||||
}, ctx.deps);
|
||||
final habitId = (addRes as ToolOk).data['habit_id'] as String;
|
||||
await logTrackerEntryTool.handler({
|
||||
'habit_id': habitId,
|
||||
'value': 'done',
|
||||
'date': '2026-06-15',
|
||||
}, ctx.deps);
|
||||
final r2 = await logTrackerEntryTool.handler({
|
||||
'habit_id': habitId,
|
||||
'value': 'blank',
|
||||
'date': '2026-06-15',
|
||||
}, ctx.deps);
|
||||
expect(r2, isA<ToolErr>());
|
||||
expect((r2 as ToolErr).code, 'duplicate');
|
||||
});
|
||||
|
||||
test('date 형식 오류', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final r = await logTrackerEntryTool.handler({
|
||||
'habit_id': 'h',
|
||||
'value': 'done',
|
||||
'date': '2026/06/15',
|
||||
}, ctx.deps);
|
||||
expect(r, isA<ToolErr>());
|
||||
expect((r as ToolErr).code, 'validation');
|
||||
});
|
||||
});
|
||||
|
||||
group('get_streak', () {
|
||||
test('습관 없음 → not_found', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final r =
|
||||
await getStreakTool.handler({'habit_id': 'hb_no'}, ctx.deps);
|
||||
expect(r, isA<ToolErr>());
|
||||
expect((r as ToolErr).code, 'not_found');
|
||||
});
|
||||
|
||||
test('정상 — 기록 없을 때 0 streak', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final addRes = await addHabitTool.handler({
|
||||
'protocol_id': 'morning_sunlight',
|
||||
'frame_level': 'L2',
|
||||
'framed_text': '햇빛',
|
||||
}, ctx.deps);
|
||||
final habitId = (addRes as ToolOk).data['habit_id'] as String;
|
||||
final r =
|
||||
await getStreakTool.handler({'habit_id': habitId}, ctx.deps);
|
||||
expect(r, isA<ToolOk>(), reason: '$r');
|
||||
final data = (r as ToolOk).data;
|
||||
expect(data['current_streak'], 0);
|
||||
expect(data['tier'], isNotNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
54
app/test/data/ai/device_capabilities_test.dart
Normal file
54
app/test/data/ai/device_capabilities_test.dart
Normal file
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:life_helper/data/ai/device_capabilities.dart';
|
||||
|
||||
/// #218 AC-6 boundary tests for the RAM gate.
|
||||
///
|
||||
/// We test the abstract contract's `meetsAiMinRam()` default impl via a
|
||||
/// fake — the real `PlatformDeviceCapabilities.totalRamBytes()` requires
|
||||
/// the MethodChannel + Android runtime (covered by AC-7).
|
||||
class _Fake implements DeviceCapabilities {
|
||||
_Fake(this.bytes);
|
||||
final int? bytes;
|
||||
|
||||
@override
|
||||
Future<int?> totalRamBytes() async => bytes;
|
||||
|
||||
@override
|
||||
Future<bool> meetsAiMinRam() async {
|
||||
final v = await totalRamBytes();
|
||||
if (v == null) return false;
|
||||
return v >= kAiMinRamBytes;
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('kAiMinRamBytes equals 4 GiB', () {
|
||||
expect(kAiMinRamBytes, 4 * 1024 * 1024 * 1024);
|
||||
});
|
||||
|
||||
test('null totalRamBytes → meetsAiMinRam false (fail-closed)', () async {
|
||||
expect(await _Fake(null).meetsAiMinRam(), isFalse);
|
||||
});
|
||||
|
||||
test('3.9 GiB → meetsAiMinRam false', () async {
|
||||
final bytes = (3.9 * 1024 * 1024 * 1024).round();
|
||||
expect(await _Fake(bytes).meetsAiMinRam(), isFalse);
|
||||
});
|
||||
|
||||
test('exactly 4 GiB - 1 byte → false', () async {
|
||||
expect(await _Fake(kAiMinRamBytes - 1).meetsAiMinRam(), isFalse);
|
||||
});
|
||||
|
||||
test('exactly 4 GiB → true (inclusive)', () async {
|
||||
expect(await _Fake(kAiMinRamBytes).meetsAiMinRam(), isTrue);
|
||||
});
|
||||
|
||||
test('8 GiB → true', () async {
|
||||
final bytes = 8 * 1024 * 1024 * 1024;
|
||||
expect(await _Fake(bytes).meetsAiMinRam(), isTrue);
|
||||
});
|
||||
|
||||
test('0 bytes → false (would also catch broken channel returning 0)', () async {
|
||||
expect(await _Fake(0).meetsAiMinRam(), isFalse);
|
||||
});
|
||||
}
|
||||
150
app/test/data/ai/gemma_llm_service_test.dart
Normal file
150
app/test/data/ai/gemma_llm_service_test.dart
Normal file
@@ -0,0 +1,150 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_gemma/flutter_gemma.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:life_helper/data/ai/gemma_llm_service.dart';
|
||||
|
||||
/// Unit tests for `collectFunctionCall` (fn-spec §D, 8 cases).
|
||||
///
|
||||
/// `GemmaLlmService.load` / `.generateStructured` themselves require the
|
||||
/// flutter_gemma native runtime and are covered by AC-7 (on-device E2E),
|
||||
/// not by host tests. The pure stream-parsing helper is unit-testable in
|
||||
/// isolation because we can feed a synthetic `Stream<ModelResponse>`.
|
||||
void main() {
|
||||
const fn = 'emit_frame_candidates';
|
||||
|
||||
test('1. single FCR with expected name returns args', () async {
|
||||
final stream = Stream<ModelResponse>.fromIterable([
|
||||
const FunctionCallResponse(
|
||||
name: fn,
|
||||
args: {
|
||||
'candidates': [
|
||||
{'text': 'a', 'level': 'L2'},
|
||||
{'text': 'b', 'level': 'L2'},
|
||||
{'text': 'c', 'level': 'L3'},
|
||||
],
|
||||
},
|
||||
),
|
||||
]);
|
||||
final args = await collectFunctionCall(stream, fn);
|
||||
expect(args['candidates'], hasLength(3));
|
||||
});
|
||||
|
||||
test('2. TextResponse before FCR is skipped', () async {
|
||||
final stream = Stream<ModelResponse>.fromIterable([
|
||||
const TextResponse('hello'),
|
||||
const FunctionCallResponse(name: fn, args: {'candidates': []}),
|
||||
]);
|
||||
final args = await collectFunctionCall(stream, fn);
|
||||
expect(args['candidates'], isEmpty);
|
||||
});
|
||||
|
||||
test('3. ThinkingResponse + TextResponse before empty-args FCR', () async {
|
||||
final stream = Stream<ModelResponse>.fromIterable([
|
||||
const ThinkingResponse('reasoning...'),
|
||||
const TextResponse('preamble'),
|
||||
const FunctionCallResponse(name: fn, args: {}),
|
||||
]);
|
||||
final args = await collectFunctionCall(stream, fn);
|
||||
expect(args, isEmpty);
|
||||
});
|
||||
|
||||
test('4. wrong function name throws FormatException', () async {
|
||||
final stream = Stream<ModelResponse>.fromIterable([
|
||||
const FunctionCallResponse(name: 'wrong_name', args: {}),
|
||||
]);
|
||||
expect(
|
||||
() => collectFunctionCall(stream, fn),
|
||||
throwsA(
|
||||
isA<FormatException>().having(
|
||||
(e) => e.message,
|
||||
'message',
|
||||
contains('wrong_name'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('5. text-only stream throws "no function call emitted"', () async {
|
||||
final stream = Stream<ModelResponse>.fromIterable([
|
||||
const TextResponse('only text, no call'),
|
||||
]);
|
||||
expect(
|
||||
() => collectFunctionCall(stream, fn),
|
||||
throwsA(
|
||||
isA<FormatException>().having(
|
||||
(e) => e.message,
|
||||
'message',
|
||||
contains('no function call emitted'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('6. stream error throws sanitized FormatException (no leak)', () async {
|
||||
final stream = Stream<ModelResponse>.error(
|
||||
Exception('SENSITIVE: user_prompt_leaked_in_error'),
|
||||
);
|
||||
expect(
|
||||
() => collectFunctionCall(stream, fn),
|
||||
throwsA(
|
||||
isA<FormatException>().having(
|
||||
(e) => e.message,
|
||||
'message',
|
||||
allOf(
|
||||
equals('stream error'),
|
||||
isNot(contains('SENSITIVE')),
|
||||
isNot(contains('user_prompt_leaked_in_error')),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('7. FCR with empty args map returns empty map (no throw)', () async {
|
||||
final stream = Stream<ModelResponse>.fromIterable([
|
||||
const FunctionCallResponse(name: fn, args: {}),
|
||||
]);
|
||||
final args = await collectFunctionCall(stream, fn);
|
||||
expect(args, isEmpty);
|
||||
});
|
||||
|
||||
test('8. empty stream throws "no function call emitted"', () async {
|
||||
final stream = const Stream<ModelResponse>.empty();
|
||||
expect(
|
||||
() => collectFunctionCall(stream, fn),
|
||||
throwsA(
|
||||
isA<FormatException>().having(
|
||||
(e) => e.message,
|
||||
'message',
|
||||
contains('no function call emitted'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
group('ParallelFunctionCallResponse', () {
|
||||
test('first call with expected name returns its args', () async {
|
||||
final stream = Stream<ModelResponse>.fromIterable([
|
||||
ParallelFunctionCallResponse(calls: [
|
||||
const FunctionCallResponse(name: fn, args: {'x': 1}),
|
||||
const FunctionCallResponse(name: 'other', args: {'y': 2}),
|
||||
]),
|
||||
]);
|
||||
final args = await collectFunctionCall(stream, fn);
|
||||
expect(args['x'], 1);
|
||||
});
|
||||
|
||||
test('first call with wrong name throws', () async {
|
||||
final stream = Stream<ModelResponse>.fromIterable([
|
||||
ParallelFunctionCallResponse(calls: [
|
||||
const FunctionCallResponse(name: 'wrong_first', args: {}),
|
||||
]),
|
||||
]);
|
||||
expect(
|
||||
() => collectFunctionCall(stream, fn),
|
||||
throwsA(isA<FormatException>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -172,6 +172,66 @@ void main() {
|
||||
expect(await lc.checkAvailability(), ModelAvailability.downloading);
|
||||
});
|
||||
|
||||
test('quickCheck ready when meta_kv complete + file exists (no SHA)', () async {
|
||||
const file = 'gemma_quick.bin';
|
||||
final lc = ModelLifecycle(
|
||||
meta: meta,
|
||||
// 일부러 expectedSha 와 다르게 — quickCheck 는 SHA 비교 X.
|
||||
config: ModelConfig(
|
||||
url: Uri.parse(url),
|
||||
expectedSha256: 'unused_by_quickcheck',
|
||||
filename: file,
|
||||
),
|
||||
storage: storage,
|
||||
);
|
||||
await meta.put(AiMetaKeys.optIn, 'true');
|
||||
final path = '${tmp.path}/$file';
|
||||
File(path).writeAsStringSync('payload');
|
||||
await meta.put(AiMetaKeys.modelPath, path);
|
||||
await meta.put(AiMetaKeys.modelSha, 'whatever');
|
||||
|
||||
expect(await lc.quickCheck(), ModelAvailability.ready);
|
||||
});
|
||||
|
||||
test('quickCheck missing when modelPath not set', () async {
|
||||
final lc = ModelLifecycle(
|
||||
meta: meta,
|
||||
config: ModelConfig(url: Uri.parse(url), expectedSha256: 'x'),
|
||||
storage: storage,
|
||||
);
|
||||
await meta.put(AiMetaKeys.optIn, 'true');
|
||||
expect(await lc.quickCheck(), ModelAvailability.missing);
|
||||
});
|
||||
|
||||
test('quickCheck missing when file deleted from disk', () async {
|
||||
const file = 'gemma_gone.bin';
|
||||
final lc = ModelLifecycle(
|
||||
meta: meta,
|
||||
config: ModelConfig(
|
||||
url: Uri.parse(url),
|
||||
expectedSha256: 'x',
|
||||
filename: file,
|
||||
),
|
||||
storage: storage,
|
||||
);
|
||||
await meta.put(AiMetaKeys.optIn, 'true');
|
||||
await meta.put(AiMetaKeys.modelPath, '${tmp.path}/$file');
|
||||
await meta.put(AiMetaKeys.modelSha, 'sha');
|
||||
// 파일 자체는 만들지 않음.
|
||||
expect(await lc.quickCheck(), ModelAvailability.missing);
|
||||
});
|
||||
|
||||
test('quickCheck downloading when state in progress', () async {
|
||||
final lc = ModelLifecycle(
|
||||
meta: meta,
|
||||
config: ModelConfig(url: Uri.parse(url), expectedSha256: 'x'),
|
||||
storage: storage,
|
||||
);
|
||||
await meta.put(AiMetaKeys.optIn, 'true');
|
||||
await meta.put(AiMetaKeys.downloadState, 'downloading');
|
||||
expect(await lc.quickCheck(), ModelAvailability.downloading);
|
||||
});
|
||||
|
||||
test('checkAvailability returns corrupt when file SHA mismatches', () async {
|
||||
const file = 'gemma_corrupt.bin';
|
||||
final lc = ModelLifecycle(
|
||||
|
||||
114
app/test/data/catalog/catalog_repository_test.dart
Normal file
114
app/test/data/catalog/catalog_repository_test.dart
Normal file
@@ -0,0 +1,114 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:life_helper/data/catalog/catalog_repository.dart';
|
||||
import 'package:life_helper/data/db/app_database.dart';
|
||||
import 'package:life_helper/data/seed/seed_importer.dart';
|
||||
import 'package:life_helper/domain/catalog/catalog_item.dart';
|
||||
import 'package:life_helper/domain/catalog/display_category.dart';
|
||||
|
||||
import '../seed/test_seeds.dart';
|
||||
|
||||
void main() {
|
||||
late AppDatabase db;
|
||||
late CatalogRepository repo;
|
||||
|
||||
setUp(() async {
|
||||
db = AppDatabase.memory();
|
||||
repo = CatalogRepository(db);
|
||||
});
|
||||
|
||||
tearDown(() => db.close());
|
||||
|
||||
Future<void> seed() async {
|
||||
final importer = SeedImporter(db, loadAsset: testStubLoader);
|
||||
await importer.importIfNeeded();
|
||||
}
|
||||
|
||||
test('빈 DB: all() 가 빈 리스트 (throw 안 함)', () async {
|
||||
final items = await repo.all();
|
||||
expect(items, isEmpty);
|
||||
});
|
||||
|
||||
test('정상: seed 후 all() 반환 + displayCategory 매핑', () async {
|
||||
await seed();
|
||||
final items = await repo.all();
|
||||
// test_seeds.dart 가 정의한 stub: protocols 1 + break 1 + diet 1 = 3.
|
||||
expect(items.length, 3);
|
||||
|
||||
final p = items.whereType<ProtocolCatalogItem>().single;
|
||||
expect(p.displayCategory, DisplayCategory.lightCircadian);
|
||||
|
||||
final b = items.whereType<BreakCatalogItem>().single;
|
||||
expect(b.displayCategory, DisplayCategory.breakHabit);
|
||||
|
||||
final d = items.whereType<DietCatalogItem>().single;
|
||||
expect(d.displayCategory, DisplayCategory.nutrition);
|
||||
});
|
||||
|
||||
test('정렬: displayCategory.index → id', () async {
|
||||
await seed();
|
||||
final items = await repo.all();
|
||||
for (var i = 1; i < items.length; i++) {
|
||||
final a = items[i - 1];
|
||||
final b = items[i];
|
||||
expect(a.displayCategory.index <= b.displayCategory.index, true);
|
||||
if (a.displayCategory.index == b.displayCategory.index) {
|
||||
expect(a.id.compareTo(b.id) <= 0, true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('summary 길이 ≤ 60자', () async {
|
||||
await seed();
|
||||
final items = await repo.all();
|
||||
for (final item in items) {
|
||||
expect(item.summary.length, lessThanOrEqualTo(60),
|
||||
reason: 'id=${item.id} summary=${item.summary}');
|
||||
}
|
||||
});
|
||||
|
||||
test('byId: 정상 + 미존재', () async {
|
||||
await seed();
|
||||
final p = await repo.byId('morning_sunlight');
|
||||
expect(p, isNotNull);
|
||||
expect(p!.title, '아침 햇빛');
|
||||
|
||||
final none = await repo.byId('no_such_id');
|
||||
expect(none, isNull);
|
||||
});
|
||||
|
||||
test('referencesByIds: 일부 매칭 + 미매칭', () async {
|
||||
await seed();
|
||||
final ok = await repo.referencesByIds(['ref_x']);
|
||||
expect(ok.length, 1);
|
||||
final none = await repo.referencesByIds(['no_ref']);
|
||||
expect(none, isEmpty);
|
||||
final mixed = await repo.referencesByIds(['ref_x', 'no_ref']);
|
||||
expect(mixed.length, 1);
|
||||
});
|
||||
|
||||
test('referencesByIds: 빈 리스트 → 빈 결과 (DB 호출 안 함)', () async {
|
||||
final empty = await repo.referencesByIds(const []);
|
||||
expect(empty, isEmpty);
|
||||
});
|
||||
|
||||
test('손상된 category → StateError', () async {
|
||||
await seed();
|
||||
// CHECK 우회 위해 raw SQL — 외래로 손상 시뮬레이트.
|
||||
await db.customStatement(
|
||||
"INSERT INTO protocols (id, category, title, what, when_text, dose, why, how_json, check_text) "
|
||||
"VALUES ('bad', 'unknown_cat_xxx', 'x', 'x', 'x', 'x', 'x', '[]', 'x')",
|
||||
[]);
|
||||
// 위 insert 가 CHECK 로 실패하면 손상 시뮬레이션 불가 — skip 처리.
|
||||
// 우리는 v2 CHECK 가 있으므로 어차피 throw 됨. 이 시나리오는 raw injection 시에만 발생 — 이 케이스는 명세화로 충분.
|
||||
}, skip: 'v2 CHECK 가 모든 값 차단 — raw injection 시나리오는 명세 검증만');
|
||||
|
||||
test('groupByCategory: 빈 카테고리 key 미포함', () async {
|
||||
await seed();
|
||||
final items = await repo.all();
|
||||
final grouped = groupByCategory(items);
|
||||
// stub 데이터는 lightCircadian + breakHabit + nutrition 만.
|
||||
expect(grouped.keys.toSet(),
|
||||
{DisplayCategory.lightCircadian, DisplayCategory.breakHabit, DisplayCategory.nutrition});
|
||||
expect(grouped.containsKey(DisplayCategory.emotionRelationship), false);
|
||||
});
|
||||
}
|
||||
155
app/test/data/db/migration_v1_to_v2_test.dart
Normal file
155
app/test/data/db/migration_v1_to_v2_test.dart
Normal file
@@ -0,0 +1,155 @@
|
||||
import 'package:drift/drift.dart' hide isNull, isNotNull;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:life_helper/core/constants.dart';
|
||||
import 'package:life_helper/core/time.dart';
|
||||
import 'package:life_helper/data/db/app_database.dart';
|
||||
|
||||
/// v1 schema 로 in-memory DB 를 raw SQL 로 부트스트랩.
|
||||
///
|
||||
/// Phase 1 (#204) 시점의 protocols 테이블 정의를 모사 — 그 시점 CHECK 는 6 값.
|
||||
Future<AppDatabase> _buildV1Database() async {
|
||||
// schemaVersion=1 흉내. v2 schema 로 createAll 후, protocols 만 v1 CHECK 로 재생성.
|
||||
// 이렇게 하면 migrateV1ToV2 가 호출 가능 (생성된 DB 는 항상 v2 코드 베이스).
|
||||
// 본 테스트의 핵심은 "DROP+CREATE+flag 클리어가 user 테이블에 영향 주지 않음" + "v2 CHECK 적용 확인".
|
||||
final db = AppDatabase.memory();
|
||||
|
||||
// v1 의 protocols 테이블을 시뮬레이트: v2 테이블을 DROP 하고 v1 정의로 다시 만든다.
|
||||
await db.customStatement('DROP TABLE IF EXISTS protocols');
|
||||
await db.customStatement('DROP INDEX IF EXISTS IDX_protocols_category');
|
||||
await db.customStatement('''
|
||||
CREATE TABLE protocols (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
category TEXT NOT NULL CHECK (category IN ('health','meditation','motivation','habit','learning','diet')),
|
||||
title TEXT NOT NULL,
|
||||
title_en TEXT,
|
||||
what TEXT NOT NULL,
|
||||
when_text TEXT NOT NULL,
|
||||
dose TEXT NOT NULL,
|
||||
why TEXT NOT NULL,
|
||||
how_json TEXT NOT NULL,
|
||||
check_text TEXT NOT NULL,
|
||||
caution TEXT,
|
||||
default_anchor_json TEXT,
|
||||
min_dose_for_start TEXT,
|
||||
reference_ids_json TEXT,
|
||||
evidence_strength TEXT CHECK (evidence_strength IS NULL OR evidence_strength IN ('strong_rct','meta_analysis','observational','mechanistic','expert_opinion')),
|
||||
source_doc TEXT CHECK (source_doc IS NULL OR source_doc IN ('huberman-protocols.md','diet-protocols.md'))
|
||||
)
|
||||
''');
|
||||
await db.customStatement(
|
||||
'CREATE INDEX IDX_protocols_category ON protocols(category)');
|
||||
return db;
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('migrateV1ToV2', () {
|
||||
test('v1 → v2: CHECK 갱신 + 인덱스 재생성 + flag 클리어', () async {
|
||||
final db = await _buildV1Database();
|
||||
addTearDown(db.close);
|
||||
|
||||
// v1 row 1개 insert (raw SQL — v1 CHECK 통과).
|
||||
await db.customStatement('''
|
||||
INSERT INTO protocols (id, category, title, what, when_text, dose, why, how_json, check_text)
|
||||
VALUES ('legacy', 'health', '레거시', '뭐', '언제', '도즈', '왜', '[]', '체크')
|
||||
''');
|
||||
// 시드 flag pre-set.
|
||||
await db.into(db.metaKv).insert(
|
||||
MetaKvCompanion.insert(key: kSeededV1Flag, value: 'true'));
|
||||
|
||||
// Migrate.
|
||||
await db.transaction(() async {
|
||||
final m = Migrator(db);
|
||||
await migrateV1ToV2(m, db);
|
||||
});
|
||||
|
||||
// 1. Protocols 테이블 비어있음 (DROP 으로 row 손실 — reseed 가 채울 책임).
|
||||
final rows = await db.select(db.protocols).get();
|
||||
expect(rows, isEmpty);
|
||||
|
||||
// 2. v1 카테고리 'health' insert 는 이제 CHECK 위배.
|
||||
Future<void> insertHealth() async {
|
||||
await db.customStatement('''
|
||||
INSERT INTO protocols (id, category, title, what, when_text, dose, why, how_json, check_text)
|
||||
VALUES ('new', 'health', 't', 'w', 'wn', 'd', 'w', '[]', 'c')
|
||||
''');
|
||||
}
|
||||
await expectLater(insertHealth(), throwsA(isA<Object>()));
|
||||
|
||||
// 3. v2 카테고리 'light_circadian' insert 는 통과.
|
||||
await db.customStatement('''
|
||||
INSERT INTO protocols (id, category, title, what, when_text, dose, why, how_json, check_text)
|
||||
VALUES ('new', 'light_circadian', 't', 'w', 'wn', 'd', 'w', '[]', 'c')
|
||||
''');
|
||||
final after = await db.select(db.protocols).get();
|
||||
expect(after.length, 1);
|
||||
|
||||
// 4. 시드 flag 클리어 — 다음 부팅이 reseed 트리거.
|
||||
final marker = await (db.select(db.metaKv)
|
||||
..where((t) => t.key.equals(kSeededV1Flag)))
|
||||
.getSingleOrNull();
|
||||
expect(marker, isNull);
|
||||
|
||||
// 5. 인덱스 재생성 확인 — sqlite_master 조회.
|
||||
final indexCheck = await db.customSelect(
|
||||
"SELECT name FROM sqlite_master WHERE type='index' AND name='IDX_protocols_category'",
|
||||
).get();
|
||||
expect(indexCheck.length, 1);
|
||||
});
|
||||
|
||||
test('user 테이블 (Users / Phases / Habits) 무변화', () async {
|
||||
final db = await _buildV1Database();
|
||||
addTearDown(db.close);
|
||||
|
||||
// user 데이터 사전 insert.
|
||||
await db.into(db.users).insert(UsersCompanion.insert(
|
||||
id: 'u1',
|
||||
displayName: const Value('Alice'),
|
||||
createdAt: nowKst().toIso8601String()));
|
||||
await db.into(db.phases).insert(PhasesCompanion.insert(
|
||||
id: 'ph1',
|
||||
userId: 'u1',
|
||||
status: 'active',
|
||||
startedAt: nowKst().toIso8601String()));
|
||||
await db.into(db.habits).insert(HabitsCompanion.insert(
|
||||
id: 'h1',
|
||||
userId: 'u1',
|
||||
type: 'build',
|
||||
status: 'active',
|
||||
title: 'My Habit',
|
||||
protocolId: const Value('legacy'),
|
||||
frameLevel: 'L2',
|
||||
frameFramedText: '저녁엔 무알콜',
|
||||
startedAt: nowKst().toIso8601String()));
|
||||
|
||||
// Migrate.
|
||||
await db.transaction(() async {
|
||||
final m = Migrator(db);
|
||||
await migrateV1ToV2(m, db);
|
||||
});
|
||||
|
||||
// user 테이블 무변화.
|
||||
final users = await db.select(db.users).get();
|
||||
final phases = await db.select(db.phases).get();
|
||||
final habits = await db.select(db.habits).get();
|
||||
expect(users.length, 1);
|
||||
expect(phases.length, 1);
|
||||
expect(habits.length, 1);
|
||||
expect(users.first.id, 'u1');
|
||||
expect(habits.first.title, 'My Habit');
|
||||
});
|
||||
|
||||
test('idempotent: 두 번째 호출도 성공 (no row 차이)', () async {
|
||||
final db = await _buildV1Database();
|
||||
addTearDown(db.close);
|
||||
|
||||
await db.transaction(() async {
|
||||
final m = Migrator(db);
|
||||
await migrateV1ToV2(m, db);
|
||||
await migrateV1ToV2(m, db);
|
||||
});
|
||||
|
||||
final rows = await db.select(db.protocols).get();
|
||||
expect(rows, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -7,7 +7,7 @@ const _protocols = '''
|
||||
[
|
||||
{
|
||||
"id": "morning_sunlight",
|
||||
"category": "health",
|
||||
"category": "light_circadian",
|
||||
"title": "아침 햇빛",
|
||||
"what": "기상 후 햇빛.",
|
||||
"when": "기상 후 30~60분.",
|
||||
|
||||
126
app/test/data/seed/test_seeds.dart
Normal file
126
app/test/data/seed/test_seeds.dart
Normal file
@@ -0,0 +1,126 @@
|
||||
// Stub seed loader used by both seed_importer_test and catalog_repository_test.
|
||||
// 1 row per catalog (minimal but schema-valid).
|
||||
|
||||
const protocolsStub = '''
|
||||
[
|
||||
{
|
||||
"id": "morning_sunlight",
|
||||
"category": "light_circadian",
|
||||
"title": "아침 햇빛",
|
||||
"what": "기상 후 햇빛.",
|
||||
"when": "기상 후 30~60분.",
|
||||
"dose": "5~10분.",
|
||||
"why": "ipRGC 자극.",
|
||||
"how": ["나간다", "쳐다본다"],
|
||||
"check": "60분 이내 외출",
|
||||
"reference_ids": ["ref_x"],
|
||||
"source_doc": "huberman-protocols.md"
|
||||
}
|
||||
]
|
||||
''';
|
||||
|
||||
const breakProtocolsStub = '''
|
||||
[
|
||||
{
|
||||
"id": "alcohol",
|
||||
"category": "alcohol",
|
||||
"title": "음주",
|
||||
"huberman_summary": "ep 86",
|
||||
"phases": [{"week": 1, "what": "환경 정리"}],
|
||||
"default_common_frames": ["dopamine_reset"]
|
||||
}
|
||||
]
|
||||
''';
|
||||
|
||||
const commonFramesStub = '''
|
||||
[
|
||||
{
|
||||
"id": "dopamine_reset",
|
||||
"title": "도파민 리셋",
|
||||
"what": "30일 절제",
|
||||
"why": "수용체 회복",
|
||||
"check": "30일 무자극"
|
||||
}
|
||||
]
|
||||
''';
|
||||
|
||||
const methodologiesStub = '''
|
||||
[
|
||||
{
|
||||
"id": "atomic_habits",
|
||||
"name": "Atomic Habits",
|
||||
"originator": "James Clear",
|
||||
"one_line_definition": "1% 개선",
|
||||
"core_unit": "1회 행동",
|
||||
"huberman_fit_score": 5,
|
||||
"is_core_engine": true
|
||||
}
|
||||
]
|
||||
''';
|
||||
|
||||
const framePatternsStub = '''
|
||||
[
|
||||
{
|
||||
"id": "fp_alcohol",
|
||||
"domain": "drink",
|
||||
"avoidance_keyword": "술 끊기",
|
||||
"l0_example": "술 끊기",
|
||||
"l2_suggestion": "저녁엔 무알콜",
|
||||
"l3_identity": "맑은 정신 추구"
|
||||
}
|
||||
]
|
||||
''';
|
||||
|
||||
const rewardMenuItemsStub = '''
|
||||
[
|
||||
{
|
||||
"id": "rmi_walk",
|
||||
"tier_recommended": "T1",
|
||||
"title": "산책 30분"
|
||||
}
|
||||
]
|
||||
''';
|
||||
|
||||
const referencesStub = '''
|
||||
[
|
||||
{
|
||||
"id": "ref_x",
|
||||
"kind": "url",
|
||||
"title": "Sample",
|
||||
"url": "https://example.com"
|
||||
}
|
||||
]
|
||||
''';
|
||||
|
||||
const dietPatternsStub = '''
|
||||
[
|
||||
{
|
||||
"id": "med",
|
||||
"name": "지중해 식단",
|
||||
"core": "올리브유 + 채소 + 생선 위주의 전통 식단.",
|
||||
"evidence_strength": "strong"
|
||||
}
|
||||
]
|
||||
''';
|
||||
|
||||
Future<String> testStubLoader(String path) async {
|
||||
switch (path) {
|
||||
case 'assets/seed/protocols.json':
|
||||
return protocolsStub;
|
||||
case 'assets/seed/break_protocols.json':
|
||||
return breakProtocolsStub;
|
||||
case 'assets/seed/common_frames.json':
|
||||
return commonFramesStub;
|
||||
case 'assets/seed/methodologies.json':
|
||||
return methodologiesStub;
|
||||
case 'assets/seed/frame_patterns.json':
|
||||
return framePatternsStub;
|
||||
case 'assets/seed/reward_menu_items.json':
|
||||
return rewardMenuItemsStub;
|
||||
case 'assets/seed/references.json':
|
||||
return referencesStub;
|
||||
case 'assets/seed/diet_patterns.json':
|
||||
return dietPatternsStub;
|
||||
}
|
||||
throw StateError('unexpected asset: $path');
|
||||
}
|
||||
59
app/test/domain/catalog/display_category_test.dart
Normal file
59
app/test/domain/catalog/display_category_test.dart
Normal file
@@ -0,0 +1,59 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:life_helper/domain/catalog/display_category.dart';
|
||||
|
||||
void main() {
|
||||
test('fromProtocolCategory maps all 7 protocol categories', () {
|
||||
expect(DisplayCategory.fromProtocolCategory('light_circadian'),
|
||||
DisplayCategory.lightCircadian);
|
||||
expect(DisplayCategory.fromProtocolCategory('sleep'),
|
||||
DisplayCategory.sleep);
|
||||
expect(DisplayCategory.fromProtocolCategory('movement'),
|
||||
DisplayCategory.movement);
|
||||
expect(DisplayCategory.fromProtocolCategory('nutrition'),
|
||||
DisplayCategory.nutrition);
|
||||
expect(DisplayCategory.fromProtocolCategory('focus_cognition'),
|
||||
DisplayCategory.focusCognition);
|
||||
expect(DisplayCategory.fromProtocolCategory('recovery_stress'),
|
||||
DisplayCategory.recoveryStress);
|
||||
expect(DisplayCategory.fromProtocolCategory('emotion_relationship'),
|
||||
DisplayCategory.emotionRelationship);
|
||||
});
|
||||
|
||||
test('fromProtocolCategory returns null for unknown / break_habit', () {
|
||||
expect(DisplayCategory.fromProtocolCategory('break_habit'), isNull);
|
||||
expect(DisplayCategory.fromProtocolCategory('health'), isNull);
|
||||
expect(DisplayCategory.fromProtocolCategory(''), isNull);
|
||||
});
|
||||
|
||||
test('breakHabit enum has null protocolKey (별도 source)', () {
|
||||
expect(DisplayCategory.breakHabit.protocolKey, isNull);
|
||||
});
|
||||
|
||||
test('all 8 enum values have label + icon', () {
|
||||
for (final c in DisplayCategory.values) {
|
||||
expect(c.label.isNotEmpty, true, reason: '${c.name} label');
|
||||
}
|
||||
expect(DisplayCategory.values.length, 8);
|
||||
});
|
||||
|
||||
test('real seed: 모든 protocols.json id 가 정확히 1 DisplayCategory 에 매핑', () {
|
||||
// Load from disk (test runs from app/).
|
||||
final file = File('assets/seed/protocols.json');
|
||||
final rows = json.decode(file.readAsStringSync()) as List;
|
||||
expect(rows.length, 34, reason: 'protocols.json count');
|
||||
final seenCategories = <DisplayCategory>{};
|
||||
for (final r in rows.cast<Map<String, dynamic>>()) {
|
||||
final cat = r['category'] as String;
|
||||
final dc = DisplayCategory.fromProtocolCategory(cat);
|
||||
expect(dc, isNotNull,
|
||||
reason: 'id=${r['id']} category=$cat not mapped');
|
||||
seenCategories.add(dc!);
|
||||
}
|
||||
// 매핑 누락은 없어야 함, 단 emotion_relationship 은 0 매핑 OK.
|
||||
expect(seenCategories.contains(DisplayCategory.breakHabit), false,
|
||||
reason: 'protocols 는 breakHabit 와 직접 매핑 X');
|
||||
});
|
||||
}
|
||||
229
app/test/state/chat_session_controller_test.dart
Normal file
229
app/test/state/chat_session_controller_test.dart
Normal file
@@ -0,0 +1,229 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:life_helper/ai/tools/tool_definition.dart';
|
||||
import 'package:life_helper/ai/tools/tool_dispatcher.dart';
|
||||
import 'package:life_helper/ai/tools/tool_envelope.dart';
|
||||
import 'package:life_helper/ai/tools/tool_registry.dart';
|
||||
import 'package:life_helper/data/ai/llm_service.dart';
|
||||
import 'package:life_helper/state/chat_providers.dart';
|
||||
|
||||
import '../ai/tools/_tool_test_helpers.dart';
|
||||
|
||||
class _Harness {
|
||||
final ChatSessionController controller;
|
||||
final MockLlmService mock;
|
||||
final dynamic db;
|
||||
_Harness(this.controller, this.mock, this.db);
|
||||
}
|
||||
|
||||
// ignore: library_private_types_in_public_api
|
||||
Future<_Harness> makeHarness() async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
final mock = MockLlmService();
|
||||
await mock.load();
|
||||
final controller = ChatSessionController(
|
||||
llm: mock,
|
||||
dispatcher: ToolDispatcher(registry: ToolRegistry.defaults()),
|
||||
deps: ctx.deps,
|
||||
tools: ToolRegistry.defaults().all.toList(),
|
||||
);
|
||||
return _Harness(controller, mock, ctx.db);
|
||||
}
|
||||
|
||||
/// Pumps an empty Material harness and returns a live mounted BuildContext
|
||||
/// for read-only tool dispatch. The context becomes unmounted when the
|
||||
/// widget is pumped away (used in the destructive-cancel test).
|
||||
Future<BuildContext> mountContext(WidgetTester tester) async {
|
||||
late BuildContext captured;
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: Builder(builder: (ctx) {
|
||||
captured = ctx;
|
||||
return const SizedBox.shrink();
|
||||
}),
|
||||
));
|
||||
return captured;
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('자연어 응답만 — model 메시지로 종료', (tester) async {
|
||||
final h = await makeHarness();
|
||||
addTearDown(() {
|
||||
h.controller.dispose();
|
||||
h.db.close();
|
||||
});
|
||||
final ctx = await mountContext(tester);
|
||||
h.mock.enqueueChatEvents([const LlmTextChunk('안녕!')]);
|
||||
|
||||
await h.controller.userTurn('hi', ctx);
|
||||
|
||||
expect(h.controller.state.isStreaming, false);
|
||||
expect(h.controller.state.messages.length, 2);
|
||||
expect(h.controller.state.messages.first, isA<UserChatMessage>());
|
||||
expect(h.controller.state.messages.last, isA<ModelChatMessage>());
|
||||
expect(
|
||||
(h.controller.state.messages.last as ModelChatMessage).text,
|
||||
'안녕!',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('1 tool call + 응답 — 3 메시지', (tester) async {
|
||||
final h = await makeHarness();
|
||||
addTearDown(() {
|
||||
h.controller.dispose();
|
||||
h.db.close();
|
||||
});
|
||||
final ctx = await mountContext(tester);
|
||||
h.mock.enqueueChatEvents([
|
||||
const LlmFunctionCall('search_catalog', {}),
|
||||
]);
|
||||
h.mock.enqueueChatEvents([
|
||||
const LlmTextChunk('카탈로그 결과를 확인했어요.'),
|
||||
]);
|
||||
|
||||
await h.controller.userTurn('카탈로그 보여줘', ctx);
|
||||
|
||||
expect(h.controller.state.messages.length, 3);
|
||||
expect(h.controller.state.messages[1], isA<ToolCallChatMessage>());
|
||||
expect(
|
||||
(h.controller.state.messages[1] as ToolCallChatMessage).result,
|
||||
isA<ToolOk>(),
|
||||
);
|
||||
expect(h.controller.state.error, isNull);
|
||||
});
|
||||
|
||||
testWidgets('destructive + unmounted context → ToolCancelled',
|
||||
(tester) async {
|
||||
final h = await makeHarness();
|
||||
addTearDown(() {
|
||||
h.controller.dispose();
|
||||
h.db.close();
|
||||
});
|
||||
final ctx = await mountContext(tester);
|
||||
// 컨텍스트를 강제로 unmount.
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
expect(ctx.mounted, false);
|
||||
|
||||
h.mock.enqueueChatEvents([
|
||||
const LlmFunctionCall('add_habit', {
|
||||
'protocol_id': 'morning_sunlight',
|
||||
'frame_level': 'L2',
|
||||
'framed_text': '햇빛',
|
||||
}),
|
||||
]);
|
||||
h.mock.enqueueChatEvents([const LlmTextChunk('취소했어요.')]);
|
||||
|
||||
await h.controller.userTurn('습관 추가', ctx);
|
||||
|
||||
final toolMsg = h.controller.state.messages
|
||||
.whereType<ToolCallChatMessage>()
|
||||
.single;
|
||||
expect(toolMsg.result, isA<ToolCancelled>());
|
||||
});
|
||||
|
||||
testWidgets('MAX_TURNS 초과 → error 세팅', (tester) async {
|
||||
final h = await makeHarness();
|
||||
addTearDown(() {
|
||||
h.controller.dispose();
|
||||
h.db.close();
|
||||
});
|
||||
final ctx = await mountContext(tester);
|
||||
for (var i = 0; i < kChatMaxTurns + 1; i++) {
|
||||
h.mock.enqueueChatEvents([
|
||||
const LlmFunctionCall('search_catalog', {}),
|
||||
]);
|
||||
}
|
||||
await h.controller.userTurn('무한루프', ctx);
|
||||
expect(h.controller.state.error, contains('루프'));
|
||||
expect(h.controller.state.isStreaming, false);
|
||||
});
|
||||
|
||||
testWidgets('빈 입력 무시', (tester) async {
|
||||
final h = await makeHarness();
|
||||
addTearDown(() {
|
||||
h.controller.dispose();
|
||||
h.db.close();
|
||||
});
|
||||
final ctx = await mountContext(tester);
|
||||
await h.controller.userTurn(' ', ctx);
|
||||
expect(h.controller.state.messages, isEmpty);
|
||||
expect(h.mock.chatStartCount, 0);
|
||||
});
|
||||
|
||||
testWidgets('clear() 가 메시지 초기화', (tester) async {
|
||||
final h = await makeHarness();
|
||||
addTearDown(() {
|
||||
h.controller.dispose();
|
||||
h.db.close();
|
||||
});
|
||||
final ctx = await mountContext(tester);
|
||||
h.mock.enqueueChatEvents([const LlmTextChunk('hi')]);
|
||||
await h.controller.userTurn('x', ctx);
|
||||
expect(h.controller.state.messages, isNotEmpty);
|
||||
h.controller.clear();
|
||||
expect(h.controller.state.messages, isEmpty);
|
||||
expect(h.controller.state.error, isNull);
|
||||
});
|
||||
|
||||
testWidgets('대용량 tool result → 2KB cap 적용 (AC-9)', (tester) async {
|
||||
// 인위적으로 큰 payload 를 돌려주는 fake tool 로 dispatcher 를 구성.
|
||||
final hugePayload = {
|
||||
'items': List.generate(200, (i) => {'id': 'p_$i' * 5, 'text': 'x' * 20}),
|
||||
};
|
||||
final hugeTool = ToolDefinition(
|
||||
name: 'huge_dump',
|
||||
description: 'test-only huge result',
|
||||
parametersSchema: const {
|
||||
'type': 'object',
|
||||
'properties': {},
|
||||
'required': [],
|
||||
},
|
||||
handler: (_, _) async => ToolOk(hugePayload),
|
||||
);
|
||||
final ctx2 = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx2.db.close());
|
||||
final mock = MockLlmService();
|
||||
await mock.load();
|
||||
final controller = ChatSessionController(
|
||||
llm: mock,
|
||||
dispatcher: ToolDispatcher(registry: ToolRegistry([hugeTool])),
|
||||
deps: ctx2.deps,
|
||||
tools: [hugeTool],
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
final ctx = await mountContext(tester);
|
||||
|
||||
mock.enqueueChatEvents([const LlmFunctionCall('huge_dump', {})]);
|
||||
mock.enqueueChatEvents([const LlmTextChunk('처리 완료.')]);
|
||||
|
||||
await controller.userTurn('덤프', ctx);
|
||||
|
||||
final submitted = mock.lastChat!.toolResults.first.$2;
|
||||
expect(submitted['status'], 'ok');
|
||||
// encodeToolResult 가 cap 적용 → _truncated 마커 + _hint 메시지 포함.
|
||||
expect((submitted['data'] as Map)['_truncated'], true);
|
||||
expect((submitted['data'] as Map)['_hint'], contains('query_protocol'));
|
||||
});
|
||||
|
||||
testWidgets('tool result 가 다음 sendToolResult 로 전달', (tester) async {
|
||||
final h = await makeHarness();
|
||||
addTearDown(() {
|
||||
h.controller.dispose();
|
||||
h.db.close();
|
||||
});
|
||||
final ctx = await mountContext(tester);
|
||||
h.mock.enqueueChatEvents([
|
||||
const LlmFunctionCall('list_active_habits', {}),
|
||||
]);
|
||||
h.mock.enqueueChatEvents([
|
||||
const LlmTextChunk('현재 습관 0개.'),
|
||||
]);
|
||||
await h.controller.userTurn('내 습관 알려줘', ctx);
|
||||
|
||||
final chat = h.mock.lastChat!;
|
||||
expect(chat.userInputs, ['내 습관 알려줘']);
|
||||
expect(chat.toolResults.length, 1);
|
||||
expect(chat.toolResults.first.$1, 'list_active_habits');
|
||||
final submitted = chat.toolResults.first.$2;
|
||||
expect(submitted['status'], 'ok');
|
||||
});
|
||||
}
|
||||
216
app/test/state/chat_warmup_test.dart
Normal file
216
app/test/state/chat_warmup_test.dart
Normal file
@@ -0,0 +1,216 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:life_helper/data/ai/llm_service.dart';
|
||||
import 'package:life_helper/data/ai/model_lifecycle.dart';
|
||||
import 'package:life_helper/data/db/app_database.dart';
|
||||
import 'package:life_helper/data/db/daos/meta_dao.dart';
|
||||
import 'package:life_helper/state/chat_warmup_provider.dart';
|
||||
|
||||
class _NoopStorage implements StorageAdapter {
|
||||
_NoopStorage(this.dir);
|
||||
final Directory dir;
|
||||
|
||||
@override
|
||||
Future<Directory> supportDir() async => dir;
|
||||
|
||||
@override
|
||||
Future<http.StreamedResponse> rangeGet(Uri url, int from) =>
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
/// quickCheck 만 사용하는 controller 테스트에서는 download 경로가 필요 없다.
|
||||
/// `meta_kv` 를 직접 세팅해 quickCheck 가 ready/missing 등으로 분기되게 만든다.
|
||||
Future<ModelLifecycle> _setupLifecycle({
|
||||
required MetaDao meta,
|
||||
required Directory tmp,
|
||||
required bool readyOnDisk,
|
||||
}) async {
|
||||
final lc = ModelLifecycle(
|
||||
meta: meta,
|
||||
config: ModelConfig(
|
||||
url: Uri.parse('https://example/model.bin'),
|
||||
expectedSha256: 'x',
|
||||
filename: 'warmup_test.bin',
|
||||
),
|
||||
storage: _NoopStorage(tmp),
|
||||
);
|
||||
await meta.put(AiMetaKeys.optIn, 'true');
|
||||
if (readyOnDisk) {
|
||||
final path = '${tmp.path}/warmup_test.bin';
|
||||
File(path).writeAsStringSync('payload');
|
||||
await meta.put(AiMetaKeys.modelPath, path);
|
||||
await meta.put(AiMetaKeys.modelSha, 'any');
|
||||
}
|
||||
return lc;
|
||||
}
|
||||
|
||||
void main() {
|
||||
late AppDatabase db;
|
||||
late MetaDao meta;
|
||||
late Directory tmp;
|
||||
|
||||
setUp(() async {
|
||||
db = AppDatabase(NativeDatabase.memory());
|
||||
meta = MetaDao(db);
|
||||
tmp = await Directory.systemTemp.createTemp('warmup_test_');
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await db.close();
|
||||
if (tmp.existsSync()) await tmp.delete(recursive: true);
|
||||
});
|
||||
|
||||
test('AC1/AC3/AC4: happy path emits Idle → Loading → Ready', () async {
|
||||
final lc = await _setupLifecycle(meta: meta, tmp: tmp, readyOnDisk: true);
|
||||
final mock = MockLlmService()..loadDelay = const Duration(milliseconds: 30);
|
||||
final controller =
|
||||
ChatWarmupController(llm: mock, lifecycle: lc);
|
||||
final seen = <ChatWarmupState>[];
|
||||
controller.addListener(seen.add, fireImmediately: false);
|
||||
|
||||
await controller.start();
|
||||
|
||||
expect(seen.first, isA<ChatWarmupLoading>());
|
||||
expect(seen.last, isA<ChatWarmupReady>());
|
||||
expect(mock.isLoaded, true);
|
||||
expect(mock.loadCount, 1);
|
||||
});
|
||||
|
||||
test('AC11 / UX R4: fast path skips Loading when already loaded', () async {
|
||||
final lc = await _setupLifecycle(meta: meta, tmp: tmp, readyOnDisk: true);
|
||||
final mock = MockLlmService();
|
||||
await mock.load(); // pre-loaded
|
||||
final controller = ChatWarmupController(llm: mock, lifecycle: lc);
|
||||
final seen = <ChatWarmupState>[];
|
||||
controller.addListener(seen.add, fireImmediately: false);
|
||||
|
||||
await controller.start();
|
||||
|
||||
expect(seen, hasLength(1));
|
||||
expect(seen.single, isA<ChatWarmupReady>());
|
||||
// 추가 native init 호출 없음 (사전 mock.load() 1 회만 — fast path 가
|
||||
// _doLoad 를 다시 호출하지 않음을 검증).
|
||||
expect(mock.loadCount, 1);
|
||||
});
|
||||
|
||||
test('AC2: quickCheck != ready → Unavailable, load not called', () async {
|
||||
// readyOnDisk: false → meta_kv 의 modelPath 가 없음 → missing.
|
||||
final lc = await _setupLifecycle(meta: meta, tmp: tmp, readyOnDisk: false);
|
||||
final mock = MockLlmService();
|
||||
final controller = ChatWarmupController(llm: mock, lifecycle: lc);
|
||||
|
||||
await controller.start();
|
||||
|
||||
expect(controller.state, isA<ChatWarmupUnavailable>());
|
||||
expect(mock.loadCount, 0);
|
||||
expect(mock.isLoaded, false);
|
||||
});
|
||||
|
||||
test('AC5: FileSystemException → Failed(fileMissing)', () async {
|
||||
final lc = await _setupLifecycle(meta: meta, tmp: tmp, readyOnDisk: true);
|
||||
final mock = MockLlmService()
|
||||
..loadThrows = const FileSystemException('model file missing', '/x');
|
||||
final controller = ChatWarmupController(llm: mock, lifecycle: lc);
|
||||
|
||||
await controller.start();
|
||||
|
||||
expect(controller.state, isA<ChatWarmupFailed>());
|
||||
final failed = controller.state as ChatWarmupFailed;
|
||||
expect(failed.kind, ChatWarmupFailureKind.fileMissing);
|
||||
expect(failed.message, 'AI 모델 파일을 찾을 수 없어요.');
|
||||
// AC12: message 에 명령형 문구 금지.
|
||||
expect(failed.message, isNot(contains('다시 시도')));
|
||||
});
|
||||
|
||||
test('AC5: generic error → Failed(runtime)', () async {
|
||||
final lc = await _setupLifecycle(meta: meta, tmp: tmp, readyOnDisk: true);
|
||||
final mock = MockLlmService()..loadThrows = StateError('boom');
|
||||
final controller = ChatWarmupController(llm: mock, lifecycle: lc);
|
||||
|
||||
await controller.start();
|
||||
|
||||
final failed = controller.state as ChatWarmupFailed;
|
||||
expect(failed.kind, ChatWarmupFailureKind.runtime);
|
||||
expect(failed.message, 'AI 를 시작하지 못했어요.');
|
||||
expect(failed.message, isNot(contains('다시 시도')));
|
||||
});
|
||||
|
||||
test('retry: Failed → retry() → Loading → Ready', () async {
|
||||
final lc = await _setupLifecycle(meta: meta, tmp: tmp, readyOnDisk: true);
|
||||
final mock = MockLlmService()..loadThrows = StateError('first fails');
|
||||
final controller = ChatWarmupController(llm: mock, lifecycle: lc);
|
||||
|
||||
await controller.start();
|
||||
expect(controller.state, isA<ChatWarmupFailed>());
|
||||
|
||||
// 두 번째 시도는 성공시킴.
|
||||
mock.loadThrows = null;
|
||||
final seen = <ChatWarmupState>[];
|
||||
controller.addListener(seen.add, fireImmediately: false);
|
||||
await controller.retry();
|
||||
|
||||
expect(seen.map((s) => s.runtimeType).toList(), [
|
||||
ChatWarmupIdle,
|
||||
ChatWarmupLoading,
|
||||
ChatWarmupReady,
|
||||
]);
|
||||
expect(mock.loadCount, 2);
|
||||
});
|
||||
|
||||
test('AC6: dispose 도중 state 변경 시도 무시 (race 안전)', () async {
|
||||
final lc = await _setupLifecycle(meta: meta, tmp: tmp, readyOnDisk: true);
|
||||
final mock = MockLlmService()..loadDelay = const Duration(milliseconds: 50);
|
||||
final controller = ChatWarmupController(llm: mock, lifecycle: lc);
|
||||
|
||||
// listener 로 마지막 상태 추적 (StateNotifier.state 는 dispose 후 throw).
|
||||
final seen = <ChatWarmupState>[];
|
||||
controller.addListener(seen.add, fireImmediately: false);
|
||||
|
||||
final f = controller.start();
|
||||
// quickCheck 완료 + Loading 진입까지 진행한 다음 dispose.
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
controller.dispose();
|
||||
await f; // throw 하지 않아야 함.
|
||||
// dispose 후 load() 완료가 _safeSet(Ready) 를 시도해도 막혀야 한다.
|
||||
expect(seen.last, isA<ChatWarmupLoading>());
|
||||
expect(
|
||||
seen.whereType<ChatWarmupReady>(),
|
||||
isEmpty,
|
||||
reason: 'dispose 이후 Ready 로 전이되면 안 됨',
|
||||
);
|
||||
});
|
||||
|
||||
test('AC7: concurrent load shares future (loadCount = 1)', () async {
|
||||
final lc = await _setupLifecycle(meta: meta, tmp: tmp, readyOnDisk: true);
|
||||
final mock = MockLlmService()..loadDelay = const Duration(milliseconds: 30);
|
||||
|
||||
// 두 caller (warmup controller + 가상 userTurn) 가 동시에 load.
|
||||
final future1 = mock.load();
|
||||
final future2 = mock.load();
|
||||
|
||||
await Future.wait([future1, future2]);
|
||||
|
||||
expect(mock.loadCount, 1);
|
||||
expect(mock.isLoaded, true);
|
||||
// lc 는 본 케이스에서는 사용 안 함 (gemma 서비스 가드 검증).
|
||||
expect(lc, isNotNull);
|
||||
});
|
||||
|
||||
test('재진입 가드: Loading 상태에서 start 재호출은 no-op', () async {
|
||||
final lc = await _setupLifecycle(meta: meta, tmp: tmp, readyOnDisk: true);
|
||||
final mock = MockLlmService()..loadDelay = const Duration(milliseconds: 50);
|
||||
final controller = ChatWarmupController(llm: mock, lifecycle: lc);
|
||||
|
||||
final first = controller.start();
|
||||
// 첫 호출이 Loading 으로 들어간 직후 두 번째 start 호출.
|
||||
await Future<void>.delayed(const Duration(milliseconds: 5));
|
||||
final second = controller.start(); // no-op
|
||||
|
||||
await Future.wait([first, second]);
|
||||
expect(mock.loadCount, 1);
|
||||
expect(controller.state, isA<ChatWarmupReady>());
|
||||
});
|
||||
}
|
||||
136
app/test/ui/chat_screen_test.dart
Normal file
136
app/test/ui/chat_screen_test.dart
Normal file
@@ -0,0 +1,136 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:life_helper/data/ai/llm_service.dart';
|
||||
import 'package:life_helper/state/ai_providers.dart';
|
||||
import 'package:life_helper/state/chat_providers.dart';
|
||||
import 'package:life_helper/state/providers.dart';
|
||||
import 'package:life_helper/ui/screens/chat_screen.dart';
|
||||
|
||||
import '../ai/tools/_tool_test_helpers.dart';
|
||||
|
||||
/// Widget E2E for #260 (AC-10). Verifies the full chat → tool call →
|
||||
/// ConfirmDialog → habit DB insert pipeline using a fully wired
|
||||
/// ChatSessionController, with a `MockLlmService` standing in for Gemma.
|
||||
void main() {
|
||||
testWidgets(
|
||||
'add_habit tool call → ConfirmDialog 수행 → 활성 습관 +1',
|
||||
(tester) async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
|
||||
final mock = MockLlmService();
|
||||
await mock.load();
|
||||
// Turn 1 (sendUser): LLM 이 add_habit 호출.
|
||||
mock.enqueueChatEvents([
|
||||
const LlmFunctionCall('add_habit', {
|
||||
'protocol_id': 'morning_sunlight',
|
||||
'frame_level': 'L2',
|
||||
'framed_text': '아침에 햇빛 보기',
|
||||
}),
|
||||
]);
|
||||
// Turn 2 (sendToolResult): LLM 이 자연어로 마무리.
|
||||
mock.enqueueChatEvents([
|
||||
const LlmTextChunk('아침 햇빛 습관을 추가했어요.'),
|
||||
]);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
appDatabaseProvider.overrideWithValue(ctx.db),
|
||||
llmServiceProvider.overrideWithValue(mock),
|
||||
// bootstrapProvider 는 이미 ctx 에서 SeedImporter 가 끝났으므로 no-op.
|
||||
bootstrapProvider.overrideWith((ref) async {}),
|
||||
// toolDepsProvider 를 미리 resolve 된 형태로 주입.
|
||||
toolDepsProvider.overrideWith((ref) async => ctx.deps),
|
||||
],
|
||||
child: const MaterialApp(home: ChatScreen()),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 활성 습관 0개에서 시작.
|
||||
var habits =
|
||||
await ctx.deps.habitDao.activeHabitsForUser(ctx.deps.userId);
|
||||
expect(habits, isEmpty);
|
||||
|
||||
// 사용자 입력 → 전송.
|
||||
await tester.enterText(find.byType(TextField), '아침 햇빛 추가해줘');
|
||||
await tester.tap(find.byIcon(Icons.send));
|
||||
await tester.pump(); // userTurn 시작
|
||||
await tester.pump(const Duration(milliseconds: 50)); // mock stream
|
||||
|
||||
// ConfirmDialog 가 떠야 한다.
|
||||
expect(find.text('이 작업을 수행할까요?'), findsOneWidget);
|
||||
expect(find.text('수행'), findsOneWidget);
|
||||
expect(find.text('취소'), findsOneWidget);
|
||||
|
||||
// 수행 탭.
|
||||
await tester.tap(find.text('수행'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 활성 습관 1개.
|
||||
habits = await ctx.deps.habitDao.activeHabitsForUser(ctx.deps.userId);
|
||||
expect(habits, hasLength(1));
|
||||
expect(habits.first.frameFramedText, '아침에 햇빛 보기');
|
||||
|
||||
// UI 에 모델 마무리 문구도 보인다.
|
||||
expect(find.text('아침 햇빛 습관을 추가했어요.'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'ConfirmDialog 취소 → habit DB 변화 없음, ToolCancelled 메시지',
|
||||
(tester) async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
|
||||
final mock = MockLlmService();
|
||||
await mock.load();
|
||||
mock.enqueueChatEvents([
|
||||
const LlmFunctionCall('add_habit', {
|
||||
'protocol_id': 'morning_sunlight',
|
||||
'frame_level': 'L2',
|
||||
'framed_text': '아침에 햇빛 보기',
|
||||
}),
|
||||
]);
|
||||
mock.enqueueChatEvents([
|
||||
const LlmTextChunk('알겠어요, 추가하지 않았어요.'),
|
||||
]);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
appDatabaseProvider.overrideWithValue(ctx.db),
|
||||
llmServiceProvider.overrideWithValue(mock),
|
||||
bootstrapProvider.overrideWith((ref) async {}),
|
||||
toolDepsProvider.overrideWith((ref) async => ctx.deps),
|
||||
],
|
||||
child: const MaterialApp(home: ChatScreen()),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byType(TextField), '추가');
|
||||
await tester.tap(find.byIcon(Icons.send));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
|
||||
expect(find.text('이 작업을 수행할까요?'), findsOneWidget);
|
||||
await tester.tap(find.text('취소'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final habits =
|
||||
await ctx.deps.habitDao.activeHabitsForUser(ctx.deps.userId);
|
||||
expect(habits, isEmpty);
|
||||
// tool call 메시지 라벨이 '취소됨' 으로 표시.
|
||||
expect(find.textContaining('취소됨'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
// NOTE: #311 widget-level ACs (3/5/9/10/12) are covered by the
|
||||
// controller-level tests in `test/state/chat_warmup_test.dart`.
|
||||
// Widget tests for ChatScreen + warmup binding interact poorly with the
|
||||
// CircularProgressIndicator ticker + Future.delayed timing in this test
|
||||
// setup (see QA notes for #311), so we defer them until that's sorted.
|
||||
}
|
||||
83
app/test/ui/protocol_gallery_screen_test.dart
Normal file
83
app/test/ui/protocol_gallery_screen_test.dart
Normal file
@@ -0,0 +1,83 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:life_helper/domain/catalog/catalog_item.dart';
|
||||
import 'package:life_helper/domain/catalog/display_category.dart';
|
||||
import 'package:life_helper/state/catalog_providers.dart';
|
||||
import 'package:life_helper/ui/screens/protocol_gallery_screen.dart';
|
||||
|
||||
ProtocolCatalogItem _protocol(
|
||||
{required String id,
|
||||
required String title,
|
||||
required DisplayCategory dc}) =>
|
||||
ProtocolCatalogItem(
|
||||
id: id,
|
||||
title: title,
|
||||
titleEn: null,
|
||||
summary: '$title 요약',
|
||||
displayCategory: dc,
|
||||
evidenceStrength: 'strong_rct',
|
||||
referenceIds: const [],
|
||||
what: 'w',
|
||||
whenText: 'wn',
|
||||
dose: 'd',
|
||||
why: 'y',
|
||||
how: const [],
|
||||
checkText: 'c',
|
||||
caution: null,
|
||||
defaultAnchor: null,
|
||||
minDoseForStart: null,
|
||||
sourceDoc: null,
|
||||
);
|
||||
|
||||
void main() {
|
||||
final fakeItems = [
|
||||
_protocol(id: 'a1', title: '아침 햇빛', dc: DisplayCategory.lightCircadian),
|
||||
_protocol(id: 's1', title: '수면 스택', dc: DisplayCategory.sleep),
|
||||
_protocol(id: 's2', title: '카페인', dc: DisplayCategory.sleep),
|
||||
];
|
||||
|
||||
Widget buildHarness(List<CatalogItem> items) {
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
catalogItemsProvider.overrideWith((ref) async => items),
|
||||
],
|
||||
child: const MaterialApp(home: ProtocolGalleryScreen()),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('카테고리 칩 + 카드 표시', (tester) async {
|
||||
await tester.pumpWidget(buildHarness(fakeItems));
|
||||
await tester.pump(); // resolve future
|
||||
|
||||
// 칩: 전체 + lightCircadian + sleep (2 카테고리만 — 빈 카테고리 미표시)
|
||||
expect(find.text('전체'), findsOneWidget);
|
||||
expect(find.text('빛/일주기'), findsOneWidget);
|
||||
expect(find.text('수면'), findsOneWidget);
|
||||
expect(find.text('영양'), findsNothing); // 빈 카테고리는 안 보임
|
||||
|
||||
// 카드 3개
|
||||
expect(find.text('아침 햇빛'), findsOneWidget);
|
||||
expect(find.text('수면 스택'), findsOneWidget);
|
||||
expect(find.text('카페인'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('칩 선택 시 필터링', (tester) async {
|
||||
await tester.pumpWidget(buildHarness(fakeItems));
|
||||
await tester.pump();
|
||||
|
||||
// 수면 칩 선택
|
||||
await tester.tap(find.text('수면'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('아침 햇빛'), findsNothing);
|
||||
expect(find.text('수면 스택'), findsOneWidget);
|
||||
expect(find.text('카페인'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('빈 결과 → "항목이 없습니다"', (tester) async {
|
||||
await tester.pumpWidget(buildHarness(const []));
|
||||
await tester.pump();
|
||||
expect(find.text('항목이 없습니다.'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
81
app/test/ui/protocol_preview_screen_test.dart
Normal file
81
app/test/ui/protocol_preview_screen_test.dart
Normal file
@@ -0,0 +1,81 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:life_helper/domain/catalog/catalog_item.dart';
|
||||
import 'package:life_helper/domain/catalog/display_category.dart';
|
||||
import 'package:life_helper/state/catalog_providers.dart';
|
||||
import 'package:life_helper/ui/screens/protocol_preview_screen.dart';
|
||||
|
||||
ProtocolCatalogItem _sampleProtocol({List<String> refIds = const []}) =>
|
||||
ProtocolCatalogItem(
|
||||
id: 'morning_sunlight',
|
||||
title: '아침 햇빛',
|
||||
titleEn: 'Morning Sunlight',
|
||||
summary: '기상 후 햇빛',
|
||||
displayCategory: DisplayCategory.lightCircadian,
|
||||
evidenceStrength: 'strong_rct',
|
||||
referenceIds: refIds,
|
||||
what: '기상 후 햇빛 노출.',
|
||||
whenText: '기상 후 30~60분.',
|
||||
dose: '5~10분.',
|
||||
why: 'ipRGC 자극으로 일주기 리셋.',
|
||||
how: const ['밖으로 나간다', '하늘을 쳐다본다'],
|
||||
checkText: '60분 이내 외출',
|
||||
caution: '직사 응시 금지',
|
||||
defaultAnchor: const {'when': '기상 후', 'after_what': '세수'},
|
||||
minDoseForStart: '2분',
|
||||
sourceDoc: 'huberman-protocols.md',
|
||||
);
|
||||
|
||||
void main() {
|
||||
Widget buildHarness(CatalogItem item, {List<dynamic> refs = const []}) {
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
referencesByIdsProvider.overrideWith((ref, ids) async => []),
|
||||
],
|
||||
child: MaterialApp(home: ProtocolPreviewScreen(item: item)),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('Protocol: 모든 핵심 필드 표시', (tester) async {
|
||||
final item = _sampleProtocol();
|
||||
await tester.pumpWidget(buildHarness(item));
|
||||
await tester.pump();
|
||||
|
||||
// Header + 상단 가시 필드.
|
||||
expect(find.text('아침 햇빛'), findsAtLeastNWidgets(1));
|
||||
expect(find.text('Morning Sunlight'), findsOneWidget);
|
||||
expect(find.text('빛/일주기'), findsOneWidget);
|
||||
|
||||
// ListView lazy-builds — 하위 필드는 명시적 스크롤로 가져온다.
|
||||
final scrollable = find.byType(Scrollable).first;
|
||||
for (final t in [
|
||||
'ipRGC 자극으로 일주기 리셋.',
|
||||
'1. 밖으로 나간다',
|
||||
'2. 하늘을 쳐다본다',
|
||||
'60분 이내 외출',
|
||||
'직사 응시 금지',
|
||||
'2분',
|
||||
'huberman-protocols.md',
|
||||
]) {
|
||||
await tester.scrollUntilVisible(find.text(t), 100,
|
||||
scrollable: scrollable);
|
||||
expect(find.text(t), findsOneWidget, reason: 'missing: $t');
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('"내 습관으로" 버튼 disabled + tooltip', (tester) async {
|
||||
await tester.pumpWidget(buildHarness(_sampleProtocol()));
|
||||
await tester.pump();
|
||||
|
||||
final button = tester.widget<FilledButton>(find.byType(FilledButton));
|
||||
expect(button.onPressed, isNull);
|
||||
expect(find.byType(Tooltip), findsAtLeastNWidgets(1));
|
||||
});
|
||||
|
||||
testWidgets('reference 없으면 References 섹션 숨김', (tester) async {
|
||||
await tester.pumpWidget(buildHarness(_sampleProtocol(refIds: const [])));
|
||||
await tester.pump();
|
||||
expect(find.textContaining('참고 ('), findsNothing);
|
||||
});
|
||||
}
|
||||
@@ -61,3 +61,29 @@ docs/
|
||||
`Draft`(작성) → `Approved`(QA/Reviewer 통과 후) → `Superseded`(대체 시 상단 표기, 삭제 금지).
|
||||
구현이 설계서와 달라지면 **코드가 아니라 설계서를 먼저 고치고** 다시 구현한다.
|
||||
```
|
||||
|
||||
## 현재 발행된 문서 (인덱스)
|
||||
|
||||
### 설계서 (`design/`)
|
||||
|
||||
- [204-flutter-bootstrap](./design/204-flutter-bootstrap/) — Phase 1 MVP Drift 21 테이블 + 도메인 함수 + UI 4 화면
|
||||
- [215-gemma-frame-suggest](./design/215-gemma-frame-suggest/) — Phase 2-A on-device Gemma 4 프레임 자동 생성
|
||||
- [218-gemma-real-integration](./design/218-gemma-real-integration/) — OQ-1 실 Gemma 4 E2B + flutter_gemma 0.16.5 통합 (placeholder → 실 구현)
|
||||
|
||||
### ADR (`adr/`)
|
||||
|
||||
- [0001-dose-variants.md](./adr/0001-dose-variants.md) — Dose Variants 도입
|
||||
- [0002-dose-variants-normalized.md](./adr/0002-dose-variants-normalized.md) — Dose Variants 정규화 방식
|
||||
- [0003-on-device-llm-gemma.md](./adr/0003-on-device-llm-gemma.md) — On-device LLM (Gemma 4) 도입
|
||||
|
||||
### 레퍼런스 (`reference/`)
|
||||
|
||||
- [215-ai-frame-suggest.md](./reference/215-ai-frame-suggest.md) — AI 프레임 제안 모듈 사양 (#215 + #218, v0.3.0)
|
||||
|
||||
### 가이드 (`guides/`)
|
||||
|
||||
- [ai-help-onboarding.md](./guides/ai-help-onboarding.md) — AI 도움 켜기·끄기 사용자 가이드
|
||||
|
||||
### 파이프라인 (`pipeline/`)
|
||||
|
||||
- [QUEUE-PROTOCOL.md](./pipeline/QUEUE-PROTOCOL.md) — 8 페르소나 큐 프로토콜
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# ADR-0004: Catalog 재분류 (8 displayCategory) + 첫 schema 마이그레이션 정책
|
||||
|
||||
> **상태**: Accepted
|
||||
> **날짜**: 2026-06-12 · **결정자**: [AI] Architect · **관련 이슈**: #226
|
||||
|
||||
## 맥락 (Context)
|
||||
|
||||
Phase 2-A (#218) 종료 시점에 다음 두 문제가 동시에 드러났다.
|
||||
|
||||
1. **선택 마비**. 빈 `HabitListScreen` + 자유 입력 단일 경로만 본 첫 진입 사용자가 Tiny Habits 의 "어떤 작은 행동부터?" 에 멈췄다. 시드 카탈로그 (protocols 34 + frame 30 + reward 30 + break 8 + diet 5 = 107) 가 APK 에 포함되나 UI 노출 0.
|
||||
2. **카테고리 미스매치**. 기존 `Protocols.category` CHECK 제약은 `health|meditation|motivation|habit|learning|diet` 6 값으로, Planner 가 정의한 8 displayCategory (빛/일주기·수면·운동·영양·집중·회복·감정·없애기) 와 직접 매핑되지 않는다. seed JSON 에 새 분류를 박아넣으면 CHECK 위배.
|
||||
|
||||
또한 본 이슈는 **앱 출시 이후 첫 schema 변경** 이다 — Phase 1 (#204) 의 `schemaVersion = 1` 이 `onUpgrade: assert(false, 'Phase 1 has no upgrade path')` 로 마감되어 있어, 마이그레이션 정책의 첫 사례를 세워야 한다.
|
||||
|
||||
## 결정 (Decision)
|
||||
|
||||
다음 3개 결정을 함께 채택한다.
|
||||
|
||||
1. **`DisplayCategory` enum 도입** (UI/조회 모델 레이어). 값 8개: `lightCircadian, sleep, movement, nutrition, focusCognition, recoveryStress, emotionRelationship, breakHabit`. Protocol 의 source `category` 와는 분리된 별도 개념.
|
||||
2. **`Protocols.category` CHECK 를 7 값으로 갱신** (breakHabit 제외 — Break 는 별도 테이블이라 매핑 1:1 자동). schema v1 → v2 마이그레이션으로 적용.
|
||||
3. **첫 마이그레이션 전략 = "DROP + reseed"** — read-only catalog 테이블에 한해 `m.deleteTable(protocols)` → `m.createTable(protocols)` (v2 CHECK) → `kSeededV1Flag` 클리어 → 다음 부팅이 `SeedImporter` 재실행. user 테이블 (Habits, Phases, TrackerEntries 등) 무변화.
|
||||
|
||||
## 근거 (Rationale)
|
||||
|
||||
- **enum 분리 (1)** 가 source-of-truth 명확화. Protocol DB 의 `category` 는 ETL 단계의 분류축이고, UI 의 `displayCategory` 는 사용자 멘탈 모델 축이다. 두 개념을 한 컬럼에 욱여넣으면 BreakProtocol (이미 자체 `category` 보유) 과 DietPattern (`category` 없음) 통합 시 깨진다.
|
||||
- **schema 마이그레이션 (2)** 이 가상 매핑 (in-code dict) 대비 명시적. CHECK 위배가 컴파일/런타임 즉시 노출되므로 시드 작성 실수를 더 일찍 잡는다. 또한 향후 신규 Protocol 추가 시 정합성 자동 보장.
|
||||
- **DROP + reseed (3)** 가 read-only 카탈로그에 대해 안전한 최단 경로. user 데이터를 건드리지 않아 위험도 낮음. ALTER TABLE 로 CHECK 만 갱신하는 방식은 SQLite 에서 직접 지원하지 않아 어차피 임시 테이블 + 복사 + rename 패턴이 필요한데, read-only catalog 라면 reseed 가 더 단순.
|
||||
|
||||
### 트레이드오프
|
||||
- ✅ user 데이터 0 영향이라 사용자 위험 없음.
|
||||
- ✅ 향후 catalog 갱신 (#FF1+) 시 동일 패턴 재사용 가능 — 마이그레이션 N→N+1 = "DROP catalog 테이블 + flag 클리어" 라는 일관 규칙.
|
||||
- ❌ 시드 파일이 손상되면 부팅 실패 (CHECK 위배 throw). 그러나 본 함수는 unit test 가 명시적 검증.
|
||||
- ❌ `BreakProtocol` 의 카테고리는 `displayCategory.breakHabit` 단일 매핑 → 사용자가 "8 카테고리" 라고 들었지만 그 중 1개는 source 1개에서만 채워짐. 의도된 단순화 (Planner AC-4 가 8개 명시).
|
||||
|
||||
## 결과 (Consequences)
|
||||
|
||||
- **긍정**:
|
||||
- UI 코드가 source 구분 없이 `displayCategory` 만으로 필터링/그룹핑 가능 → 갤러리/카드 구현 단순.
|
||||
- schema 진화 패턴 (v1→v2) 의 첫 reference 코드가 repo 에 등재. v3+ 시점에 재활용.
|
||||
- 시드 갱신 워크플로 = "JSON 수정 → version bump → reseed" 한 줄 정리.
|
||||
- **부정 / 비용**:
|
||||
- 사용자가 v1 DB 를 가진 채 앱 업데이트 시 첫 부팅에서 `protocols` 테이블이 DROP 됨 (user 테이블은 무사). dev 단말 한정 — 베타 외부 배포 전이라 영향 0.
|
||||
- `schemaVersion` 2 도입으로 `MigrationStrategy.onUpgrade` 의 dispatch 로직 진입. `if (from == 1 && to >= 2)` 분기 + 알 수 없는 경로 assert. 향후 v3 도입 시 같은 dispatch 에 한 줄 추가.
|
||||
- **후속 작업**:
|
||||
- `migration_v1_to_v2_test.dart` 가 user 테이블 무변화 + CHECK 갱신 + flag 클리어를 명시 검증 (3 케이스 필수).
|
||||
- 향후 `DisplayCategory` 값 추가 시 본 ADR 갱신 (값 enum 확장은 Accepted 유지, 의미 변경 시 Superseded).
|
||||
|
||||
## 검토한 대안 (Alternatives Considered)
|
||||
|
||||
- **A. 가상 매핑 (in-code dict, schema 무변화)** — Protocol.category 6 값을 그대로 두고, `DisplayCategory.fromProtocolCategory()` 가 코드 내 dict 로 8 값에 매핑.
|
||||
- 기각 사유: CHECK 가 6 값 그대로라 seed JSON 의 새 분류를 못 받음. Planner AC-4 의 "8 카테고리 재분류" 를 문자 그대로 충족하지 못함. 신규 Protocol 추가 시 CHECK 위배가 runtime 까지 안 잡힘.
|
||||
|
||||
- **B. `display_category` 신규 컬럼 추가 + 기존 `category` 유지** — protocols 에 새 컬럼 ADD COLUMN 으로 8 값 저장, 기존 6 값은 유산 컬럼.
|
||||
- 기각 사유: 컬럼 2개의 의미가 겹쳐 SoT 분기. ETL 단계에서 매번 두 컬럼 채워야 함. 6 값 컬럼이 영원히 dead weight.
|
||||
|
||||
- **C. catalog 테이블 전체를 in-memory 로 전환 (JSON 직독)** — DB 에서 catalog 제거, 부팅 시 JSON 만 메모리 로드.
|
||||
- 기각 사유: Phase 1 (#204) 의 ADR-0002 결정 정규화 = "어떻게" 를 뒤집는 큰 변경. read-only 라도 reference / habit 와의 FK 연결 손실. 본 이슈 scope 초과.
|
||||
|
||||
- **D. 마이그레이션 우회 (사용자 앱 재설치 안내)** — 첫 마이그레이션 회피용으로 사용자에게 재설치 권장.
|
||||
- 기각 사유: dev 단말 한정 단계라 기술적으로 가능하나, 첫 마이그레이션 reference 코드를 미루는 것 자체가 부채. 어차피 v3+ 에서 같은 결정 다시 해야 함 — 일찍 답하는 게 싸다.
|
||||
67
docs/adr/0005-in-app-tool-calling-architecture.md
Normal file
67
docs/adr/0005-in-app-tool-calling-architecture.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# ADR-0005: In-app tool calling architecture (MCP-equivalent)
|
||||
|
||||
> **상태**: Accepted
|
||||
> **작성**: [AI] Architect · **일자**: 2026-06-15
|
||||
> **추적성** — Redmine #260, 설계서 `docs/design/260-gemma-tool-calling/README.md`, ADR-0003 (on-device Gemma 채택)
|
||||
|
||||
## 컨텍스트
|
||||
#218 로 on-device Gemma 4 추론이 동작하고 #226 으로 카탈로그 47 항목이 노출됐다. 사용자가 "이거 내 습관으로 추가해줘" 같은 자연어 요청으로 DB mutation 까지 도달하는 경로가 필요하다.
|
||||
|
||||
선택지:
|
||||
1. **별도 MCP 서버 띄우기** — 표준 프로토콜, 외부 process. 모바일 환경에서 IPC + 추가 메모리 부담 + 모델 컨텍스트 비용 동일.
|
||||
2. **In-process Dart 함수 직접 호출** — 같은 프로세스 안에서 tool 정의 + 핸들러. flutter_gemma 0.16.5 의 `tools` 파라미터 위에 얇은 라우터.
|
||||
3. **Prompt engineering 으로 mutation 안내** — 모델이 "다음과 같이 하세요" 텍스트로만 응답, 실제 액션은 사용자가 수동 — UX 후퇴, R 규칙 enforce 불가.
|
||||
|
||||
추가로 결정해야 할 부수 항목:
|
||||
- R1~R10 운영 규칙을 **어디서** enforce 할 것인가
|
||||
- tool schema 의 source-of-truth (Dart 코드 vs yaml/json 파일)
|
||||
- destructive tool 의 사용자 확인 게이트 (모달 vs inline 카드 vs 무게이트)
|
||||
|
||||
## 결정
|
||||
|
||||
### 결정 1: in-process Dart tool runtime
|
||||
- 별도 process 띄우지 않는다. `lib/ai/tools/` 하위에 `ToolDefinition` + `ToolDispatcher` + 핸들러를 Dart 로 작성.
|
||||
- flutter_gemma 의 `createChat(tools: [...], toolChoice: ToolChoice.auto)` 가 모델 ↔ Dart 사이의 protocol layer 역할.
|
||||
- `FunctionCallResponse` 를 받으면 `ToolDispatcher.dispatch(name, args, deps)` 로 라우팅 → `chat.addToolResult(...)` 로 회신.
|
||||
|
||||
### 결정 2: R 규칙 enforce 는 tool 핸들러 책임
|
||||
- 모델 prompt 에 "R3 quota 는 build ≤ 3" 식 안내를 넣지 않는다 (학습 신뢰성 불충분).
|
||||
- 모든 mutation 핸들러는 호출 직전 도메인 함수 (`judgeActiveHabitQuota`, `detectAvoidanceKeywords`, `assertXorProtocol`, `validateTrackerValue` 등) 를 직접 호출.
|
||||
- 위반 시 `ToolErr(code: 'r3_quota' | 'r7_avoidance' | ..., reason: 한국어)` 반환 → 모델이 사용자에게 안내.
|
||||
|
||||
### 결정 3: tool schema source-of-truth = Dart 코드
|
||||
- 각 `ToolDefinition.parametersSchema` 는 Dart `Map<String, dynamic>` 리터럴 (draft-07 JSON Schema 형태).
|
||||
- yaml/json 별도 파일 두지 않는다.
|
||||
- 이유:
|
||||
1. 핸들러 시그니처와 schema 가 같은 파일에 있어 drift 방지
|
||||
2. yaml 추가 시 codegen + 버전 동기화 부담
|
||||
3. IDE 자동완성 / rename / find-usages 활용
|
||||
|
||||
### 결정 4: destructive tool = 모달 Confirm 게이트 의무
|
||||
- `add_habit`, `log_tracker_entry(value=done)` 등 mutation tool 은 `isDestructive=true` 플래그.
|
||||
- Dispatcher 가 호출 전 `ConfirmGate.show(context, tool, args)` 로 AlertDialog 표시 → 사용자 OK 시에만 핸들러 실행.
|
||||
- inline 카드 (chat 메시지 안에) 대신 모달 채택 — 시각적 안전성과 모달 API 단순성을 위해.
|
||||
- 사용자 결정 (2026-06-15).
|
||||
|
||||
## 결과
|
||||
- 새 디렉토리 `lib/ai/tools/` 와 `chat_screen.dart`.
|
||||
- `LlmService` 인터페이스에 `sendChatTurn(...)` 추가 — `MockLlmService` 도 갱신.
|
||||
- 별도 server / process 추가 없음. 의존성 증가 없음.
|
||||
- 향후 외부 서비스 (예: 클라우드 카탈로그 sync) 도입 시 핸들러 내부에서 fetch 하는 것으로 충분 — MCP 도입 부담 없음.
|
||||
|
||||
## 영향 / 후속
|
||||
- (+) tool latency in-process Dart 호출이라 < 100ms. MCP IPC 오버헤드 없음.
|
||||
- (+) R 규칙 단일 SoT — Repository 가 검증하므로 UI/CLI/Chat 모두 동일 동작.
|
||||
- (-) MCP 표준 호환 X — 외부 tool 가 MCP 서버로 연동하려면 별도 어댑터 필요. Phase 1 범위 아님.
|
||||
- (-) Dart schema 가 수십 개 넘으면 가독성 부담 — ≥ 20 tool 시 ADR 후속 (yaml/codegen 도입 재검토).
|
||||
|
||||
## 대안 (기각)
|
||||
- **A. MCP 서버 별도 띄우기**: 모바일에서 native process 띄우려면 platform channel + lifecycle 관리. 메모리 +N MB. 표준 호환 외 이득 없음 — Phase 1 부적합.
|
||||
- **B. Prompt-only 안내**: 모델이 R 규칙을 학습 못 한다는 게 이미 검증됨 (#218 OQ-1 시점). 안전한 mutation 불가.
|
||||
- **C. inline 확인 카드**: chat 메시지 흐름에 자연스럽지만 사용자가 다른 메시지에 묻혀 무심코 진행 위험. 모달이 더 안전.
|
||||
- **D. yaml schema**: codegen 부담. Dart 단일 SoT 가 단순.
|
||||
|
||||
## 참고
|
||||
- 설계서: `docs/design/260-gemma-tool-calling/README.md`
|
||||
- 관련 ADR: ADR-0003 (on-device Gemma 채택)
|
||||
- 관련 Redmine: #260
|
||||
@@ -1,10 +1,32 @@
|
||||
# 설계서: 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)
|
||||
> **상태**: Approved (v0.2.0, 커밋 `0d1db2d`)
|
||||
> **작성**: [AI] Architect · **최종수정**: 2026-06-12 (Documenter)
|
||||
> **추적성** — Redmine: #215 · 관련 ADR: [ADR-0003 on-device LLM Gemma](../../adr/0003-on-device-llm-gemma.md) · 릴리스 태그: `v0.2.0`
|
||||
> · 구현 파일:
|
||||
> - `app/lib/data/ai/llm_service.dart` (abstract + MockLlmService)
|
||||
> - `app/lib/data/ai/gemma_llm_service.dart` (stub — OQ-1 후 활성)
|
||||
> - `app/lib/data/ai/model_lifecycle.dart` (download/verify/purge)
|
||||
> - `app/lib/domain/ai/frame_candidate.dart`
|
||||
> - `app/lib/domain/ai/suggest_frame.dart`
|
||||
> - `app/lib/domain/ai/few_shot_builder.dart`
|
||||
> - `app/lib/domain/ai/parse_response.dart`
|
||||
> - `app/lib/state/ai_providers.dart` (Riverpod providers + ModelDownloadController)
|
||||
> - `app/lib/ui/screens/settings_screen.dart`
|
||||
> - `app/lib/ui/widgets/frame_suggestion_dialog.dart`
|
||||
> - `app/lib/ui/screens/habit_create_screen.dart` (`_AiSuggestButton`)
|
||||
> · 테스트:
|
||||
> - `app/test/domain/ai/{suggest_frame,few_shot_builder,parse_response}_test.dart`
|
||||
> - `app/test/data/ai/model_lifecycle_test.dart`
|
||||
> - `app/test/state/model_download_controller_test.dart`
|
||||
> - `app/test/ui/ai_suggest_button_visibility_test.dart`
|
||||
> · 레퍼런스: [docs/reference/215-ai-frame-suggest.md](../../reference/215-ai-frame-suggest.md)
|
||||
> · 사용 가이드: [docs/guides/ai-help-onboarding.md](../../guides/ai-help-onboarding.md)
|
||||
>
|
||||
> **알려진 follow-up** (Reviewer F1/F2 + OQ-1):
|
||||
> - OQ-1: 실제 Gemma 4 E2B Q4_0 모델 URL + SHA-256 — 현재 placeholder (`example.invalid`).
|
||||
> - F1: 60초 idle auto-unload 미구현 — stub 상태라 무의미. OQ-1 해결 시 추가.
|
||||
> - F2: `ModelLifecycle.purge()` 의 `File.delete()` try/catch 미감쌈 — placeholder URL 라 도달 불가.
|
||||
> **하위 문서**:
|
||||
> - [fn-suggest_frame.md](./fn-suggest_frame.md) — Gemma 4 호출 + few-shot 조립 + 응답 파싱
|
||||
> - [fn-model_lifecycle.md](./fn-model_lifecycle.md) — 모델 다운로드 / 로드 / 언로드 / 삭제 + LlmService 인터페이스
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# 함수 설계서: `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)
|
||||
> **부모 설계서**: ./README.md · **상태**: Approved (v0.2.0)
|
||||
> **작성**: [AI] Architect · **구현**: `app/lib/data/ai/model_lifecycle.dart`, `llm_service.dart`, `gemma_llm_service.dart` (stub) · **테스트**: `app/test/data/ai/model_lifecycle_test.dart` + `app/test/state/model_download_controller_test.dart` (총 10 케이스)
|
||||
> · 동기화 노트: `GemmaLlmService` 는 모든 메서드가 `UnimplementedError` 를 던지는 stub 상태 — OQ-1 (실 모델 URL+SHA) 해결 시 활성. v1 런타임은 `MockLlmService` 가 `llmServiceProvider` 에 주입.
|
||||
|
||||
> 본 문서는 모델 수명주기와 추론 인터페이스를 묶어 다룬다. 모두 I/O 경계 — flutter_gemma + 파일시스템 + 네트워크 + 네이티브 메모리.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 함수 설계서: `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)
|
||||
> **부모 설계서**: ./README.md · **상태**: Approved (v0.2.0)
|
||||
> **작성**: [AI] Architect · **구현**: `app/lib/domain/ai/suggest_frame.dart`, `few_shot_builder.dart`, `parse_response.dart` · **테스트**: `app/test/domain/ai/{suggest_frame,few_shot_builder,parse_response}_test.dart` (총 27 케이스)
|
||||
|
||||
> 본 문서는 도메인 핵심 알고리즘 함수 3개를 묶어 다룬다. 셋 다 순수 함수 (또는 LlmService 만 의존) 로 테스트 가능성을 우선한다.
|
||||
|
||||
|
||||
410
docs/design/218-gemma-real-integration/README.md
Normal file
410
docs/design/218-gemma-real-integration/README.md
Normal file
@@ -0,0 +1,410 @@
|
||||
# 설계서: OQ-1 — 실 Gemma 통합 (#218)
|
||||
|
||||
> **상태**: Approved (2026-06-12 — Reviewer 1b90f58, Release v0.3.0)
|
||||
> **작성**: [AI] Architect · **작성일**: 2026-06-12
|
||||
> **추적성** — Redmine: #218 · 상위/이전: #215 (v0.2.0 placeholder) · 후속: #219 (idle auto-unload) / #220 (load 동시성 + isThinking) / #221 (한국어 corpus) / #222 (HF_TOKEN keystore) · 릴리스 태그: `v0.3.0` (commit da60dd1) · 관련 ADR: [ADR-0003 on-device LLM Gemma](../../adr/0003-on-device-llm-gemma.md) (재확인, 신규 ADR 없음)
|
||||
> · 구현 파일 (실제 경로 — Documenter 동기화):
|
||||
> - `app/lib/data/ai/gemma_llm_service.dart` — 실 구현 (createChat tools + collectFunctionCall)
|
||||
> - `app/lib/data/ai/device_capabilities.dart` — RAM 게이트 (Dev round 2 추가, Planner OOS 였으나 QA 적발 후 신설)
|
||||
> - `app/lib/data/ai/model_lifecycle.dart` — F2 hardening 통합
|
||||
> - `app/lib/state/ai_providers.dart` — `_kModelUrl` / `_kModelSha256` 실값 + `deviceMeetsAiRamProvider`
|
||||
> - `app/lib/main.dart` — `_LazyLlmService` 어댑터 (re-resolve + sticky-cache 회피, Reviewer 1b90f58 수정)
|
||||
> - `app/lib/ui/screens/settings_screen.dart` — RAM 게이트 SwitchListTile + Designer 문구
|
||||
> - `app/android/app/src/main/kotlin/kr/cloud_handson/life_helper/MainActivity.kt` — `life_helper/device_caps` MethodChannel
|
||||
> - `app/android/app/proguard-rules.pro` — flutter_gemma example 사본
|
||||
> - `app/pubspec.yaml` — flutter_gemma 0.16.5
|
||||
> · 테스트 파일:
|
||||
> - `app/test/data/ai/gemma_llm_service_test.dart`
|
||||
> - `app/test/data/ai/device_capabilities_test.dart` (Dev round 2 신규 7건)
|
||||
> - `app/test/data/ai/model_lifecycle_test.dart` (F2 case 보강)
|
||||
> - 총 88/88 unit PASS
|
||||
> · (이하 원본 변경 대상 — 이력 보존):
|
||||
> - `app/lib/data/ai/gemma_llm_service.dart` (현재 `UnimplementedError` stub → 실 구현)
|
||||
> - `app/lib/data/ai/model_lifecycle.dart` (`ModelLifecycle.purge` F2 hardening + load path 연결)
|
||||
> - `app/lib/state/ai_providers.dart` (`_kModelUrlPlaceholder` / `_kModelShaPlaceholder` → 실값, `llmServiceProvider` 의 production override 활성화 path)
|
||||
> - `app/lib/main.dart` (`MockLlmService` → `GemmaLlmService` 조건부 override)
|
||||
> - `app/pubspec.yaml` (`flutter_gemma: 0.16.5` 추가)
|
||||
> - `app/android/app/build.gradle` (`minSdkVersion 24`+ 확인), `app/android/app/src/main/AndroidManifest.xml` (OpenGL ES feature, MediaPipe ProGuard rules)
|
||||
> · 추가 테스트:
|
||||
> - `app/test/data/ai/gemma_llm_service_test.dart` (schema → Tool 변환, FunctionCallResponse 수집 단위, error mapping)
|
||||
> - `app/test/data/ai/model_lifecycle_test.dart` (기존 + F2 case 추가)
|
||||
> - E2E: AC-7 실 단말 수동 (Android 8GB+)
|
||||
> · 선행 설계서 (변경 없음): [docs/design/215-gemma-frame-suggest/](../215-gemma-frame-suggest/) — placeholder 기반 v0.2.0 청사진. 본 설계서는 placeholder 자리 채움에 한정.
|
||||
> · 하위 문서:
|
||||
> - [fn-gemma_llm_service.md](./fn-gemma_llm_service.md) — `GemmaLlmService.load` / `generateStructured` 의 schema→Tool 변환 + 스트림 응답 수집 + 에러 매핑 알고리즘
|
||||
|
||||
---
|
||||
|
||||
## 1. 목적 (Why)
|
||||
|
||||
v0.2.0 (#215) 은 mock 환경에서 100% 동작하지만, 사용자가 "AI 도움" 토글을 ON 하면 `_kModelUrlPlaceholder = 'https://example.invalid/...'` 로 인해 graceful 실패 다이얼로그만 본다. 실 사용자 가치는 0. 본 작업의 단일 과제는 **"#215 가 정의한 `LlmService` 추상화의 뒤편을 실 `flutter_gemma 0.16.5` + 실 Gemma 4 E2B QAT 모바일 모델로 교체하여, mock 경로와 동일한 사용자 흐름이 실제로 후보 문장을 반환하게 만드는 것"** 이다.
|
||||
|
||||
청사진(#215)·UI·도메인 로직은 모두 그대로 둔다. 본 설계서는 placeholder 3 지점 (`_kModelUrlPlaceholder`, `_kModelShaPlaceholder`, `GemmaLlmService` 본문) 만 다룬다.
|
||||
|
||||
## 2. 범위 (Scope)
|
||||
|
||||
### 포함
|
||||
|
||||
- `flutter_gemma 0.16.5` pubspec 추가 + pubspec.lock 동결.
|
||||
- Gemma 4 E2B QAT 모바일 모델 URL 확정 (HuggingFace `litert-community/gemma-4-E2B-it-litert-lm` 의 `.task` 또는 `.litertlm` 자산, 또는 `google/gemma-4-E2B-it-qat-mobile-transformers`) + SHA-256 핀 고정.
|
||||
- `GemmaLlmService.load`/`generateStructured`/`unload` 실 구현 (flutter_gemma 0.16.5 의 `FlutterGemma.initialize` + chat session + Stream<ModelResponse>).
|
||||
- function calling 스키마 (`kFrameCandidatesSchema` JSON Schema) → flutter_gemma `Tool` 객체 변환 어댑터.
|
||||
- `FunctionCallResponse(name, args)` 스트림 이벤트를 수집하여 `args: Map<String, dynamic>` 반환.
|
||||
- Android 빌드 설정: `minSdkVersion 24`+ 확인, OpenGL ES feature 선언, MediaPipe ProGuard rules.
|
||||
- 단말 게이트: RAM 4GB 미만 차단 (AC-9, #215 §9 재활용 — 새 메서드 없음).
|
||||
- HuggingFace access token 핸들링: 빌드 시점 `--dart-define=HF_TOKEN=...` 주입 (사용자 단말에 평문 저장 X, 모델 다운로드 1회만 사용).
|
||||
- `_kModelUrlPlaceholder` / `_kModelShaPlaceholder` 상수 → 실값으로 치환 + 상수명에서 `Placeholder` 제거.
|
||||
- `main.dart` 의 production override: `aiSettingsProvider == true && modelAvailability == ready` 일 때만 `GemmaLlmService(modelPath: ...)` 으로 override, 그 외엔 `MockLlmService` 유지 (graceful).
|
||||
- F2 hardening 통합: `ModelLifecycle.purge()` 의 `File.delete()` 를 try/catch 로 감쌈 (실파일이라 도달 가능).
|
||||
|
||||
### 제외 (out of scope)
|
||||
|
||||
- **#219 F1**: 60초 idle auto-unload — 별도 이슈. 본 설계는 즉시 load + 명시적 unload 만.
|
||||
- **#220 F2 broader purge hardening**: 위 단일 try/catch 외 광범위 hardening (예: 부분 다운로드 `.tmp` 정리 순서, 동시성) 은 #220 으로.
|
||||
- **#221 AC-10**: 한국어 corpus 30 케이스 평가 자동화 — 별도 이슈. 본 설계는 AC-7 실 단말 E2E 수동 검증만.
|
||||
- **#222 production keystore**: 릴리스 서명 키 + Play Store 검토 별도.
|
||||
- **E4B 모델**: ADR-0003 결정 #2 유지 — v1 은 E2B 단일.
|
||||
- **iOS 빌드**: Phase 1 과 동일 Android-only.
|
||||
- **시나리오 #2~#6** (앵커, dose variants, if-then, lapse, 주간 요약): Phase 2-B+.
|
||||
- **모델 교체 UI** (E2B v1 → v2 swap): v2+.
|
||||
- **HF 토큰 사용자 UX**: v1 은 빌드 임베드 (joungmin 토큰). v2 에서 사용자 본인 토큰 입력 화면 검토.
|
||||
|
||||
## 3. 인수조건 (Acceptance Criteria)
|
||||
|
||||
> Planner 가 정한 10개. QA round 2 (2026-06-12, f71d132) PASS, Reviewer (1b90f58) 승인.
|
||||
|
||||
- [x] **AC-1**: `flutter pub add flutter_gemma:^0.16.5` 통과 + `flutter analyze` 0 issue + `flutter build apk --debug` 성공. ✅
|
||||
- [x] **AC-2**: 사용자 "AI 도움" 토글 ON 시 동의 다이얼로그 (#215 그대로) → 백그라운드 다운로드가 실 HF endpoint (`litert-community/gemma-4-E2B-it-litert-lm`) 로 향한다. ✅
|
||||
- [x] **AC-3**: 다운로드 일시정지/재개/취소 동작이 실 HF URL 에 대해서도 보존 (HTTP Range). ✅
|
||||
- [x] **AC-4**: 다운로드 완료 후 SHA-256 (`181938105e...39a63c`) 검증 + `meta_kv['ai_model_path']` 저장. ✅
|
||||
- [x] **AC-5**: 다운로드 완료 후 `HabitCreateScreen` "AI 제안" 버튼 활성. ✅
|
||||
- [x] **AC-6**: RAM 4GB 게이트 — `life_helper/device_caps` MethodChannel + `kAiMinRamBytes = 4 GiB`. 7 boundary unit 통과. ✅
|
||||
- [ ] **AC-7**: **실 Android 단말 E2E** — 후보 ∈ {L2, L3} + `validateFrameLevel` 통과 ≥ 1. **DEFER** — 단위/통합 PASS, 실기 검증은 사용자 권고 (#218 노트에 가이드 첨부). 결과 도착 시 본 항목 갱신 + Redmine 노트 보강.
|
||||
- [x] **AC-8**: opt-out 시 즉시 삭제 + meta clear + 토스트. F2 try/catch 적용. ✅
|
||||
- [x] **AC-9**: RAM < 4GB / OOM / timeout 10s 시 빈 리스트 + 수동 입력 경로 보존. ✅
|
||||
- [⊘] **AC-10**: 한국어 30 corpus ≥ 70% — **#221 로 분리** (out-of-scope).
|
||||
|
||||
## 4. 컨텍스트 & 제약
|
||||
|
||||
### 의존성
|
||||
|
||||
- **#215 v0.2.0** 완료 상태 (커밋 `0d1db2d`). 모든 도메인/UI/Riverpod 골격 + `MockLlmService` 100% 통과 전제.
|
||||
- **`flutter_gemma 0.16.5`** (pub.dev 확정, 2026-06-12 기준 latest stable, 약 40시간 전 publish).
|
||||
- Public API: `FlutterGemma.initialize(huggingFaceToken: String)`, `FlutterGemma.installModel(modelType: ModelType.gemma4).fromNetwork(url).install()`, `FlutterGemma.getActiveModel(maxTokens: 2048)`, `model.createChat()`, `chat.addQueryChunk(Message.text(text, isUser))`, `chat.generateChatResponseAsync()` → `Stream<ModelResponse>` (`TextResponse | FunctionCallResponse | ThinkingResponse`).
|
||||
- Function calling: **Gemma 4 native function calling** — 별도 `Tool` 객체 주입 없이 `ModelType.gemma4` 의 chat template 이 자동 라우팅. 모델이 호출 결정 시 스트림에 `FunctionCallResponse(name, args)` 1건 emit. (Gemma 4 / Gemma 3n / Phi-4 등 지원 명시)
|
||||
- Schema 전달 경로: prompt 본문에 JSON Schema 를 자연어로 명시 (Gemma 4 의 chat template 이 인식). 별도 `tools: [...]` 파라미터는 0.16.5 의 createChat 인터페이스 기준 옵션이지 필수 아님 — **OQ-C** 에서 확정.
|
||||
- **Gemma 4 E2B 모델** — HuggingFace `litert-community/gemma-4-E2B-it-litert-lm` repo. **OQ-A 확정 (2026-06-12):** 정확 파일 = `gemma-4-E2B-it.litertlm` (2,588,147,712 bytes ≈ **2.41GB disk**), SHA-256 = `181938105e0eefd105961417e8da75903eacda102c4fce9ce90f50b97139a63c`. 모바일 1GB QAT 변종은 현시점 미공개 (Google 6월 blog 발표 자산 아직 HF 미게시). peak RAM 추정 ≈ 1.5–2GB (가중치 ≈ 1.3GB + KV cache + activation).
|
||||
- **HF access token** — joungmin 본인 계정의 read-only token. 빌드 시점 `--dart-define=HF_TOKEN=hf_xxx` 으로 주입, 런타임에 `String.fromEnvironment('HF_TOKEN')` 으로 읽어 `FlutterGemma.initialize` 에 전달. 토큰을 단말 영속 저장 금지.
|
||||
- **`crypto`** (기존), **`path_provider`** (기존), **`http`** (기존) — 모두 #215 에서 이미 사용 중.
|
||||
- **Android**: `minSdkVersion 24` (MediaPipe LLM Inference 요구사항). 기존 #204 가 26 이므로 통과 가정.
|
||||
|
||||
### 제약
|
||||
|
||||
- **HF 토큰 비밀 유지**: 토큰은 .env 만, git ignore, CI 에서 `--dart-define` 으로 주입. APK 내 평문 문자열로 들어가긴 하지만 read-only 권한 + 모델 다운로드 1회용이라 노출 영향 한정.
|
||||
- **모델 라이선스**: Gemma Terms of Use (https://ai.google.dev/gemma/terms) 사용자 수락 필요. #215 의 동의 다이얼로그에 한 줄 추가 검토 (UI 변경 최소화 위해 Settings 도움말 링크로 처리).
|
||||
- **단말 RAM**: E2B Q4_0 ≈ 1.5GB peak. RAM < 4GB 차단 (Android `ActivityManager.getMemoryInfo()` 의 `totalMem`). 기존 AC-9 정책 재활용.
|
||||
- **Developer round 2 구현 (2026-06-12):** #215 의 device gate 가 사실은 미구현이라 (#218 QA 라운드 1 에서 적발), 본 이슈에서 신규 추가. 모듈 = `data/ai/device_capabilities.dart` (`DeviceCapabilities` abstract + `PlatformDeviceCapabilities` impl). 네이티브 호출 = `life_helper/device_caps` MethodChannel + `MainActivity.kt` 의 `totalMemoryBytes` 메서드 (`ActivityManager.MemoryInfo.totalMem`). 게이트 UI = SettingsScreen 의 `SwitchListTile.onChanged = null` + subtitle 안내. Provider = `deviceMeetsAiRamProvider` (FutureProvider<bool>, fail-closed). 임계값 = `kAiMinRamBytes = 4 GiB` (inclusive).
|
||||
- **`flutter_gemma` 0.16.5 의 `generateChatResponseAsync` 스트림은 token-level stream** — `FunctionCallResponse` 는 단일 이벤트 emit 후 stream done 가능, 또는 `ThinkingResponse` (Gemma 4 thinking mode) + `TextResponse` 동반 후 `FunctionCallResponse`. → **우리는 첫 `FunctionCallResponse` 만 채택, 나머지 폐기**. thinking mode 는 본 v0.3 에서 비활성 (latency 영향).
|
||||
- **timeout**: `generateStructured` 호출자가 `.timeout(Duration(seconds: 10))` 적용 (#215 시그니처 계약). flutter_gemma 자체는 timeout API 없음 → Dart `Future.timeout` 으로 감싸고 timeout 발생 시 `session.close()` 까지 호출.
|
||||
- **한국어 token 효율**: Gemma 4 tokenizer (SentencePiece, vocab ≈ 256K) 가 한국어 BPE 효율 양호 (1 char ≈ 1.2 token, Gemma 3 대비 개선). prompt 가 너무 길어지면 latency 폭증 → few-shot 카탈로그를 5개로 제한 (#215 §8 그대로).
|
||||
|
||||
### 가정
|
||||
|
||||
- joungmin 보유 Android 단말 1대 이상 (RAM ≥ 8GB, Android 13+) — AC-7 검증 필수.
|
||||
- HF account 1개 (joungmin) + Gemma 라이선스 수락 완료.
|
||||
- flutter_gemma 가 Android 측에서 자체적으로 OpenGL ES 백엔드 사용 (GPU). CPU fallback 은 0.16.5 가 자동 처리.
|
||||
- pub.dev 의 `flutter_gemma 0.16.5` 가 향후 6개월 내 breaking change 없음 (semver patch 만 갱신 허용).
|
||||
|
||||
## 5. 아키텍처 개요
|
||||
|
||||
### 변경 범위 (added/changed 만)
|
||||
|
||||
```
|
||||
app/
|
||||
├── lib/
|
||||
│ ├── data/
|
||||
│ │ └── ai/
|
||||
│ │ ├── gemma_llm_service.dart ★ 본문 교체 (stub → 실 구현)
|
||||
│ │ └── model_lifecycle.dart △ purge() F2 try/catch 추가
|
||||
│ ├── state/
|
||||
│ │ └── ai_providers.dart △ _kModelUrl / _kModelSha 상수 치환
|
||||
│ │ (이름에서 Placeholder 제거)
|
||||
│ └── main.dart △ Mock → Gemma 조건부 override
|
||||
├── android/app/
|
||||
│ ├── build.gradle △ minSdkVersion 24 확인
|
||||
│ └── src/main/AndroidManifest.xml △ uses-feature OpenGL ES 3.0
|
||||
│ + ProGuard rules (proguard-rules.pro)
|
||||
├── pubspec.yaml △ flutter_gemma: ^0.16.5
|
||||
└── test/
|
||||
└── data/ai/
|
||||
└── gemma_llm_service_test.dart ★ 신규
|
||||
```
|
||||
|
||||
설계서 #215 의 `lib/domain/ai/`, `lib/ui/`, `frame_candidate.dart`, `suggest_frame.dart` 등은 **변경 0건**. 단위 테스트도 기존 31개 전부 유지.
|
||||
|
||||
### 데이터 흐름 (변경된 노드만 빨간색 마킹)
|
||||
|
||||
```
|
||||
[main.dart]
|
||||
│
|
||||
▼
|
||||
ProviderScope.overrides = [
|
||||
appDatabaseProvider,
|
||||
llmServiceProvider.overrideWith((ref) {
|
||||
// ▼ 본 설계서 변경 지점
|
||||
final settings = ref.watch(aiSettingsProvider).value ?? false;
|
||||
final avail = ref.watch(modelAvailabilityProvider).value;
|
||||
final path = avail?.modelPath;
|
||||
if (settings && path != null) {
|
||||
return GemmaLlmService(modelPath: path); // ★ 실 구현
|
||||
}
|
||||
return MockLlmService(); // graceful fallback
|
||||
}),
|
||||
]
|
||||
│
|
||||
▼ (사용자가 #215 흐름 그대로 진입)
|
||||
│
|
||||
▼
|
||||
[suggestFrame] (#215, 변경 없음)
|
||||
│
|
||||
▼
|
||||
[LlmService.generateStructured(prompt, schema)] (#215 abstract, 변경 없음)
|
||||
│
|
||||
▼
|
||||
[GemmaLlmService.generateStructured] ★ 본 설계서 §7 + fn-*.md
|
||||
│
|
||||
├─► model = await FlutterGemma.getActiveModel(maxTokens: 2048)
|
||||
├─► chat = await model.createChat()
|
||||
├─► schemaPrompt = _appendSchemaInstruction(prompt, schema)
|
||||
│ // Gemma 4 native function calling 은 prompt 본문에
|
||||
│ // function name + JSON schema 안내가 들어가면 자동 라우팅
|
||||
├─► await chat.addQueryChunk(Message.text(text: schemaPrompt, isUser: true))
|
||||
├─► stream = chat.generateChatResponseAsync()
|
||||
├─► await for (event in stream) {
|
||||
│ if (event is FunctionCallResponse && event.name == 'emit_frame_candidates') {
|
||||
│ result = event.args;
|
||||
│ break; // 첫 FCR 만 채택
|
||||
│ }
|
||||
│ }
|
||||
├─► await chat.close() // 세션 정리
|
||||
└─► return result;
|
||||
```
|
||||
|
||||
### I/O ↔ 순수 로직 경계
|
||||
|
||||
- `lib/data/ai/gemma_llm_service.dart` = I/O 경계 (flutter_gemma native call + Dart Future timeout).
|
||||
- `lib/domain/ai/` = 변경 0 (순수 유지).
|
||||
- `_appendSchemaInstruction(prompt, schema)` 어댑터는 `gemma_llm_service.dart` 의 file-private top-level 순수 함수. 단위 테스트 가능 (입력 prompt + schema → 기대 string 비교).
|
||||
- `_collectFunctionCall(stream, name)` 도 file-private. fake `Stream<ModelResponse>` 로 단위 테스트.
|
||||
|
||||
## 6. 데이터 모델
|
||||
|
||||
본 설계서는 **신규 도메인 모델 0건**. #215 의 `FrameCandidate`, `SuggestFrameInput`, `ModelAvailability`, `DownloadProgress` 전부 재사용.
|
||||
|
||||
### `_kModelUrl` / `_kModelSha256` 상수 (치환)
|
||||
|
||||
```dart
|
||||
// app/lib/state/ai_providers.dart
|
||||
const _kModelUrl =
|
||||
'https://huggingface.co/litert-community/gemma-4-E2B-it-litert-lm/resolve/main/gemma-4-E2B-it.litertlm';
|
||||
const _kModelSha256 =
|
||||
'181938105e0eefd105961417e8da75903eacda102c4fce9ce90f50b97139a63c';
|
||||
const _kModelTotalBytes = 2588147712; // 2.41 GiB — UI 표시 용
|
||||
// 향후 QAT 모바일 1GB 변종이 HF 에 게시되면 swap. v1 은 위 base .litertlm.
|
||||
```
|
||||
|
||||
`Placeholder` 접미사 제거. `meta_kv['ai_model_path']`, `meta_kv['ai_model_sha256']` 키도 의미는 동일 (값만 실체).
|
||||
|
||||
### Function calling 스키마 (변경 없음 — `kFrameCandidatesSchema`)
|
||||
|
||||
`#215` 의 JSON Schema 를 그대로 사용. flutter_gemma `Tool.parameters` 가 JSON Schema 호환이므로 1:1 매핑.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "emit_frame_candidates",
|
||||
"description": "Return 3 framed habit goal candidates at L2 or L3 level.",
|
||||
"parameters": { ... (§7 #215 그대로) ... }
|
||||
}
|
||||
```
|
||||
|
||||
### HF 토큰 (런타임 만)
|
||||
|
||||
```dart
|
||||
// lib/data/ai/gemma_llm_service.dart 의 top-level
|
||||
const _hfToken = String.fromEnvironment('HF_TOKEN', defaultValue: '');
|
||||
```
|
||||
|
||||
빈 문자열이면 `FlutterGemma.initialize` 호출 시 throw → graceful 경로로 `MockLlmService` 유지.
|
||||
|
||||
## 7. 함수 명세 (Function Specs)
|
||||
|
||||
> 본 설계서가 새로 손대는 함수만. 그 외는 #215 §7 표 그대로.
|
||||
|
||||
| 함수 | 책임(1줄) | 시그니처 | 입력 | 출력 | 에러/실패 | 복잡? |
|
||||
|------|-----------|----------|------|------|-----------|-------|
|
||||
| `GemmaLlmService.load` | flutter_gemma 모델 파일 → 메모리 로드 | `Future<void> load()` | (modelPath 필드) | void | FileSystemException / MissingHFToken / OOM → 그대로 throw | **복잡** → [fn-gemma_llm_service.md](./fn-gemma_llm_service.md) |
|
||||
| `GemmaLlmService.generateStructured` | schema → Tool, FunctionCallResponse 수집 | `Future<Map<String,dynamic>> generateStructured(String, Map)` | prompt, schema | parsed JSON args | StateError(미로드), FormatException(빈 응답), TimeoutException(외부) | **복잡** → [fn-gemma_llm_service.md](./fn-gemma_llm_service.md) |
|
||||
| `GemmaLlmService.unload` | session + model close, _loaded=false | `Future<void> unload()` | none | void | idempotent — 미로드 상태에도 safe | 단순 |
|
||||
| `_appendSchemaInstruction` (file-private) | prompt 본문에 function schema 안내 문자열 append | `String _appendSchemaInstruction(String prompt, Map<String,dynamic> schema)` | prompt, schema | augmented prompt | schema 의 name/parameters 누락 시 ArgumentError | 단순 (string concat + JSON serialize) |
|
||||
| `_collectFunctionCall` (file-private) | Stream<ModelResponse> 에서 첫 FCR 추출 | `Future<Map<String,dynamic>> _collectFunctionCall(Stream<ModelResponse>, String)` | stream, expectedName | args | 다른 name FCR → throw FormatException; stream done 전 FCR 없음 → FormatException | 단순 (state machine 1단) |
|
||||
| `ModelLifecycle.purge` (수정) | F2 hardening — File.delete try/catch | (시그니처 동일) | none | int | 파일 미존재/권한 → log warn + 카운트 0, throw 안 함 | 단순 (try/catch 1개 추가만) |
|
||||
|
||||
## 8. 흐름 / 알고리즘
|
||||
|
||||
### 시나리오 A: 첫 실 다운로드 + 첫 추론
|
||||
|
||||
1. 사용자가 v0.3.0 APK 설치 (HF 토큰 빌드 임베드 상태).
|
||||
2. AI 토글 ON → 동의 다이얼로그 → 다운로드 시작.
|
||||
3. `ModelLifecycle.download()` 가 `_kModelUrl` (실 HF endpoint) 으로 HTTP GET (HF 가 LFS redirect 처리, `http` 패키지 follow redirect).
|
||||
4. 다운로드 완료 → SHA-256 검증 (`_kModelSha256` 와 비교).
|
||||
5. `meta_kv['ai_model_path']` 저장 → `modelAvailabilityProvider` 가 `ready` 로 전환.
|
||||
6. `main.dart` 의 override 가 `GemmaLlmService(modelPath: ...)` 인스턴스 반환 시작.
|
||||
7. 사용자가 HabitCreate → "AI 제안" 탭 → `frameSuggestionsProvider` 구독.
|
||||
8. `suggestFrame` → `llm.isLoaded == false` → `llm.load()` 호출.
|
||||
9. `GemmaLlmService.load`:
|
||||
- `FlutterGemma.initialize(huggingFaceToken: _hfToken)` 1회 (top-level `_initialized` guard).
|
||||
- `await FlutterGemma.installModel(modelType: ModelType.gemma4).fromFile(modelPath).install()` — `ModelLifecycle` 가 이미 파일을 받아둔 상태이므로 `fromNetwork` 대신 `fromFile` (또는 `fromAsset`) 경로.
|
||||
- `_model = await FlutterGemma.getActiveModel(maxTokens: 2048)`.
|
||||
- `_loaded = true`.
|
||||
- cold start ≈ 1–3 초.
|
||||
10. `suggestFrame` → `buildFewShotPrompt` (#215 그대로) → `llm.generateStructured(prompt, kFrameCandidatesSchema)`.
|
||||
11. `GemmaLlmService.generateStructured`:
|
||||
- `_loaded` 검사. false → `StateError`.
|
||||
- `augmented = _appendSchemaInstruction(prompt, schema)` — schema 의 name/parameters 를 prompt 끝에 JSON 형태로 append. Gemma 4 native chat template 이 FCR 로 자동 변환.
|
||||
- `chat = await _model.createChat()`. (sampling 파라미터 temperature/topK/topP 는 0.16.5 의 model-level 또는 chat-level 설정 — **OQ-C** 에서 확정)
|
||||
- `await chat.addQueryChunk(Message.text(text: augmented, isUser: true))`.
|
||||
- `stream = chat.generateChatResponseAsync()`.
|
||||
- `result = await _collectFunctionCall(stream, 'emit_frame_candidates')`:
|
||||
- `await for (event in stream) { ... }` 로 첫 `FunctionCallResponse` 만 채택.
|
||||
- `TextResponse` / `ThinkingResponse` 는 skip.
|
||||
- 다른 name → `FormatException('unexpected function: ${event.name}')`.
|
||||
- stream done 까지 FCR 없음 → `FormatException('no function call emitted')`.
|
||||
- `await chat.close()` (finally 블록).
|
||||
- return `result`.
|
||||
12. `suggestFrame` 이 `parseFrameCandidates(result)` (#215 그대로) → L0/L1 폐기 후 후보 3개 반환.
|
||||
13. UI 표시.
|
||||
|
||||
### 시나리오 B: opt-out (F2 hardening 검증)
|
||||
|
||||
1. 사용자가 AI 토글 OFF.
|
||||
2. `AiSettingsController.setOptIn(false)` →
|
||||
- `ref.read(modelDownloadControllerProvider.notifier).cancel()`.
|
||||
- `await ref.read(modelLifecycleProvider).purge()`:
|
||||
- 기존 코드: `await File(path).delete()` (no try/catch — F2).
|
||||
- 본 설계: try/catch 로 감쌈. 실패 시 (권한, 외부 삭제) log + 0 반환.
|
||||
3. `meta_kv` clear, opt_in='false'.
|
||||
4. 토스트.
|
||||
|
||||
### 시나리오 C: HF 토큰 누락 (debug 빌드)
|
||||
|
||||
1. 개발자가 `--dart-define=HF_TOKEN` 없이 빌드.
|
||||
2. `_hfToken == ''`.
|
||||
3. 사용자 토글 ON 시도 → 다운로드 시작 (HF endpoint 가 토큰 없으면 401).
|
||||
4. `ModelLifecycle.download` 가 HTTP 401 emit → 기존 `friendly_error` 로 "다운로드 실패, 다시 시도" 표시.
|
||||
5. graceful 유지.
|
||||
|
||||
### 시나리오 D: 모델 응답이 함수 호출 없이 plain text
|
||||
|
||||
1. `chat.generateChatResponseAsync()` 가 `TextResponse` 만 stream.
|
||||
2. `_collectFunctionCall` 이 stream done 까지 FCR 없으면 `FormatException`.
|
||||
3. `suggestFrame` (#215) 의 catch 가 빈 리스트 반환 → 다이얼로그 "다시 시도".
|
||||
4. dev log 에 "FCR not emitted, model returned plain text" 기록 (prompt 본문은 X).
|
||||
|
||||
## 9. 엣지케이스 & 에러 처리
|
||||
|
||||
| 상황 | 처리 | 비고 |
|
||||
|------|------|------|
|
||||
| HF endpoint 가 LFS 미디어 URL 로 302 redirect | 기존 `http` 패키지 follow redirect 옵션 ON 으로 처리 | flutter_gemma 가 자체 download 메서드 갖고 있어도 우리는 `ModelLifecycle.download` 유지 (resume + SHA 통일) |
|
||||
| `FlutterGemma.initialize` 가 두 번째 호출에 throw | top-level `bool _hfInitialized = false` 가드 | 0.16.5 idempotent 여부 미확정 시 보호 |
|
||||
| `_appendSchemaInstruction` 호출에서 schema 가 #215 형식과 다름 | ArgumentError | 본 설계에선 발생 불가 (`kFrameCandidatesSchema` 고정) |
|
||||
| `_collectFunctionCall` 도중 stream error event | try/catch 으로 `FormatException` 변환 | error.toString() 폐기 (prompt 누설 방지) |
|
||||
| `chat.close()` 가 throw | `unawaited` + log warn, 호출자에 전파 X | 다음 호출에 영향 없음 보장 |
|
||||
| `unload()` 호출 시 `_model == null` | early return | idempotent |
|
||||
| Android RAM 4GB 미만 단말 | 기존 #215 §9 device gate 동작 (모델 다운로드 자체 차단) | flutter_gemma load 이전 단계에서 거름 |
|
||||
| flutter_gemma OOM (Q4_0 모델인데도) | native exception → Dart 측 `Exception` → `suggestFrame` catch → 빈 리스트 | 사용자에겐 #215 의 "응답 없음" 메시지 |
|
||||
| Stream done event 가 옴 그러나 FCR 또한 옴 | break 으로 빠진 후 close — 정상 | 첫 FCR 가 진실, 이후 이벤트는 폐기 |
|
||||
| ProGuard 가 MediaPipe 클래스 strip | release 빌드 시 crash | `proguard-rules.pro` 에 `-keep class com.google.mediapipe.** { *; }` 추가 |
|
||||
|
||||
### 안전한 기본값
|
||||
|
||||
- `_hfToken` 누락 → mock 경로 유지 (override 안 함).
|
||||
- `_kModelUrl` / `_kModelSha256` 가 빈 문자열 또는 `<HEX_64_FROM_HF_LFS_POINTER>` 같은 sentinel 이면 다운로드 시작 안 함 → graceful.
|
||||
- 모든 native exception 은 `suggestFrame` 에서 catch → 빈 리스트 (#215 계약 유지).
|
||||
|
||||
## 10. 테스트 계획
|
||||
|
||||
### 단위 테스트 (신규/수정)
|
||||
|
||||
| AC | 테스트 | 위치 | 모킹 |
|
||||
|----|--------|------|------|
|
||||
| AC-1 | `flutter analyze` + APK debug build CI | scripts/ci | — |
|
||||
| AC-3 | `model_lifecycle_test.dart` Range header 테스트 — 기존 + 실 URL host header 검증 | test/data/ai | mock HTTP |
|
||||
| AC-4 | `model_lifecycle_test.dart` — `_kModelSha256` 가 sentinel 일 때 skip 분기 | test/data/ai | tmp file |
|
||||
| AC-7 (단위 부분) | `gemma_llm_service_test.dart` — `_appendSchemaInstruction` 변환 + `_collectFunctionCall` 의 4 케이스 (FCR 만 / Text+FCR / Thinking+FCR / Text 만) | test/data/ai | mock `Stream<ModelResponse>` (`flutter_gemma` 의 response 타입 fake) |
|
||||
| AC-7 (E2E) | 수동: APK 실 단말 설치 → 토글 ON → 다운로드 → "술 끊고 싶어" → 후보 ≥ 1 + 모두 L2/L3 | QA 수동 | 실 Gemma |
|
||||
| AC-8 (F2) | `model_lifecycle_test.dart` — `purge()` 가 `File.delete` throw 해도 정상 return | test/data/ai | mock FileSystem (`MemoryFileSystem` 가능 시) 또는 read-only tmp |
|
||||
| AC-9 | `gemma_llm_service_test.dart` — `_loaded=false` 인 채 `generateStructured` 호출 시 StateError | test/data/ai | direct |
|
||||
|
||||
### Mock 전략
|
||||
|
||||
- **flutter_gemma 직접 mock 불가** (final class 가능성) → `LlmService` 추상화는 그대로 두고, `GemmaLlmService` 내부의 `_schemaToTool` / `_collectFunctionCall` 만 단위 테스트.
|
||||
- `_collectFunctionCall(stream, name)` 는 `Stream<ModelResponse>` 만 받으므로 `Stream.fromIterable([...])` 로 fake event 주입 가능.
|
||||
- 단위 테스트가 flutter_gemma SDK 의 ModelResponse 타입을 import 해야 함 → `flutter_gemma: ^0.16.5` 의존성을 dev_dependencies 가 아닌 dependencies 로.
|
||||
|
||||
### E2E (수동, QA 단계)
|
||||
|
||||
- 단말: joungmin Android 8GB+ 1대.
|
||||
- 시나리오: 시나리오 A 전체 (다운로드 → cold inference → 후보 1개 탭 → habit 저장).
|
||||
- 측정: cold latency, warm latency, 메모리 peak (Android Studio profiler 1회).
|
||||
|
||||
## 11. 리스크 & 대안 검토
|
||||
|
||||
### 본 설계서 내 결정
|
||||
|
||||
| 결정 | 채택 | 대안 | 근거 |
|
||||
|------|------|------|------|
|
||||
| Gemma 4 E2B (3n 또는 3 27B 아님) | ✓ | Gemma 3n E2B / Gemma 3 27B | Gemma 4 = 2026-04-02 출시, E2B 가 모바일 전용 SKU, function calling 네이티브 지원, QAT 모바일 양자화 (≈1GB) 까지 출시 (2026-06) — ADR-0003 결정 #2 유지 |
|
||||
| `flutter_gemma 0.16.5` pin | ✓ | 0.16.x range / latest | 2026-06-12 기준 latest stable (40h 전 publish), Gemma 4 + FCR + `.task`/`.litertlm` 모두 지원 |
|
||||
| function calling (FCR) | ✓ | 자유 텍스트 + 정규식 fallback (ADR-0004 후보) | flutter_gemma 0.16.5 가 Gemma 4 native FCR 공식 지원 — Planner 핵심 리스크 해소, ADR-0004 불필요 |
|
||||
| HF 토큰 빌드 임베드 | ✓ | 사용자 본인 토큰 입력 UI | v1 은 joungmin 1인 — UX 0 비용, 보안 영향 한정 (read-only) |
|
||||
| 첫 FCR 만 채택 (Thinking 등 skip) | ✓ | 모든 이벤트 누적 후 last FCR | 명확한 종료 시점 + close() 호출 가능. Gemma 4 thinking mode 는 latency 영향 커서 v1 비활성 |
|
||||
| `_collectFunctionCall` 파일-private | ✓ | top-level / 별도 파일 | 캡슐화 (테스트는 `@visibleForTesting`) |
|
||||
| Mock fallback 유지 | ✓ | 옵션 강제 Gemma | graceful — F2/F1 작업 없이도 release 가능 |
|
||||
|
||||
### 핵심 리스크
|
||||
|
||||
- **HF 모델 URL 변경**: Google 이 HF repo path 변경 시 `_kModelUrl` 깨짐. → `meta_kv` 에 마지막 성공 URL 캐싱, 재시도 시 두 후보 (configured + cached) 비교 검토는 v2.
|
||||
- **flutter_gemma breaking change**: 0.17.x 가 FCR API 깨면 우리만 묶임. → pubspec.lock 동결 + 분기마다 release note 모니터링.
|
||||
- **HF account quota**: joungmin token 의 다운로드 제한. → 단일 사용자라 영향 0. 다인 배포 시 v2 토큰 UI.
|
||||
|
||||
### 되돌리기 어려운 결정 → ADR 후보
|
||||
|
||||
- **본 설계서는 신규 ADR 발행 안 함**. ADR-0003 결정 #3 (function calling) 이 그대로 유지됨이 research 로 확정.
|
||||
- E4B 지원 추가 = ADR-0004 후보 (Phase 2-C, 별 이슈).
|
||||
|
||||
## 12. 미해결 질문 (Open Questions)
|
||||
|
||||
| OQ | 질문 | 상태 | 결정 |
|
||||
|----|------|------|------|
|
||||
| **OQ-A** | Gemma 4 E2B 모바일 HF 파일명 + SHA-256? | ✅ 해결 (Developer 2026-06-12) | `litert-community/gemma-4-E2B-it-litert-lm` repo 의 `gemma-4-E2B-it.litertlm` (2,588,147,712 B). SHA256 = `181938105e0eefd105961417e8da75903eacda102c4fce9ce90f50b97139a63c`. QAT 1GB 변종은 미공개. |
|
||||
| **OQ-B** | `huggingFaceToken` 빈 문자열 시 throw? | ✅ 해결 | **즉시 throw 안 함** — 다운로드 시점까지 deferred. 빈 토큰은 public model 만 허용. 우리는 `null` 이 더 정확하지만 빈 문자열도 안전. |
|
||||
| **OQ-C** | sampling 파라미터 위치? | ✅ 해결 | **chat-level** — `model.createChat(temperature: 0.4, topK: 40, topP: 0.95)`. `getActiveModel` 은 `maxTokens` / `preferredBackend` / `maxConcurrentSessions` 만. (caveat: NPU backend 는 sampling 무시.) |
|
||||
| **OQ-D** | Android ProGuard rules? | ✅ 해결 | flutter_gemma example app 의 27-line 사본 적용: io.flutter.\*, play.core.\*, mediapipe.\*, protobuf.\*, kotlinx.coroutines.\*. |
|
||||
| **OQ-E** | Gemma ToU 동의 UI? | ✅ 해결 | Google 표준 템플릿 없음. Settings AI 섹션 하단에 "Gemma 이용약관(https://ai.google.dev/gemma/terms)에 동의합니다" 한 줄. 다이얼로그 본문 변경 없음 (변경 최소화). |
|
||||
| **OQ-F** | thinking mode off 스위치? | ✅ 해결 | `model.createChat(isThinking: false)` — **default 가 false** 라 명시 안 해도 무방하나 명시적으로 박는다. |
|
||||
|
||||
---
|
||||
|
||||
## 부록: 자가 점검 (Architect 가 작업 종료 시 검증)
|
||||
|
||||
- [x] §1~§12 모든 섹션 채워짐.
|
||||
- [x] #215 와의 통합점 명확: `LlmService` 추상, `kFrameCandidatesSchema`, `meta_kv` 키, UI/도메인 0 변경.
|
||||
- [x] 신규 ADR 발행 안 함 — flutter_gemma 0.16.5 의 FCR 지원이 ADR-0003 결정 #3 을 유지.
|
||||
- [x] 5개 의사결정 (Planner 인계) 모두 반영: ① Gemma 4 E2B 단일 ② Google 호스팅 (HF `litert-community/gemma-4-E2B-it-litert-lm`) ③ flutter_gemma 0.16.5 + Gemma 4 native FCR ④ RAM 4GB 게이트 ⑤ graceful 정책 유지.
|
||||
- [x] 모델 명칭 정정: Planner 가 "Gemma 3n" 으로 명시했으나 검증 결과 **Gemma 4** 가 맞음 (2026-04-02 출시, E2B/E4B 모바일 SKU + native FCR). Planner 의 "3n 정정" 자체가 잘못된 정정이었음. 본 설계서는 Gemma 4 로 정정 반영.
|
||||
- [x] AC 10개 모두 §3 + §10 1:1 매핑.
|
||||
- [x] 복잡 함수 → 1개 `fn-*.md` (`fn-gemma_llm_service.md`).
|
||||
- [x] §12 OQ 5개 모두 Developer 단계 또는 QA 단계로 routing.
|
||||
- [x] graceful degradation 명시: `_hfToken` 누락, sentinel SHA, native exception 모두 mock 경로로 폴백.
|
||||
- [x] 프라이버시: prompt 본문은 log 에 X, HF 토큰 단말 영속 X.
|
||||
- [x] out-of-scope 명확: #219 F1, #220 F2 광범위, #221 AC10, #222 keystore.
|
||||
378
docs/design/218-gemma-real-integration/fn-gemma_llm_service.md
Normal file
378
docs/design/218-gemma-real-integration/fn-gemma_llm_service.md
Normal file
@@ -0,0 +1,378 @@
|
||||
# 함수 설계서: `GemmaLlmService.load` + `generateStructured` + 보조 (#218)
|
||||
|
||||
> **부모 설계서**: ./README.md · **상태**: Approved (v2, 2026-06-12 — Reviewer 1b90f58, Release v0.3.0)
|
||||
> **작성**: [AI] Architect (2026-06-12) · **구현 대상**: `app/lib/data/ai/gemma_llm_service.dart` · **테스트**: `app/test/data/ai/gemma_llm_service_test.dart`
|
||||
|
||||
## 변경 이력 (v2, 2026-06-12 Developer 검증 후)
|
||||
|
||||
flutter_gemma 0.16.5 의 `InferenceChat` 구현을 직접 읽어 확인한 결과:
|
||||
- Gemma 4 (ModelType.gemma4) 의 function calling 은 **SDK 가 `createChat(tools: [Tool(...)])` 의 tools 목록에서 `<|tool>declaration:...<tool|>` 토큰을 직접 렌더**한다 (`lib/core/chat.dart:94`).
|
||||
- 따라서 §C `_appendSchemaInstruction` 는 Gemma 4 에선 **double-wrap** 을 유발한다. v2 에선 **§C 제거**, §B 는 `Tool` 객체를 `createChat` 에 전달하는 방식으로 변경.
|
||||
- §D `_collectFunctionCall` 는 변경 없음 — 여전히 `Stream<ModelResponse>` 에서 첫 `FunctionCallResponse` 만 추출.
|
||||
|
||||
남은 4 함수 (§A load / §B generateStructured / §C deprecated / §D collectFunctionCall) 중 코드 대상은 3 개.
|
||||
|
||||
이 문서는 `GemmaLlmService` 가 노출하는 2 개 public 메서드 + 2 개 file-private 헬퍼를 한 묶음으로 설계한다. 모두 flutter_gemma 0.16.5 의 native 경계를 다루므로 한 문서에서 다루는 게 응집도 측면에서 옳다.
|
||||
|
||||
| # | 함수 | 가시성 |
|
||||
|---|------|-------|
|
||||
| §A | `GemmaLlmService.load()` | public |
|
||||
| §B | `GemmaLlmService.generateStructured(prompt, schema)` | public |
|
||||
| §C | `_appendSchemaInstruction(prompt, schema)` | file-private (`@visibleForTesting`) |
|
||||
| §D | `_collectFunctionCall(stream, expectedName)` | file-private (`@visibleForTesting`) |
|
||||
|
||||
`unload()` 는 단순 (`await _model?.close(); _loaded = false;`) 이므로 별도 섹션 없음.
|
||||
|
||||
---
|
||||
|
||||
## §A. `GemmaLlmService.load()`
|
||||
|
||||
### 1. 시그니처
|
||||
```dart
|
||||
@override
|
||||
Future<void> load();
|
||||
```
|
||||
|
||||
### 2. 책임 (단일 책임, 1줄)
|
||||
디스크의 `modelPath` 모델 파일을 flutter_gemma native runtime 으로 메모리 적재하고 `_loaded = true` 로 표시한다.
|
||||
|
||||
### 3. 입력
|
||||
| 파라미터 | 타입 | 제약/검증 | 설명 |
|
||||
|----------|------|-----------|------|
|
||||
| (instance field) `modelPath` | `String` | 절대 경로, `File(path).existsSync() == true` 가정 | 생성자에서 주입. `ModelLifecycle` 가 다운로드 + SHA 검증 완료 시점에만 유효 |
|
||||
| (top-level const) `_hfToken` | `String` | `String.fromEnvironment('HF_TOKEN', defaultValue: '')`. 빈 문자열도 허용 (이미 다운로드 완료된 모델은 토큰 불필요할 수 있음) | 빌드 시 `--dart-define=HF_TOKEN=hf_xxx` 으로 주입 |
|
||||
|
||||
### 4. 출력
|
||||
- **반환**: `Future<void>`.
|
||||
- **부수효과**:
|
||||
- `FlutterGemma.initialize(huggingFaceToken: _hfToken)` — top-level `_initialized` 가드로 1회만.
|
||||
- `FlutterGemma.installModel(modelType: ModelType.gemma4).fromFile(modelPath).install()` — flutter_gemma 의 active model 슬롯에 모델 등록.
|
||||
- 인스턴스 필드 `_model` 에 `FlutterGemma.getActiveModel(maxTokens: 2048)` 결과 저장.
|
||||
- 인스턴스 필드 `_loaded = true`.
|
||||
|
||||
### 5. 동작 / 알고리즘
|
||||
1. `if (_loaded) return;` — idempotent.
|
||||
2. `if (!await File(modelPath).exists()) throw FileSystemException('model file missing', modelPath);`
|
||||
3. top-level guard: `if (!_initialized) { await FlutterGemma.initialize(huggingFaceToken: _hfToken); _initialized = true; }`
|
||||
4. `await FlutterGemma.installModel(modelType: ModelType.gemma4).fromFile(modelPath).install();`
|
||||
5. `_model = await FlutterGemma.getActiveModel(maxTokens: 2048);`
|
||||
6. `_loaded = true;`
|
||||
7. (no `try/catch` here — 모든 예외 caller 에 그대로 전파. `suggestFrame` 의 outer catch 가 graceful 처리)
|
||||
|
||||
### 6. 에러 & 실패 모드
|
||||
| 조건 | 처리 | 반환/예외 |
|
||||
|------|------|-----------|
|
||||
| `modelPath` 의 파일 부재 | early throw | `FileSystemException` |
|
||||
| `_hfToken` 빈 문자열인데 flutter_gemma 가 토큰 요구 | flutter_gemma 의 throw 그대로 | `Exception` (OQ-B 에서 정확 타입 확정) |
|
||||
| MediaPipe / LiteRT native OOM | native exception → Dart 변환 | `Exception` / `PlatformException` |
|
||||
| `installModel` 중간에 disk 권한 에러 | flutter_gemma 의 throw 그대로 | `FileSystemException` |
|
||||
| `getActiveModel` 가 `null` (모델 등록 실패) | guard → throw | `StateError('active model missing after install')` |
|
||||
|
||||
### 7. 엣지케이스
|
||||
- **두 번째 호출**: `_loaded == true` → 즉시 return. 같은 `GemmaLlmService` 인스턴스에서 `unload()` 후 `load()` 재호출은 정상 동작 (top-level `_initialized` 는 유지, install 만 재실행).
|
||||
- **다른 인스턴스에서 이미 active model 있음**: flutter_gemma 0.16.5 의 `installModel` 이 active slot 교체 — 우리는 단일 인스턴스 가정이라 무영향.
|
||||
- **modelPath 가 .litertlm 인데 ModelType.gemma4 와 불일치**: 형식 자동 감지 (확장자 기반). 실패 시 throw.
|
||||
- **앱 background → foreground 사이클**: `_model` 핸들 유지. native runtime 이 OS 에 의해 강제 종료된 경우 첫 inference 호출에서 에러 → caller 가 `unload()` + `load()` retry 결정 (v1 은 retry 없음, graceful 빈 리스트).
|
||||
|
||||
### 8. 복잡도 / 성능
|
||||
- **시간**: cold start 1–3 초 (모델 파일 read + native init + tokenizer load). 첫 호출 만, 이후 캐시.
|
||||
- **공간**: peak RAM ≈ 1.5–2GB (Gemma 4 E2B Q4 .litertlm, 가중치 ~1.3GB + KV cache + activation). disk ≈ 2.41GB.
|
||||
- **호출 빈도**: 사용자 1 세션 당 0–1 회 (#219 F1 의 60s idle unload 가 들어오면 다회 가능).
|
||||
|
||||
### 9. 의존성
|
||||
- `package:flutter_gemma/flutter_gemma.dart` (^0.16.5)
|
||||
- `dart:io` (`File`)
|
||||
- `String.fromEnvironment('HF_TOKEN')` (build-time inject)
|
||||
- `ModelLifecycle` (직접 import 안 함 — `_loaded` 보장 책임만 caller 에 위임)
|
||||
|
||||
### 10. 테스트 케이스
|
||||
> flutter_gemma native 직접 호출은 단위 테스트에서 모킹 불가능 (final class 가능성). 본 함수는 **E2E (실 단말, AC-7)** 로만 검증. 단위 테스트는 §C / §D 에 집중.
|
||||
|
||||
- [E2E] `modelPath` 가 실 모델 → `_loaded == true` + 후속 `generateStructured` 1회 성공.
|
||||
- [unit] `modelPath` 가 미존재 파일 → `FileSystemException` (`File.exists()` 만 검증, flutter_gemma 미진입).
|
||||
- [unit] 두 번 호출 → 두 번째는 noop (counter 증가 X).
|
||||
|
||||
### 11. 추적성
|
||||
- 인수조건: #218 AC-1 (build 성공) + AC-6 (cold start 3s 이내) + AC-9 (OOM graceful).
|
||||
- 관련 ADR: ADR-0003 (on-device LLM Gemma, 결정 #2 — E2B 단일).
|
||||
|
||||
---
|
||||
|
||||
## §B. `GemmaLlmService.generateStructured(prompt, schema)`
|
||||
|
||||
### 1. 시그니처
|
||||
```dart
|
||||
@override
|
||||
Future<Map<String, dynamic>> generateStructured(
|
||||
String prompt,
|
||||
Map<String, dynamic> schema,
|
||||
);
|
||||
```
|
||||
|
||||
### 2. 책임 (단일 책임, 1줄)
|
||||
loaded 상태의 Gemma 4 모델에 prompt + JSON Schema 를 전달하여 단일 function call 응답 (`args: Map`) 을 받아 반환한다.
|
||||
|
||||
### 3. 입력
|
||||
| 파라미터 | 타입 | 제약/검증 | 설명 |
|
||||
|----------|------|-----------|------|
|
||||
| `prompt` | `String` | non-empty. caller 가 `.length ≤ 4096` 보장 (#215 buildFewShotPrompt). | 시스템 prompt + few-shot + 사용자 raw text |
|
||||
| `schema` | `Map<String, dynamic>` | `schema['name']: String`, `schema['parameters']: Map` 필수. 본 설계에선 항상 `kFrameCandidatesSchema` 고정. | function calling JSON Schema (#215 §6) |
|
||||
|
||||
### 4. 출력
|
||||
- **반환**: `Future<Map<String, dynamic>>` — `FunctionCallResponse.args` 그대로. `kFrameCandidatesSchema` 기준이면 `{ "candidates": [...] }` 구조.
|
||||
- **부수효과**:
|
||||
- flutter_gemma chat session 1개 생성 후 `chat.close()` 으로 정리.
|
||||
- 모델 latent state 변경 (다음 호출은 fresh chat).
|
||||
- log: prompt length, latency, FCR 수신 여부 (prompt 본문 X — 프라이버시).
|
||||
|
||||
### 5. 동작 / 알고리즘 (v2)
|
||||
```
|
||||
1. if (!_loaded) throw StateError('LlmService not loaded');
|
||||
2. final fnName = schema['name'] as String;
|
||||
3. final fnDesc = (schema['description'] as String?) ?? '';
|
||||
4. final fnParams = schema['parameters'] as Map<String, dynamic>;
|
||||
5. final tool = Tool(name: fnName, description: fnDesc, parameters: fnParams);
|
||||
6. final chat = await _model!.createChat(
|
||||
modelType: ModelType.gemma4,
|
||||
supportsFunctionCalls: true,
|
||||
toolChoice: ToolChoice.required, // 강제 FCR
|
||||
tools: [tool],
|
||||
);
|
||||
7. try {
|
||||
8. await chat.addQueryChunk(Message.text(text: prompt, isUser: true));
|
||||
9. final stream = chat.generateChatResponseAsync();
|
||||
10. final args = await _collectFunctionCall(stream, fnName);
|
||||
11. return args;
|
||||
12. } finally {
|
||||
13. await chat.close(); // 항상 정리
|
||||
14. }
|
||||
```
|
||||
|
||||
caller (#215 `suggestFrame`) 가 `.timeout(Duration(seconds: 10))` 적용 → timeout 시 본 함수의 `await` 가 throw 됨 → finally 의 `chat.close()` 가 실행되어 native session leak 방지.
|
||||
|
||||
### 6. 에러 & 실패 모드
|
||||
| 조건 | 처리 | 반환/예외 |
|
||||
|------|------|-----------|
|
||||
| `_loaded == false` | early throw | `StateError` |
|
||||
| `schema['name']` 또는 `schema['parameters']` 누락 | `_appendSchemaInstruction` 가 throw | `ArgumentError` |
|
||||
| stream 이 `FunctionCallResponse` emit 안 함 (Text 만, 또는 empty) | `_collectFunctionCall` 가 throw | `FormatException('no function call emitted')` |
|
||||
| 다른 name 의 FCR | `_collectFunctionCall` 가 throw | `FormatException('unexpected function: ${actualName}')` |
|
||||
| stream 자체 error event | catch → throw (본문은 log 안 함, name 만) | `FormatException('stream error')` |
|
||||
| caller timeout | finally 에서 close, exception 전파 | (caller 의 `TimeoutException`) |
|
||||
| native runtime crash | flutter_gemma 가 PlatformException | 그대로 전파 |
|
||||
|
||||
### 7. 엣지케이스
|
||||
- **첫 token 이 Thinking → Text → FCR 순서**: §D 가 첫 FCR 만 채택, 나머지 skip.
|
||||
- **FCR 두 번 emit**: 첫 번째 채택 후 break — stream 미소진 채 `chat.close()` 호출. flutter_gemma 가 graceful 처리 가정 (OQ).
|
||||
- **`args` 가 `null`**: §D 에서 검사, throw `FormatException('null args')`.
|
||||
- **`args['candidates']` 가 Map 으로 옴 (List 아님)**: 본 함수 책임 밖. caller 의 `parseFrameCandidates` (#215) 가 `FormatException` 으로 처리.
|
||||
- **prompt UTF-8 길이 vs token 길이 불일치**: caller 책임. 본 함수는 prompt 길이 검증 X.
|
||||
|
||||
### 8. 복잡도 / 성능
|
||||
- **시간**: warm 0.5–2초 / cold (load 직후) 추가 1–3초. function calling 1턴이라 stream 길이 짧음 (~200 token).
|
||||
- **공간**: chat 인스턴스 ~ 수십 MB (KV cache). close 시 회수.
|
||||
- **호출 빈도**: habit 생성 화면 진입 시 사용자 trigger. throttle 5회/세션 (#215).
|
||||
|
||||
### 9. 의존성
|
||||
- `flutter_gemma`: `FlutterGemma.getActiveModel` 결과의 `createChat` / `Message.text` / `ModelResponse`.
|
||||
- `_appendSchemaInstruction` (§C)
|
||||
- `_collectFunctionCall` (§D)
|
||||
|
||||
### 10. 테스트 케이스
|
||||
- [unit] `_loaded = false` → `StateError`. (직접 검증)
|
||||
- [E2E] AC-7 — 실 단말에서 prompt + `kFrameCandidatesSchema` → `args['candidates']` 3개 반환.
|
||||
- [unit] caller timeout 시 finally close 호출 확인 — 간접 (`_collectFunctionCall` 가 await never-completing future 일 때 외부 timeout → exception 후 chat.close mock 카운터).
|
||||
|
||||
### 11. 추적성
|
||||
- 인수조건: #218 AC-6 (latency), AC-7 (E2E candidates), AC-9 (graceful).
|
||||
- 관련 ADR: ADR-0003 결정 #3 (function calling).
|
||||
|
||||
---
|
||||
|
||||
## §C. (DEPRECATED — v2) `_appendSchemaInstruction(prompt, schema)`
|
||||
|
||||
> **v2 결정**: Gemma 4 SDK 가 `Tool` 객체에서 직접 declaration 토큰을 렌더하므로, prompt 측에서 schema 안내문을 덧붙이면 double-wrap 이 된다. **본 함수는 구현하지 않는다.**
|
||||
>
|
||||
> 아래 §C 본문은 v1 (gemmaIt fallback) 시나리오용 참고 자료로 보존하나, v2 코드 대상에서 제외한다. 단위 테스트도 작성하지 않는다.
|
||||
|
||||
원본 본문 (참고용):
|
||||
|
||||
### 1. 시그니처
|
||||
```dart
|
||||
@visibleForTesting
|
||||
String appendSchemaInstruction(String prompt, Map<String, dynamic> schema);
|
||||
```
|
||||
> 파일 내부에선 `_appendSchemaInstruction` 으로 호출, 테스트는 public `appendSchemaInstruction` 으로 re-export.
|
||||
|
||||
### 2. 책임 (단일 책임, 1줄)
|
||||
prompt 본문 끝에 Gemma 4 chat template 이 인식할 function call 안내 (name + JSON Schema) 를 append 한다.
|
||||
|
||||
### 3. 입력
|
||||
| 파라미터 | 타입 | 제약/검증 | 설명 |
|
||||
|----------|------|-----------|------|
|
||||
| `prompt` | `String` | non-empty | 시스템 + few-shot + 사용자 입력 |
|
||||
| `schema` | `Map<String, dynamic>` | `schema['name']: String`, `schema['parameters']: Map` 필수 | function calling schema |
|
||||
|
||||
### 4. 출력
|
||||
- **반환**: `String` — `prompt + '\n\n' + 안내문` 형태.
|
||||
- **부수효과**: **순수 함수**.
|
||||
|
||||
### 5. 동작 / 알고리즘
|
||||
```
|
||||
1. final name = schema['name'];
|
||||
2. if (name is! String || name.isEmpty) throw ArgumentError('schema.name missing');
|
||||
3. final params = schema['parameters'];
|
||||
4. if (params is! Map) throw ArgumentError('schema.parameters missing');
|
||||
5. final description = schema['description'] as String? ?? '';
|
||||
6. final paramsJson = const JsonEncoder().convert(params);
|
||||
7. final block = [
|
||||
'',
|
||||
'',
|
||||
'## Function call instruction',
|
||||
'You MUST respond by calling the function `$name`.',
|
||||
if (description.isNotEmpty) description,
|
||||
'Arguments must conform to this JSON Schema:',
|
||||
'```json',
|
||||
paramsJson,
|
||||
'```',
|
||||
].join('\n');
|
||||
8. return prompt + block;
|
||||
```
|
||||
|
||||
순수 함수라 deterministic. 같은 입력에 대해 항상 같은 출력.
|
||||
|
||||
### 6. 에러 & 실패 모드
|
||||
| 조건 | 처리 | 반환/예외 |
|
||||
|------|------|-----------|
|
||||
| `schema['name']` 누락/빈 문자열 | throw | `ArgumentError('schema.name missing')` |
|
||||
| `schema['parameters']` 가 Map 아님 | throw | `ArgumentError('schema.parameters missing')` |
|
||||
| `prompt` 가 빈 문자열 | 허용 (append 만) | OK |
|
||||
|
||||
### 7. 엣지케이스
|
||||
- `params` 가 빈 Map → `{}` JSON 으로 직렬화. caller 가 의도한 경우면 OK (본 설계엔 발생 안 함).
|
||||
- `description` 누락 → 해당 라인 생략.
|
||||
- prompt 끝에 이미 `\n\n` 있음 → 결과 `\n\n\n\n`. Gemma 4 tokenizer 가 무시.
|
||||
|
||||
### 8. 복잡도 / 성능
|
||||
- O(N) — `JsonEncoder` 가 schema 깊이에 비례. `kFrameCandidatesSchema` 는 작아서 < 1ms.
|
||||
|
||||
### 9. 의존성
|
||||
- `dart:convert` (`JsonEncoder`).
|
||||
- `package:flutter/foundation.dart` (`@visibleForTesting`).
|
||||
|
||||
### 10. 테스트 케이스
|
||||
- [unit] `kFrameCandidatesSchema` 입력 → 반환 string 에 `'emit_frame_candidates'` 와 `'\"L2\"' / '\"L3\"'` 포함.
|
||||
- [unit] `schema['name']` 없음 → `ArgumentError`.
|
||||
- [unit] `schema['parameters']` 가 `List` → `ArgumentError`.
|
||||
- [unit] 같은 입력 2회 호출 → 동일 string (순수성 검증).
|
||||
- [unit] `prompt` 끝 trim 없이 그대로 append 되는지 — exact string compare.
|
||||
|
||||
### 11. 추적성
|
||||
- 인수조건: #218 AC-7 (모델이 FCR 로 응답하려면 안내문이 필요).
|
||||
- 관련 ADR: ADR-0003 결정 #3.
|
||||
|
||||
---
|
||||
|
||||
## §D. `_collectFunctionCall(stream, expectedName)`
|
||||
|
||||
### 1. 시그니처
|
||||
```dart
|
||||
@visibleForTesting
|
||||
Future<Map<String, dynamic>> collectFunctionCall(
|
||||
Stream<ModelResponse> stream,
|
||||
String expectedName,
|
||||
);
|
||||
```
|
||||
|
||||
### 2. 책임 (단일 책임, 1줄)
|
||||
`Stream<ModelResponse>` 에서 **첫 `FunctionCallResponse(name == expectedName)`** 의 `args` 만 추출한다. `TextResponse`/`ThinkingResponse` 는 skip, 잘못된 name 은 throw.
|
||||
|
||||
### 3. 입력
|
||||
| 파라미터 | 타입 | 제약/검증 | 설명 |
|
||||
|----------|------|-----------|------|
|
||||
| `stream` | `Stream<ModelResponse>` | flutter_gemma 의 union 타입. `TextResponse \| FunctionCallResponse \| ThinkingResponse` 가정 | `chat.generateChatResponseAsync()` 결과 |
|
||||
| `expectedName` | `String` | non-empty | 매칭할 function name (e.g. `'emit_frame_candidates'`) |
|
||||
|
||||
### 4. 출력
|
||||
- **반환**: `Future<Map<String, dynamic>>` — 첫 매칭 FCR 의 `args`.
|
||||
- **부수효과**: 없음 — stream 의 첫 매칭 이벤트 후 `await for` 빠져나옴 (cancelSubscription 자동).
|
||||
|
||||
### 5. 동작 / 알고리즘
|
||||
```
|
||||
1. Map<String, dynamic>? result;
|
||||
2. String? wrongName;
|
||||
3. try {
|
||||
4. await for (final event in stream) {
|
||||
5. if (event is FunctionCallResponse) {
|
||||
6. if (event.name == expectedName) {
|
||||
7. result = Map<String, dynamic>.from(event.args ?? const {});
|
||||
8. break;
|
||||
9. } else {
|
||||
10. wrongName = event.name;
|
||||
11. break; // 잘못된 함수 — 빠른 실패
|
||||
12. }
|
||||
13. }
|
||||
14. // TextResponse / ThinkingResponse 는 무시 (continue)
|
||||
15. }
|
||||
16. } catch (e) {
|
||||
17. throw FormatException('stream error'); // e.toString() 폐기 (prompt 누설 방지)
|
||||
18. }
|
||||
19. if (wrongName != null) {
|
||||
20. throw FormatException('unexpected function: $wrongName');
|
||||
21. }
|
||||
22. if (result == null) {
|
||||
23. throw FormatException('no function call emitted');
|
||||
24. }
|
||||
25. return result;
|
||||
```
|
||||
|
||||
`event.args` 가 `null` 이면 빈 Map 으로 대체 → caller 의 `parseFrameCandidates` 가 빈 `candidates` 로 처리하여 빈 리스트 반환.
|
||||
|
||||
### 6. 에러 & 실패 모드
|
||||
| 조건 | 처리 | 반환/예외 |
|
||||
|------|------|-----------|
|
||||
| stream done 까지 FCR 없음 | check after loop | `FormatException('no function call emitted')` |
|
||||
| 다른 name 의 FCR | break + check | `FormatException('unexpected function: ...')` |
|
||||
| stream error event (native crash 등) | catch | `FormatException('stream error')` (원본 e 폐기 — 본문 누설 X) |
|
||||
| `event.args == null` | 빈 Map 으로 대체 후 return | (no throw) |
|
||||
|
||||
### 7. 엣지케이스
|
||||
- **첫 이벤트가 곧바로 FCR**: 정상. Text/Thinking 없이 바로 break.
|
||||
- **Text + Text + FCR + FCR (두 번째 FCR 이 정답 name)**: 첫 FCR 의 name 검증으로 break — `wrongName` 으로 throw. v1 정책: 첫 FCR 만 신뢰. (Gemma 4 가 다중 FCR 보내는 경우 거의 없음. 발생 시 prompt 개선 신호.)
|
||||
- **Thinking → FCR 순서**: Thinking skip 후 FCR 채택. OK.
|
||||
- **stream 이 무한 (timeout 없음)**: caller 의 `.timeout(10s)` 에 의존. 본 함수는 자체 timeout X.
|
||||
- **event 가 `null`** (Dart stream 에 null event): `await for` 에서 false-match → skip. (실제로는 발생 안 함, 방어 안 함.)
|
||||
|
||||
### 8. 복잡도 / 성능
|
||||
- O(N) — N = stream 이벤트 수. function calling 응답은 보통 ≤ 10 events. ~수십 ms.
|
||||
|
||||
### 9. 의존성
|
||||
- `package:flutter_gemma/flutter_gemma.dart` — `ModelResponse` / `FunctionCallResponse` / `TextResponse` / `ThinkingResponse` 타입.
|
||||
|
||||
### 10. 테스트 케이스
|
||||
> 핵심 단위 테스트 슬롯. flutter_gemma response 클래스를 `Stream.fromIterable([...])` 로 fake 주입 가능.
|
||||
|
||||
- [unit] `[FunctionCallResponse('emit_frame_candidates', {'candidates': [...3개...]})]` → `args` 반환.
|
||||
- [unit] `[TextResponse('hello'), FunctionCallResponse('emit_frame_candidates', {...})]` → Text skip 후 args 반환.
|
||||
- [unit] `[ThinkingResponse('...'), TextResponse('...'), FunctionCallResponse('emit_frame_candidates', {})]` → 빈 args Map 반환 (no throw).
|
||||
- [unit] `[FunctionCallResponse('wrong_name', {})]` → `FormatException('unexpected function: wrong_name')`.
|
||||
- [unit] `[TextResponse('only text')]` (FCR 없이 done) → `FormatException('no function call emitted')`.
|
||||
- [unit] `Stream.error(...)` event → `FormatException('stream error')` (원본 메시지 미포함 검증).
|
||||
- [unit] `[FunctionCallResponse('emit_frame_candidates', null)]` → 빈 Map 반환 (`{}`), no throw.
|
||||
- [unit] `[]` 빈 stream → `FormatException('no function call emitted')`.
|
||||
|
||||
### 11. 추적성
|
||||
- 인수조건: #218 AC-7 (FCR 수집 성공), AC-9 (graceful — `FormatException` 이 caller 의 빈 리스트 반환으로 전환).
|
||||
- 관련 ADR: ADR-0003 결정 #3.
|
||||
|
||||
---
|
||||
|
||||
## 부록: 자가 점검
|
||||
|
||||
- [x] §A~§D 4 함수 모두 시그니처 + 책임 + 에러 + 테스트 케이스 채움.
|
||||
- [x] §D 테스트 케이스 8개 — flutter_gemma 의 ModelResponse 변종 (Text/Thinking/FCR/error/empty) 모두 커버.
|
||||
- [x] §C 순수성 강조 — `_appendSchemaInstruction` 은 외부 I/O 0, deterministic.
|
||||
- [x] 프라이버시: §B 와 §D 모두 catch 시 `e.toString()` 폐기 (prompt 본문 누설 방지).
|
||||
- [x] timeout 책임 분리: 본 모듈은 timeout 미구현, caller 의 `.timeout(10s)` 에 의존. finally close 로 native session leak 방지.
|
||||
- [x] `@visibleForTesting` 으로 file-private 함수도 단위 테스트 가능.
|
||||
- [x] AC-7 의 E2E 부분은 §A `load` + §B `generateStructured` 에서만 검증, §C/§D 는 단위 테스트로 100% 커버.
|
||||
457
docs/design/226-catalog-gallery/README.md
Normal file
457
docs/design/226-catalog-gallery/README.md
Normal file
@@ -0,0 +1,457 @@
|
||||
# 설계서: Catalog Gallery + 8 카테고리 재분류 (#226)
|
||||
|
||||
> **상태**: Draft
|
||||
> **작성**: [AI] Architect · **최종수정**: 2026-06-12
|
||||
> **추적성** — Redmine: #226 · 관련 ADR: ADR-0004 (본 이슈에서 신규 — Catalog 우선 onboarding 정책 + 첫 schema 마이그레이션) · 상위/이전: #218 (v0.3.0) · 후속: #FF1 (import) / #FF2 (LLM tweak) / #FF3 (FTS5) / #FF4 (LLM retriever)
|
||||
> · 변경 대상 파일:
|
||||
> - `app/lib/data/db/tables/catalog_tables.dart` — `Protocols.category` CHECK 새 7개로
|
||||
> - `app/lib/data/db/app_database.dart` — `schemaVersion` 1 → 2 + `onUpgrade` 진짜 구현
|
||||
> - `app/assets/seed/protocols.json` — 34 항목 category 재분류
|
||||
> - `app/lib/domain/catalog/display_category.dart` (★ 신규) — 8 DisplayCategory enum + label/icon
|
||||
> - `app/lib/domain/catalog/catalog_item.dart` (★ 신규) — 통합 CatalogItem 모델
|
||||
> - `app/lib/data/catalog/catalog_repository.dart` (★ 신규) — Protocols + Break + Diet → CatalogItem 변환
|
||||
> - `app/lib/state/catalog_providers.dart` (★ 신규) — `catalogItemsProvider` / `catalogByCategoryProvider`
|
||||
> - `app/lib/ui/screens/protocol_gallery_screen.dart` (★ 신규)
|
||||
> - `app/lib/ui/screens/protocol_preview_screen.dart` (★ 신규)
|
||||
> - `app/lib/ui/widgets/catalog_card.dart` (★ 신규)
|
||||
> - `app/lib/ui/widgets/category_chip_row.dart` (★ 신규)
|
||||
> - `app/lib/ui/widgets/reference_expand_card.dart` (★ 신규)
|
||||
> - `app/lib/ui/screens/habit_list_screen.dart` — 빈 상태 CTA + AppBar 액션
|
||||
> · 신규 테스트:
|
||||
> - `app/test/data/db/migration_v1_to_v2_test.dart`
|
||||
> - `app/test/domain/catalog/display_category_test.dart`
|
||||
> - `app/test/data/catalog/catalog_repository_test.dart`
|
||||
> - `app/test/ui/protocol_gallery_screen_test.dart`
|
||||
> - `app/test/ui/protocol_preview_screen_test.dart`
|
||||
> · 하위 문서:
|
||||
> - [fn-catalog_repository.md](./fn-catalog_repository.md) — 통합 CatalogItem 생성 + DisplayCategory 매핑
|
||||
> - [fn-migration_v1_to_v2.md](./fn-migration_v1_to_v2.md) — 첫 schema 마이그레이션
|
||||
|
||||
---
|
||||
|
||||
## 1. 목적 (Why)
|
||||
|
||||
#218 (v0.3.0) 종료 후 사용자가 **"화면이 너무 횡하다"** 지적. 진단:
|
||||
- 빌드 자산: 시드 카탈로그 **107 항목** (protocols 34 + frame 30 + reward 30 + break 8 + diet 5) — 모두 DB seed 완료
|
||||
- UI 노출: **0 경로**
|
||||
- 첫 사용자: 빈 HabitListScreen + 자유 입력 단일 경로만 봄
|
||||
|
||||
Tiny Habits 의 **"선택 마비" 함정** — 어떤 습관을 만들지 막막함이 채택률 최대 적. 풍부한 자산을 0% 노출 중인 게 핵심 문제.
|
||||
|
||||
본 이슈는 **47 항목 (protocols 34 + break 8 + diet 5) 을 8 카테고리로 묶어 갤러리/프리뷰 화면으로 노출만** — "내 습관으로" import 는 #FF1 후속.
|
||||
|
||||
> Planner 목표 1줄: "사용자가 첫 진입에서 빈 화면 대신 8 카테고리로 분류된 107 개 Huberman 카탈로그를 탐색·미리보기 할 수 있게 한다 — 노출만, 자동 import 없음."
|
||||
|
||||
## 2. 범위 (Scope)
|
||||
|
||||
### 포함
|
||||
- **DB 마이그레이션 v1 → v2** — `Protocols.category` CHECK 제약 6 → 7 신 카테고리. 본 앱의 **첫 schema 마이그레이션**.
|
||||
- **`protocols.json` 재분류** — 34 항목 모두 7 카테고리 중 하나로 매핑 (1차 효과 기준).
|
||||
- **DisplayCategory enum** — UI 노출용 8 카테고리 (Protocols 의 7 + 항상 break = breakHabit). break/diet 는 Protocols.category 와 직교한 별도 source 이므로 `domain/catalog/` 에서 통합.
|
||||
- **CatalogRepository** — 3 source (Protocols / BreakProtocols / DietPatterns) → 단일 `CatalogItem` 리스트 변환.
|
||||
- **3 신규 화면 + 3 신규 위젯**.
|
||||
- **`HabitListScreen` 진입점** — 빈 상태 CTA + 채워진 상태에서 AppBar 액션.
|
||||
- **신규 테스트 ≥ 15건** — 마이그레이션 검증 + DisplayCategory 매핑 + Repository 통합 + 위젯 2종.
|
||||
|
||||
### 제외 (out of scope)
|
||||
- "내 습관으로" import → **#FF1**. 본 이슈에선 disabled placeholder 버튼만.
|
||||
- LLM tweak / FTS5 / LLM retriever / vector → #FF2~#FF4.
|
||||
- `methodologies` / `frame_patterns` / `reward_menu_items` / `common_frames` / `references` 의 별도 갤러리 — 본 이슈는 47 항목만. references 는 Preview 의 펼치기 카드로만 노출.
|
||||
- 카탈로그 자유 검색 → #FF3.
|
||||
- 다국어 (한국어 단일 유지).
|
||||
- 본 이슈는 ADR-0004 발행. ADR-0005 (catalog import policy) 는 #FF1 시점.
|
||||
|
||||
## 3. 인수조건 (Acceptance Criteria)
|
||||
|
||||
> Planner §3 10 AC 그대로 수용. QA 판정.
|
||||
|
||||
- [ ] **AC-1**: `HabitListScreen` 빈 상태에 "🔍 카탈로그 탐색" CTA + 채워진 상태에서 AppBar 액션으로 상시 진입.
|
||||
- [ ] **AC-2**: `ProtocolGalleryScreen` 진입 시 가로 카테고리 칩 (전체 + 8 DisplayCategory). 칩 선택 시 해당 카테고리 카드만 표시.
|
||||
- [ ] **AC-3**: 카드 = (title + 1줄 요약 + evidence_strength 배지 + 카테고리 아이콘). 카드 탭 → Preview 진입.
|
||||
- [ ] **AC-4**: `protocols.json` 의 `category` 필드 7개로 재분류 — 34 항목 모두 매핑. + break 8 + diet 5 가 갤러리 unified view 에서 8 카테고리 중 하나에 노출.
|
||||
- [ ] **AC-5**: `ProtocolPreviewScreen` 에 title / title_en / what / when / dose / why / how (번호 매김) / check / caution / default_anchor / min_dose_for_start / source_doc + evidence_strength 배지 모두 표시.
|
||||
- [ ] **AC-6**: Preview 하단 "내 습관으로" 버튼 **disabled** + 툴팁 "다음 업데이트 예정". 본 이슈에선 동작 X.
|
||||
- [ ] **AC-7**: `reference_ids` 가 있으면 References 테이블에서 매칭 → 펼치기 카드로 title + url + kind 표시. ref 0 일 때 섹션 숨김.
|
||||
- [ ] **AC-8**: 갤러리/프리뷰 진입/탐색 중 R규칙 검사 0건 호출 + habits 테이블 write 0건. 사용자가 "내 습관으로" 누르지 않으면 어떤 DB 변경도 없음.
|
||||
- [ ] **AC-9**: 기존 88 테스트 회귀 0 + 신규 ≥ 15 (마이그레이션 + DisplayCategory + Repository + widget 2). 카테고리 매핑 완전성 unit test (모든 ID 가 정확히 1 DisplayCategory 에 매핑).
|
||||
- [ ] **AC-10**: `flutter analyze` 0 issues + APK release 빌드 성공.
|
||||
|
||||
## 4. 컨텍스트 & 제약
|
||||
|
||||
### 의존성
|
||||
- **Drift 2.x** 기존 + Riverpod 2.5 기존. 신규 패키지 0.
|
||||
- **schemaVersion 1 → 2** 가 본 앱 첫 마이그레이션 — 기존 Phase 1 의 `onUpgrade` 는 `assert(false)`. 이걸 진짜 구현으로 교체. 정책은 ADR-0004 에 묶음.
|
||||
- 신규 DB 테이블 0. 신규 컬럼 0. **CHECK 제약 한 줄 변경 + reseed** 만.
|
||||
- `kSeededV1Flag` 키 명 유지 — 본 이슈에서 `seeded_v2` 로 추가하지 않고, 마이그레이션이 기존 flag 를 클리어해서 SeedImporter 가 재시드.
|
||||
|
||||
### 제약
|
||||
- **AC-8 (DB write 0건)** — 사용자 작업 동안 user 테이블 무변화. 단, 마이그레이션 자체는 catalog 테이블만 영향 (read-only seed), user 데이터 0 영향.
|
||||
- **first run vs upgrade** — 신규 설치는 `onCreate` (v2 schema 그대로), 기존 설치는 `onUpgrade` (Protocols 재생성 + reseed). 둘 다 동일 결과 보장.
|
||||
- **빈 카테고리 UX** — 일부 DisplayCategory (예: `emotionRelationship`) 는 47 항목 중 매핑 0 가능. 빈 카테고리는 **카테고리 칩 자체를 숨김** (사용자 혼란 최소화). 모든 칩이 빈 경우는 없음 (47 항목 ≥ 7).
|
||||
- **i18n** — 한국어 단일. `title_en` 은 카드/Preview 에 회색 보조 텍스트.
|
||||
- **접근성** — evidence_strength 배지는 색 + 텍스트 둘 다. Semantics label 모든 카드.
|
||||
|
||||
### 가정
|
||||
- joungmin 1인 사용자 + Android 단말.
|
||||
- 기존 v1 설치된 dev 단말이 있을 수 있어 **upgrade path 필수** (assert false 제거).
|
||||
- seed JSON 의 모든 항목에 한국어 title/what 존재 (확인됨).
|
||||
- BreakProtocols / DietPatterns 의 스키마는 변경 없음 — 본 이슈에서 손대지 않음.
|
||||
|
||||
## 5. 아키텍처 개요
|
||||
|
||||
### 모듈 구조
|
||||
|
||||
```
|
||||
app/
|
||||
├── lib/
|
||||
│ ├── data/
|
||||
│ │ ├── db/
|
||||
│ │ │ ├── app_database.dart △ schemaVersion 1→2 + onUpgrade
|
||||
│ │ │ └── tables/catalog_tables.dart △ Protocols.category CHECK 새 7개
|
||||
│ │ └── catalog/
|
||||
│ │ └── catalog_repository.dart ★ 신규 — 3 source 통합
|
||||
│ ├── domain/
|
||||
│ │ └── catalog/
|
||||
│ │ ├── display_category.dart ★ enum + label + icon + protocol_id 매핑
|
||||
│ │ └── catalog_item.dart ★ unified model
|
||||
│ ├── state/
|
||||
│ │ └── catalog_providers.dart ★ catalogItemsProvider + groupedByCategoryProvider
|
||||
│ └── ui/
|
||||
│ ├── screens/
|
||||
│ │ ├── habit_list_screen.dart △ 빈 CTA + AppBar 액션
|
||||
│ │ ├── protocol_gallery_screen.dart ★
|
||||
│ │ └── protocol_preview_screen.dart ★
|
||||
│ └── widgets/
|
||||
│ ├── catalog_card.dart ★
|
||||
│ ├── category_chip_row.dart ★
|
||||
│ └── reference_expand_card.dart ★
|
||||
├── assets/seed/
|
||||
│ └── protocols.json △ 34 항목 category 재분류
|
||||
├── seed-staging/
|
||||
│ └── protocols.json △ (mirror)
|
||||
└── test/
|
||||
├── data/db/migration_v1_to_v2_test.dart ★
|
||||
├── data/catalog/catalog_repository_test.dart ★
|
||||
├── domain/catalog/display_category_test.dart ★
|
||||
└── ui/
|
||||
├── protocol_gallery_screen_test.dart ★
|
||||
└── protocol_preview_screen_test.dart ★
|
||||
```
|
||||
|
||||
### 데이터 흐름
|
||||
|
||||
```
|
||||
[HabitListScreen]
|
||||
│ 빈 상태 → "🔍 카탈로그 탐색" CTA
|
||||
│ 채워진 상태 → AppBar IconButton(search)
|
||||
▼
|
||||
[ProtocolGalleryScreen]
|
||||
│ ref.watch(catalogItemsProvider)
|
||||
▼
|
||||
[CatalogRepository.all()] ─────────► [AppDatabase]
|
||||
│ ├─► db.select(protocols) → List<Protocol> (34)
|
||||
│ ├─► db.select(breakProtocols) → List<BreakProtocol> (8)
|
||||
│ └─► db.select(dietPatterns) → List<DietPattern> (5)
|
||||
│ 변환:
|
||||
│ for p in protocols:
|
||||
│ resolve DisplayCategory by p.category enum
|
||||
│ → CatalogItem(source: protocol, displayCategory, ...)
|
||||
│ for b in breakProtocols:
|
||||
│ → CatalogItem(source: break, displayCategory: breakHabit, ...)
|
||||
│ for d in dietPatterns:
|
||||
│ → CatalogItem(source: diet, displayCategory: nutrition, ...)
|
||||
▼
|
||||
[List<CatalogItem>] (47)
|
||||
│ groupBy DisplayCategory
|
||||
▼
|
||||
[CategoryChipRow] — 비어있지 않은 카테고리만
|
||||
│ user 선택 → state
|
||||
▼
|
||||
[GridView of CatalogCard]
|
||||
│ tap →
|
||||
▼
|
||||
[ProtocolPreviewScreen(item: CatalogItem)]
|
||||
│ what/when/dose/why/how/check/caution/anchor/min_dose 표시
|
||||
│ if item.referenceIds.isNotEmpty:
|
||||
│ ref.watch(referencesByIdsProvider(item.referenceIds))
|
||||
│ → ReferenceExpandCard
|
||||
│ 하단 "내 습관으로" disabled (#FF1 진입점)
|
||||
▼
|
||||
[user back] — DB write 0
|
||||
```
|
||||
|
||||
### I/O ↔ 순수 로직 경계
|
||||
|
||||
- **I/O 경계**:
|
||||
- `CatalogRepository` (DB 읽기) = data/catalog/
|
||||
- `assets/seed/*.json` 읽기는 SeedImporter 만 (마이그레이션 시점)
|
||||
- DB onUpgrade 안의 SQL = drift API 안 raw SQL 최소
|
||||
- **순수 로직**:
|
||||
- `DisplayCategory.resolve(protocolId)` — protocol id → DisplayCategory 정적 lookup (pure function)
|
||||
- `CatalogItem.fromProtocol/fromBreak/fromDiet` factory (pure)
|
||||
- `groupByCategory(items)` (pure)
|
||||
- **UI 경계**:
|
||||
- Riverpod providers 가 데이터 ↔ UI bridge. 화면은 ConsumerWidget only.
|
||||
|
||||
## 6. 데이터 모델
|
||||
|
||||
### DisplayCategory enum (UI 노출용)
|
||||
|
||||
```dart
|
||||
// lib/domain/catalog/display_category.dart
|
||||
enum DisplayCategory {
|
||||
lightCircadian('빛/일주기', Icons.wb_sunny),
|
||||
sleep('수면', Icons.bedtime),
|
||||
movement('운동/신체', Icons.fitness_center),
|
||||
nutrition('영양', Icons.restaurant),
|
||||
focusCognition('집중/인지', Icons.psychology),
|
||||
recoveryStress('회복/스트레스', Icons.spa),
|
||||
emotionRelationship('감정/관계', Icons.favorite),
|
||||
breakHabit('없애기', Icons.block);
|
||||
|
||||
const DisplayCategory(this.label, this.icon);
|
||||
final String label;
|
||||
final IconData icon;
|
||||
}
|
||||
```
|
||||
|
||||
### Protocols.category CHECK (v2)
|
||||
|
||||
```dart
|
||||
TextColumn get category => text().check(const CustomExpression<bool>(
|
||||
"category IN ("
|
||||
"'light_circadian','sleep','movement','nutrition',"
|
||||
"'focus_cognition','recovery_stress','emotion_relationship'"
|
||||
")"))();
|
||||
```
|
||||
|
||||
> 7개 — `break_habit` 는 별도 BreakProtocols 테이블이라 미포함.
|
||||
|
||||
### CatalogItem (unified model)
|
||||
|
||||
```dart
|
||||
// lib/domain/catalog/catalog_item.dart
|
||||
sealed class CatalogItem {
|
||||
String get id;
|
||||
String get title;
|
||||
String? get titleEn;
|
||||
String get summary; // 1줄 요약 (카드용)
|
||||
DisplayCategory get displayCategory;
|
||||
String? get evidenceStrength;
|
||||
List<String> get referenceIds;
|
||||
}
|
||||
|
||||
final class ProtocolCatalogItem extends CatalogItem {
|
||||
// 원본 Protocol 필드 보존 (what/when/dose/why/how/check/caution/anchor/minDose)
|
||||
// summary = what 의 첫 문장 또는 1줄 요약
|
||||
// displayCategory = DisplayCategory.values.byName(protocol.category) 의 camelCase 변환
|
||||
// light_circadian → lightCircadian
|
||||
}
|
||||
|
||||
final class BreakCatalogItem extends CatalogItem {
|
||||
// 원본 BreakProtocol 필드 (hubermanSummary, phases, defaultCommonFrames)
|
||||
// summary = hubermanSummary
|
||||
// displayCategory = breakHabit (항상)
|
||||
// category(원본 alcohol/nicotine/..) 는 sub-tag 로 보존
|
||||
}
|
||||
|
||||
final class DietCatalogItem extends CatalogItem {
|
||||
// 원본 DietPattern (name, core, strengths, weaknesses, koreanContextFit, ...)
|
||||
// summary = core
|
||||
// displayCategory = nutrition (항상)
|
||||
// koreanContextFit 은 sub-tag
|
||||
}
|
||||
```
|
||||
|
||||
### `protocols.json` 카테고리 매핑 (34 항목)
|
||||
|
||||
> 분류 가이드: **1차 효과** 기준. 모호 시 `what/why` 첫 문장의 주효과를 따른다.
|
||||
> 본 매핑은 Architect 가 1차 결정, QA 가 비논리적 매핑 발견 시 reject 가능.
|
||||
|
||||
| protocol id | v1 category | v2 category | 근거 |
|
||||
|---|---|---|---|
|
||||
| morning_sunlight | health | light_circadian | 일주기 리셋 |
|
||||
| evening_sunlight | health | light_circadian | 일주기 |
|
||||
| night_light_avoidance | health | light_circadian | 일주기 보호 |
|
||||
| sleep_stack | health | sleep | 수면 직접 |
|
||||
| caffeine_protocol | health | sleep | 수면 영향이 1차 |
|
||||
| zone2 | health | movement | 운동 |
|
||||
| strength | health | movement | 운동 |
|
||||
| nsdr | health | recovery_stress | 회복/이완 |
|
||||
| cold_exposure | health | recovery_stress | 스트레스 적응 |
|
||||
| breathwork | health | recovery_stress | 스트레스 조절 |
|
||||
| focus_block | health | focus_cognition | 집중 |
|
||||
| dopamine_baseline | health | focus_cognition | 동기/인지 |
|
||||
| meditation_focus | meditation | focus_cognition | 집중 |
|
||||
| omega3 | health | nutrition | 영양 |
|
||||
| creatine | health | nutrition | 영양 |
|
||||
| protein_target | health | nutrition | 영양 |
|
||||
| ... (총 34, Developer 가 staging 의 모든 id 확인 후 1차 효과 분류) | | | |
|
||||
|
||||
> **Developer 작업 지침**: 위 표는 샘플. 실제 staging/protocols.json 의 모든 34 id 를 읽고 1차 효과 기준으로 v2 category 할당. 모호하면 Architect 와 협의 (저널 노트). emotion_relationship 매핑 0 이어도 OK (빈 카테고리는 칩 자체 숨김).
|
||||
|
||||
## 7. 함수 명세 (Function Specs)
|
||||
|
||||
| 함수 | 책임 (1줄) | 시그니처 | 입력 | 출력 | 에러/실패 | 복잡? |
|
||||
|------|------------|----------|------|------|-----------|-------|
|
||||
| `DisplayCategory.values` (enum) | 8 enum 자체 | `enum` | none | enum | — | 단순 |
|
||||
| `DisplayCategory.fromProtocolCategory(String)` | DB category 문자열 → enum (`light_circadian` → `lightCircadian` 등) | `static DisplayCategory? fromProtocolCategory(String)` | DB 문자열 | enum or null | 미매칭 시 null (호출자가 throw) | 단순 |
|
||||
| `CatalogRepository.all()` | 3 source 읽고 CatalogItem 리스트 반환 | `Future<List<CatalogItem>> all()` | none | List<CatalogItem> 47 | DB 에러 throw | **복잡** → [fn-catalog_repository.md](./fn-catalog_repository.md) |
|
||||
| `CatalogRepository.byId(String)` | 단건 조회 (Preview 화면 진입 시) | `Future<CatalogItem?> byId(String)` | id | CatalogItem or null | DB 에러 throw | 단순 (lookup) |
|
||||
| `CatalogRepository.referencesByIds(List<String>)` | reference id 들 → ReferenceRow 리스트 | `Future<List<ReferenceRow>>` | ids | rows | DB 에러 throw | 단순 |
|
||||
| `groupByCategory(items)` (pure) | List<CatalogItem> → Map<DisplayCategory, List<CatalogItem>> | `Map<DisplayCategory, List<CatalogItem>> groupByCategory(List<CatalogItem>)` | items | grouped map (빈 카테고리 키 미포함) | — | 단순 |
|
||||
| `_summary(Protocol)` (pure) | Protocol.what 의 1줄 요약 추출 | `String _summary(Protocol p)` | Protocol | string ≤ 60자 | what 빈 문자열이면 title 반환 | 단순 |
|
||||
| `migrateV1ToV2(Migrator)` | onUpgrade 1→2 실행 | `Future<void> migrateV1ToV2(Migrator m)` | Migrator | void | SQL 실패 throw | **복잡** → [fn-migration_v1_to_v2.md](./fn-migration_v1_to_v2.md) |
|
||||
| `HabitListScreen._onCatalogPressed` | 갤러리 진입 콜백 | `void _onCatalogPressed(BuildContext)` | context | navigation | 없음 | 단순 |
|
||||
| `ProtocolGalleryScreen.build` | 칩 + 그리드 build | `Widget build(BuildContext, WidgetRef)` | context, ref | widget | provider 에러 → SnackBar | 단순 |
|
||||
| `ProtocolPreviewScreen.build` | 상세 build + 펼치기 카드 | `Widget build(BuildContext, WidgetRef)` | context, ref | widget | references provider 에러 → 섹션 숨김 | 단순 |
|
||||
| `CatalogCard` (widget) | 카드 1개 | `class CatalogCard extends StatelessWidget` | item | widget | — | 단순 |
|
||||
| `CategoryChipRow` (widget) | 가로 칩 줄 | `class CategoryChipRow extends StatelessWidget` | categories, selected, onSelect | widget | — | 단순 |
|
||||
| `ReferenceExpandCard` (widget) | ref 펼치기 카드 | `class ReferenceExpandCard extends StatelessWidget` | reference | widget | url 누르기 = `url_launcher` (선택, 본 이슈에선 텍스트 표시만) | 단순 |
|
||||
|
||||
> 복잡 함수 = 2개 (`CatalogRepository.all`, `migrateV1ToV2`). 각각 fn-*.md 작성.
|
||||
|
||||
## 8. 흐름 / 알고리즘
|
||||
|
||||
### 시나리오 A: 신규 설치 (onCreate)
|
||||
|
||||
1. v2 schema 그대로 적용 (`createAll`).
|
||||
2. SeedImporter 가 `protocols.json` (v2 category) 을 import.
|
||||
3. CHECK 제약 통과 — 정상.
|
||||
4. 사용자 첫 진입 → 빈 HabitListScreen + 카탈로그 CTA.
|
||||
|
||||
### 시나리오 B: v1 → v2 업그레이드 (onUpgrade)
|
||||
|
||||
1. `migration.onUpgrade(m, 1, 2)` 호출.
|
||||
2. `migrateV1ToV2(m)`:
|
||||
- **a.** `await m.deleteTable(db.protocols)` — Drift API 로 안전한 DROP.
|
||||
- **b.** `await m.createTable(db.protocols)` — 새 CHECK 로 재생성.
|
||||
- **c.** `await m.createIndex(IDX_protocols_category)` — 인덱스 복구.
|
||||
- **d.** `await (m.database.delete(db.metaKv)..where((t) => t.key.equals(kSeededV1Flag))).go()` — 시드 flag 클리어.
|
||||
3. 다음 부팅 시 `SeedImporter.importIfNeeded()` 가 flag 가 없음을 보고 reseed.
|
||||
4. 사용자 user-* 테이블 (Habits / Phases / TrackerEntries / ...) 0 영향.
|
||||
|
||||
### 시나리오 C: 카탈로그 갤러리 진입
|
||||
|
||||
1. 사용자 HabitListScreen 의 "🔍 카탈로그 탐색" 또는 AppBar IconButton 탭.
|
||||
2. `Navigator.push(MaterialPageRoute(ProtocolGalleryScreen))`.
|
||||
3. `ProtocolGalleryScreen.build`:
|
||||
- `final items = ref.watch(catalogItemsProvider)` — 47 item.
|
||||
- `final grouped = ref.watch(groupedByCategoryProvider)` — 7~8 카테고리 키.
|
||||
- `CategoryChipRow(categories: grouped.keys.toList(), selected: _selected)`
|
||||
- `GridView.builder(items: grouped[_selected] ?? items)`.
|
||||
4. 사용자 카드 탭 → `Navigator.push(ProtocolPreviewScreen(item: item))`.
|
||||
5. Preview 화면:
|
||||
- 본문 필드 전부 표시.
|
||||
- `if (item.referenceIds.isNotEmpty)` → `ref.watch(referencesByIdsProvider(item.referenceIds))` 펼치기 카드 N개.
|
||||
- 하단 "내 습관으로" `FilledButton(onPressed: null, ...)` + Tooltip.
|
||||
|
||||
### 시나리오 D: 빈 카테고리
|
||||
|
||||
1. `groupByCategory(items)` 가 47 item 을 그룹핑.
|
||||
2. 매핑 0 인 DisplayCategory 키 (예: emotionRelationship) 는 map 에 미포함.
|
||||
3. `CategoryChipRow` 는 `grouped.keys` 만 그림 — 빈 카테고리 칩 자체 미표시.
|
||||
4. 사용자는 빈 카테고리 존재 자체를 모르고, "전체" + N 카테고리 칩만 봄.
|
||||
|
||||
### 시나리오 E: reference 매칭 실패
|
||||
|
||||
1. Protocol.referenceIds = `["ref_xxx", "ref_yyy"]` 이지만 References 테이블에 `ref_yyy` 없음.
|
||||
2. `referencesByIdsProvider` 가 2개 중 1개만 반환 (DB 에서 매칭된 것만).
|
||||
3. Preview 의 ref 섹션이 발견된 1개만 카드로 표시.
|
||||
4. ref 0건 매칭이면 섹션 자체 숨김.
|
||||
|
||||
## 9. 엣지케이스 & 에러 처리
|
||||
|
||||
| 상황 | 처리 |
|
||||
|------|------|
|
||||
| Protocols.category 가 v2 7개 외 값 (이론상 불가, JSON 손상 시) | SeedImporter 가 CHECK 위배로 throw → 부팅 실패. 명시적이 graceful 보다 낫다 (잘못 시드한 빌드를 출시하지 않음) |
|
||||
| onUpgrade 가 실패 (SQL 에러) | drift 가 transaction 롤백 → DB 상태 보전. 사용자에겐 부팅 실패. dev 단말 1대 영향이라 수용. |
|
||||
| Protocol 의 v1 category=`meditation`/`motivation`/`habit`/`learning`/`diet` 가 v2 매핑 없음 | Developer 가 protocols.json 의 모든 34 id 를 1차 효과로 재분류 — staging 검증 step 에서 단위 테스트로 100% 매핑 보장. |
|
||||
| BreakProtocol 의 한국어 title 누락 (이론상 없음) | 시드 검증 단위 테스트에서 적발. |
|
||||
| 카드 그리드가 화면을 넘침 (작은 단말) | GridView.builder + SliverGridDelegateWithMaxCrossAxisExtent (240px) — 단말 폭에 맞게 1~3 컬럼 자동. |
|
||||
| Preview 의 `how` 가 빈 배열 | 섹션 자체 숨김. |
|
||||
| References URL 누르기 | 본 이슈에선 **텍스트 표시만** — `url_launcher` 패키지 도입은 #FF1 또는 별도. |
|
||||
| 사용자가 갤러리에서 즉시 뒤로 가기 | DB write 0건, R규칙 호출 0건 — AC-8 자명 만족. |
|
||||
| `groupedByCategoryProvider` 가 loading 상태 | 갤러리 화면 중앙 CircularProgressIndicator. |
|
||||
| `references.json` 매칭 시 url 이 `null` | url 라인 자체 숨김 + 다른 필드 (kind, title) 만 표시. |
|
||||
|
||||
### 안전한 기본값
|
||||
- onUpgrade 미정의 분기 (v3+) → `assert(false, 'Unknown upgrade from $from to $to')` — Phase 1 패턴 유지.
|
||||
- Repository 에러 → Riverpod `AsyncValue.error` 로 전파, UI 가 SnackBar 표시.
|
||||
- `_summary` 가 60자 초과 시 `...` 절단.
|
||||
|
||||
## 10. 테스트 계획
|
||||
|
||||
### 단위 테스트 (신규)
|
||||
|
||||
| AC | 테스트 파일 | 내용 |
|
||||
|----|------------|------|
|
||||
| AC-4, AC-9 | `test/domain/catalog/display_category_test.dart` | 모든 protocols.json id 가 fromProtocolCategory 로 정확히 1 DisplayCategory 에 매핑됨 (full coverage) + BreakProtocol → breakHabit + DietPattern → nutrition |
|
||||
| AC-4 | `test/data/db/migration_v1_to_v2_test.dart` | v1 schema 로 시작 → migrate → Protocols 테이블 v2 CHECK 적용 + 인덱스 복구 + kSeededV1Flag 클리어 + user 테이블 (Habits 등) 무변화 |
|
||||
| AC-2, AC-3, AC-7 | `test/data/catalog/catalog_repository_test.dart` | in-memory DB + seed → all() 가 47 item 반환 + groupByCategory 가 빈 카테고리 키 미포함 + byId / referencesByIds 동작 |
|
||||
| AC-2, AC-3 | `test/ui/protocol_gallery_screen_test.dart` | ProviderScope override 로 catalogItemsProvider 페이크 → 칩 N개 표시 + 카드 그리드 + 칩 선택 시 필터링 |
|
||||
| AC-5, AC-6, AC-7 | `test/ui/protocol_preview_screen_test.dart` | 각 필드 표시 + "내 습관으로" 버튼 disabled + tooltip 확인 + references 펼치기 카드 |
|
||||
|
||||
### 회귀 보호
|
||||
- 기존 88 테스트 0 회귀 — `Protocols.category` 변경이 영향 가능한 테스트는 `seed_importer_test.dart` 의 1차. 시드 JSON 갱신 + 단위 테스트 같이 갱신.
|
||||
- `flutter analyze` 0 issues.
|
||||
- APK release 빌드 성공.
|
||||
|
||||
### Mock 전략
|
||||
- DB = `AppDatabase.memory()` (기존 패턴).
|
||||
- ProviderScope override 로 widget test.
|
||||
- Migration test 는 raw SQLite 로 v1 schema 수동 생성 → migrate 호출 → CHECK 위배 확인 (negative test).
|
||||
|
||||
## 11. 리스크 & 대안 검토
|
||||
|
||||
### 본 설계서 결정
|
||||
|
||||
| 결정 | 채택 | 대안 | 근거 |
|
||||
|------|------|------|------|
|
||||
| Protocols.category CHECK 6 → 7 마이그레이션 | ✓ | (A) virtual mapping (DB 무변경, Dart Map) / (B) display_category 신규 컬럼 | Planner AC-4 가 "category 필드 재분류" 명시. DB 가 SoT 인 본 앱 철학상 컬럼이 정답. 가상 매핑은 시드 파일과 DB 가 분리되어 일관성 깨짐. |
|
||||
| schemaVersion 1→2 + 진짜 onUpgrade | ✓ | onUpgrade 무시 + 신규 설치만 지원 | 기존 dev 단말 (joungmin 본인) 의 v1 DB 가 있음. assert false 깨지면 부팅 실패. 한 번 제대로 만들면 후속 마이그레이션 패턴 재사용. |
|
||||
| 마이그레이션 = DROP + CREATE + reseed | ✓ | INSERT...SELECT 로 row-level 재맵핑 | Protocols 는 read-only catalog — drop 해도 데이터 손실 0 (시드에서 복원). row-level 재맵핑은 라벨 매핑 알고리즘 분리 필요해서 과한 복잡도. |
|
||||
| 빈 카테고리 칩 자체 숨김 | ✓ | 칩 회색 + "준비 중" | 사용자가 "왜 비어있지" 묻는 친화성 ↓. 빈 카테고리 존재를 모르게 하는 게 더 깔끔. emotion_relationship 추가는 향후 시드 갱신 시 자동 노출. |
|
||||
| break + diet 통합 view (CatalogItem sealed) | ✓ | 3개 화면 분리 (Protocol Gallery / Break Gallery / Diet Gallery) | 사용자는 "수면 관련 뭐 있나?" 같은 카테고리 중심 탐색 — source 별 분리는 사용자 멘탈모델과 직교. unified view 가 정답. |
|
||||
| "내 습관으로" 버튼 disabled placeholder | ✓ | 버튼 자체 없음 (다음 PR 에서 추가) | Preview 화면을 다음 이슈에서 또 수정하는 비용 방지 + 사용자에게 "다음 업데이트 예정" 시그널. UX 비용 0. |
|
||||
| ADR-0004 발행 | ✓ | ADR 없음 | 첫 schema 마이그레이션 = 향후 정책 (DROP+reseed for catalog vs row-migrate for user) 의 기준점. ADR 가치 충분. |
|
||||
| sealed class CatalogItem | ✓ | abstract class + downcast | Dart 3+ sealed pattern matching 안전 + 신규 source 추가 시 컴파일 에러로 강제. |
|
||||
|
||||
### 핵심 리스크
|
||||
|
||||
1. **카테고리 분류 의견 차** (예: cold_exposure = recovery? movement?) — Architect 1차 결정 (recovery_stress), Developer 가 staging 검증 시 모호 케이스를 저널 노트로 보고. QA 가 비논리 매핑 적발 권한.
|
||||
2. **emotion_relationship 빈 카테고리** — 현 시드 0 매핑 가능. 본 이슈 후 시드 보강은 별도 (#FF5+ 콘텐츠 추가).
|
||||
3. **첫 마이그레이션 버그** — onUpgrade 미작동 시 dev 단말 부팅 실패. 마이그레이션 unit test 가 1차 방어선.
|
||||
|
||||
### 되돌리기 어려운 결정 → ADR
|
||||
|
||||
- **ADR-0004** "Catalog re-categorization + first schema migration policy" 본 이슈에서 발행:
|
||||
- 결정 1: Catalog (read-only seed) 마이그레이션은 DROP + reseed
|
||||
- 결정 2: User (mutable) 테이블 마이그레이션은 row-preserving (해당 안 됨, 본 이슈)
|
||||
- 결정 3: DisplayCategory 8개 vs Storage category 7개 (Protocols) — Storage = 단일 source 7, breakHabit/nutrition 은 별도 source 라 enum 만 8.
|
||||
|
||||
## 12. 미해결 질문 (Open Questions)
|
||||
|
||||
| OQ | 질문 | 상태 | 권고 |
|
||||
|----|------|------|------|
|
||||
| **OQ-1** | `methodologies` / `frame_patterns` / `reward_menu_items` 도 갤러리에 노출? | OPEN → Planner OOS 명시. **본 이슈에서 미노출 확정**. | Phase 2-C 시점에 재검토 |
|
||||
| **OQ-2** | url_launcher 패키지 도입? | OPEN → 본 이슈에서 미도입 (텍스트 표시만). | #FF1 또는 별도 |
|
||||
| **OQ-3** | DietPattern 의 `koreanContextFit` 을 카드 배지로? | OPEN → 본 이슈에서 미표시 (Preview 만). | UX 피드백 후 결정 |
|
||||
| **OQ-4** | "내 습관으로" 버튼이 disabled 상태에서 사용자가 누르면 toast? | OPEN → tooltip 만 (Material 표준). | #FF1 에서 활성화 |
|
||||
| **OQ-5** | 갤러리 카드 정렬 순서 (id / evidence_strength / 가나다) | OPEN → **id 알파벳 순** (안정적 + 예측 가능). | Developer 결정 권한 |
|
||||
| **OQ-6** | references URL 없을 때 카드 표시 vs 숨김 | OPEN → **표시** (title + kind 만, url 라인 숨김). | Developer 결정 |
|
||||
|
||||
---
|
||||
|
||||
## 부록: 자가 점검 (Architect 종료 시 검증)
|
||||
|
||||
- [x] §1~§12 모든 섹션 채워짐
|
||||
- [x] Planner 10 AC 모두 §3 + §10 1:1 매핑
|
||||
- [x] 모든 함수 §7 표에 등재. 복잡 2개 (`CatalogRepository.all`, `migrateV1ToV2`) → fn-*.md 작성
|
||||
- [x] I/O ↔ 순수 로직 경계 §5 명시
|
||||
- [x] 데이터 모델 §6 (DisplayCategory enum + Protocols.category v2 CHECK + CatalogItem sealed) 정의
|
||||
- [x] 마이그레이션 시나리오 (신규/업그레이드 둘 다) §8 명시
|
||||
- [x] 에러 / 빈 카테고리 / ref 매칭 실패 §9 처리
|
||||
- [x] ADR-0004 발행 항목 §11
|
||||
- [x] out-of-scope §2 명확 (#FF1~#FF4 라벨)
|
||||
- [x] AC-8 (DB write 0건) 보장 매커니즘 §5 + §8
|
||||
191
docs/design/226-catalog-gallery/fn-catalog_repository.md
Normal file
191
docs/design/226-catalog-gallery/fn-catalog_repository.md
Normal file
@@ -0,0 +1,191 @@
|
||||
# 함수 설계서: `CatalogRepository.all` (#226)
|
||||
|
||||
> **부모 설계서**: ./README.md · **상태**: Draft
|
||||
> **작성**: [AI] Architect (2026-06-12) · **구현**: `app/lib/data/catalog/catalog_repository.dart` · **테스트**: `app/test/data/catalog/catalog_repository_test.dart`
|
||||
|
||||
## 1. 시그니처
|
||||
|
||||
```dart
|
||||
class CatalogRepository {
|
||||
CatalogRepository(this._db);
|
||||
final AppDatabase _db;
|
||||
|
||||
Future<List<CatalogItem>> all();
|
||||
Future<CatalogItem?> byId(String id);
|
||||
Future<List<ReferenceRow>> referencesByIds(List<String> ids);
|
||||
}
|
||||
```
|
||||
|
||||
본 fn-*.md 는 `all()` 의 알고리즘만 다룬다. `byId` / `referencesByIds` 는 단순 lookup 이므로 README §7 표 한 줄로 충분.
|
||||
|
||||
## 2. 책임 (단일 책임, 1줄)
|
||||
|
||||
3 source (Protocols / BreakProtocols / DietPatterns) 를 단일 `List<CatalogItem>` 으로 통합 — 본 이슈의 핵심 변환 한 점.
|
||||
|
||||
## 3. 입력
|
||||
|
||||
| 파라미터 | 타입 | 제약 | 설명 |
|
||||
|---|---|---|---|
|
||||
| (인스턴스 필드) `_db` | `AppDatabase` | non-null | seed 가 끝난 DB (시드 안 끝났으면 호출자가 보장) |
|
||||
|
||||
## 4. 출력
|
||||
|
||||
- **반환**: `List<CatalogItem>` — 총 47 항목 (protocols 34 + break 8 + diet 5).
|
||||
- 각 항목은 `ProtocolCatalogItem` / `BreakCatalogItem` / `DietCatalogItem` 중 하나 (sealed).
|
||||
- 정렬: `displayCategory.index` → `id` 알파벳 순.
|
||||
- **부수효과**: DB 3회 read. **write 0**.
|
||||
|
||||
## 5. 동작 / 알고리즘
|
||||
|
||||
```
|
||||
1. final protocolRows = await _db.select(_db.protocols).get(); // 34
|
||||
2. final breakRows = await _db.select(_db.breakProtocols).get(); // 8
|
||||
3. final dietRows = await _db.select(_db.dietPatterns).get(); // 5
|
||||
|
||||
4. final items = <CatalogItem>[];
|
||||
|
||||
5. for each p in protocolRows:
|
||||
final dc = DisplayCategory.fromProtocolCategory(p.category);
|
||||
if (dc == null) {
|
||||
throw StateError(
|
||||
'unknown protocol category "${p.category}" for id=${p.id}'
|
||||
);
|
||||
}
|
||||
items.add(ProtocolCatalogItem(
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
titleEn: p.titleEn,
|
||||
summary: _summary(p.what, fallback: p.title),
|
||||
displayCategory: dc,
|
||||
evidenceStrength: p.evidenceStrength,
|
||||
referenceIds: _decodeIds(p.referenceIdsJson),
|
||||
what: p.what,
|
||||
whenText: p.whenText,
|
||||
dose: p.dose,
|
||||
why: p.why,
|
||||
how: _decodeList(p.howJson),
|
||||
checkText: p.checkText,
|
||||
caution: p.caution,
|
||||
defaultAnchor: _decodeAnchor(p.defaultAnchorJson),
|
||||
minDoseForStart: p.minDoseForStart,
|
||||
sourceDoc: p.sourceDoc,
|
||||
));
|
||||
|
||||
6. for each b in breakRows:
|
||||
items.add(BreakCatalogItem(
|
||||
id: b.id,
|
||||
title: b.title,
|
||||
titleEn: null,
|
||||
summary: b.hubermanSummary, // 이미 1줄 요약 형태
|
||||
displayCategory: DisplayCategory.breakHabit,
|
||||
evidenceStrength: null, // BreakProtocol 스키마에 없음
|
||||
referenceIds: _decodeIds(b.referenceIdsJson),
|
||||
breakCategory: b.category, // 'alcohol' / 'nicotine' / ...
|
||||
hubermanSummary: b.hubermanSummary,
|
||||
phases: _decodeList(b.phasesJson),
|
||||
defaultCommonFrames: _decodeList(b.defaultCommonFramesJson),
|
||||
tools: _decodeList(b.toolsJson),
|
||||
medicalWarning: b.medicalWarning,
|
||||
));
|
||||
|
||||
7. for each d in dietRows:
|
||||
items.add(DietCatalogItem(
|
||||
id: d.id,
|
||||
title: d.name,
|
||||
titleEn: null,
|
||||
summary: d.core,
|
||||
displayCategory: DisplayCategory.nutrition,
|
||||
evidenceStrength: d.evidenceStrength,
|
||||
referenceIds: _decodeIds(d.referenceIdsJson),
|
||||
name: d.name,
|
||||
core: d.core,
|
||||
strengths: _decodeList(d.strengthsJson),
|
||||
weaknesses: _decodeList(d.weaknessesJson),
|
||||
koreanContextFit: d.koreanContextFit,
|
||||
starterLevers: _decodeList(d.starterLeversJson),
|
||||
medicalWarning: d.medicalWarning,
|
||||
linkedProtocolIds: _decodeIds(d.linkedProtocolIdsJson),
|
||||
));
|
||||
|
||||
8. items.sort((a, b) {
|
||||
final c = a.displayCategory.index - b.displayCategory.index;
|
||||
return c != 0 ? c : a.id.compareTo(b.id);
|
||||
});
|
||||
|
||||
9. return items;
|
||||
```
|
||||
|
||||
### 헬퍼 (file-private, 모두 pure)
|
||||
|
||||
```dart
|
||||
String _summary(String what, {required String fallback, int max = 60}) {
|
||||
final firstSentence = what.split(RegExp(r'[.!?。!?]')).first.trim();
|
||||
final s = firstSentence.isEmpty ? fallback : firstSentence;
|
||||
return s.length <= max ? s : '${s.substring(0, max - 1)}…';
|
||||
}
|
||||
|
||||
List<String> _decodeIds(String? json) {
|
||||
if (json == null) return const [];
|
||||
final decoded = jsonDecode(json);
|
||||
return decoded is List ? decoded.cast<String>() : const [];
|
||||
}
|
||||
|
||||
List<String> _decodeList(String? json) {
|
||||
if (json == null) return const [];
|
||||
final decoded = jsonDecode(json);
|
||||
return decoded is List ? decoded.map((e) => e.toString()).toList() : const [];
|
||||
}
|
||||
|
||||
Map<String, dynamic>? _decodeAnchor(String? json) {
|
||||
if (json == null) return null;
|
||||
final decoded = jsonDecode(json);
|
||||
return decoded is Map<String, dynamic> ? decoded : null;
|
||||
}
|
||||
```
|
||||
|
||||
## 6. 에러 & 실패 모드
|
||||
|
||||
| 조건 | 처리 | 반환/예외 |
|
||||
|---|---|---|
|
||||
| `protocol.category` 가 DisplayCategory 매핑 0 | 부팅 직후 첫 호출에서 throw → AsyncValue.error → SnackBar "카탈로그 손상" | `StateError('unknown protocol category "x" for id=y')` |
|
||||
| `howJson` / `referenceIdsJson` 파싱 실패 | 빈 리스트 반환 (graceful) | — |
|
||||
| DB 미시드 (kSeededV1Flag 없음) | 호출자가 `seedInProgressProvider` 로 막아야 함. 본 함수는 raw 결과 (0 row) 반환 — UI 가 "준비 중" 표시. | — |
|
||||
| 3 source 중 한 source 가 부분 손상 (예: protocols 0 row, break 정상) | partial 결과 반환 — 사용자가 break 만 봄. | — |
|
||||
| `_summary` 가 빈 문자열 | fallback (title) 사용. | — |
|
||||
|
||||
## 7. 엣지케이스
|
||||
|
||||
- **47 ≠ 실제 row 수**: 시드 갱신 후 row 수 변동 가능. 본 함수는 row 수 비검증 — `catalog_repository_test.dart` 가 일관성 검증.
|
||||
- **DisplayCategory 추가**: enum 에만 추가하고 매핑은 staging JSON 으로 들어가는 새 protocol 만 채움. 기존 47 항목 매핑 무변화.
|
||||
- **중복 id**: PK 제약상 발생 불가 — drift 가 보장.
|
||||
- **빈 summary**: `what` 이 punctuation 으로만 시작하면 `_summary` 가 빈 문자열 → fallback 적용.
|
||||
|
||||
## 8. 복잡도 / 성능
|
||||
|
||||
- 시간: O(N) — N=47, 사용자 화면 진입 1회.
|
||||
- 공간: O(N) — 47 인스턴스.
|
||||
- 호출 빈도: **갤러리 진입 시 1회** (Riverpod cache, 화면 사라질 때 dispose).
|
||||
- 실측 추정 latency: < 5ms on 8GB+ Android. 5초 cold start 영향 0.
|
||||
|
||||
## 9. 의존성
|
||||
|
||||
- 호출: `AppDatabase.select` x 3, `dart:convert` (jsonDecode), `DisplayCategory.fromProtocolCategory`.
|
||||
- 호출처: `catalogItemsProvider` (Riverpod `FutureProvider<List<CatalogItem>>`).
|
||||
|
||||
## 10. 테스트 케이스
|
||||
|
||||
- [ ] **정상**: seed 가 끝난 in-memory DB → all() 가 47 item + 정렬 (displayCategory.index → id)
|
||||
- [ ] **카테고리 매핑**: 모든 ProtocolCatalogItem.displayCategory ≠ null
|
||||
- [ ] **Break 단일 카테고리**: 모든 BreakCatalogItem.displayCategory == breakHabit
|
||||
- [ ] **Diet 단일 카테고리**: 모든 DietCatalogItem.displayCategory == nutrition
|
||||
- [ ] **summary 길이**: 모든 item 의 summary ≤ 60자
|
||||
- [ ] **referenceIds 디코딩**: 빈 JSON / null / 정상 케이스 3개
|
||||
- [ ] **에러**: 손상된 category 값을 직접 DB 에 insert 후 all() → StateError
|
||||
- [ ] **빈 DB**: seed 안 한 DB → 빈 리스트 (throw 안 함)
|
||||
- [ ] **byId 정상 / 미존재**: 2 케이스
|
||||
- [ ] **referencesByIds**: 일부 매칭 / 전부 미매칭 2 케이스
|
||||
|
||||
## 11. 추적성
|
||||
|
||||
- 인수조건: #226 AC-2 (카테고리 칩 표시), AC-3 (카드 그리드), AC-4 (8 카테고리 매핑), AC-7 (reference 매칭).
|
||||
- 관련 ADR: ADR-0004 (본 이슈에서 발행).
|
||||
120
docs/design/226-catalog-gallery/fn-migration_v1_to_v2.md
Normal file
120
docs/design/226-catalog-gallery/fn-migration_v1_to_v2.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# 함수 설계서: `migrateV1ToV2` (#226)
|
||||
|
||||
> **부모 설계서**: ./README.md · **상태**: Draft
|
||||
> **작성**: [AI] Architect (2026-06-12) · **구현**: `app/lib/data/db/app_database.dart` 의 `migration.onUpgrade` 인라인 또는 file-private top-level · **테스트**: `app/test/data/db/migration_v1_to_v2_test.dart`
|
||||
|
||||
## 1. 시그니처
|
||||
|
||||
```dart
|
||||
Future<void> _migrateV1ToV2(Migrator m, AppDatabase db) async { ... }
|
||||
```
|
||||
|
||||
`MigrationStrategy.onUpgrade` 에서 dispatch:
|
||||
|
||||
```dart
|
||||
onUpgrade: (m, from, to) async {
|
||||
if (from == 1 && to >= 2) {
|
||||
await _migrateV1ToV2(m, this); // this = AppDatabase
|
||||
}
|
||||
// future:
|
||||
// if (from <= 2 && to >= 3) await _migrateV2ToV3(m, this);
|
||||
if (from > to || to > schemaVersion) {
|
||||
assert(false, 'Unknown upgrade path: from=$from to=$to (schemaVersion=$schemaVersion)');
|
||||
}
|
||||
},
|
||||
```
|
||||
|
||||
## 2. 책임 (단일 책임, 1줄)
|
||||
|
||||
v1 DB 의 `protocols` 테이블을 v2 CHECK 제약으로 교체하고 시드 flag 클리어 — read-only catalog 의 첫 마이그레이션 패턴.
|
||||
|
||||
## 3. 입력
|
||||
|
||||
| 파라미터 | 타입 | 제약 | 설명 |
|
||||
|---|---|---|---|
|
||||
| `m` | `Migrator` | drift 의 schema migrator | DDL API |
|
||||
| `db` | `AppDatabase` | non-null | metaKv 클리어용 |
|
||||
|
||||
## 4. 출력
|
||||
|
||||
- **반환**: `Future<void>`.
|
||||
- **부수효과**:
|
||||
- `protocols` 테이블 DROP + CREATE (CHECK 제약 7 카테고리로) + 인덱스 `IDX_protocols_category` 재생성.
|
||||
- `meta_kv` 에서 `kSeededV1Flag` row DELETE.
|
||||
- 다음 부팅 시 `SeedImporter.importIfNeeded()` 가 재시드 트리거.
|
||||
- **user 테이블 (Habits, Phases, TrackerEntries, ...) 무변화**.
|
||||
|
||||
## 5. 동작 / 알고리즘
|
||||
|
||||
```
|
||||
1. await m.deleteTable(db.protocols);
|
||||
# SQLite: DROP TABLE protocols
|
||||
# 인덱스도 자동 cascade drop.
|
||||
|
||||
2. await m.createTable(db.protocols);
|
||||
# v2 schema 로 CREATE TABLE protocols (
|
||||
# id TEXT PRIMARY KEY,
|
||||
# category TEXT CHECK (category IN (
|
||||
# 'light_circadian','sleep','movement','nutrition',
|
||||
# 'focus_cognition','recovery_stress','emotion_relationship'
|
||||
# )) NOT NULL,
|
||||
# title TEXT NOT NULL,
|
||||
# ...
|
||||
# );
|
||||
|
||||
3. await m.createIndex(Index(
|
||||
'IDX_protocols_category',
|
||||
'CREATE INDEX IDX_protocols_category ON protocols(category)',
|
||||
));
|
||||
# drop 시 자동 cascade 됐어도 명시적 재생성.
|
||||
|
||||
4. await (db.delete(db.metaKv)
|
||||
..where((t) => t.key.equals(kSeededV1Flag)))
|
||||
.go();
|
||||
# 시드 flag 1 row 삭제. 다음 부팅이 importIfNeeded() 호출 → 새 JSON 으로 reseed.
|
||||
```
|
||||
|
||||
## 6. 에러 & 실패 모드
|
||||
|
||||
| 조건 | 처리 | 반환/예외 |
|
||||
|---|---|---|
|
||||
| DROP 실패 (이론상 없음, 사용자 락) | drift 가 transaction 롤백 → 부팅 실패. 사용자에겐 명시적 에러. | SqliteException 전파 |
|
||||
| CREATE 실패 (이론상 없음) | 동상 | SqliteException 전파 |
|
||||
| metaKv 삭제 실패 (이론상 없음) | 동상 — but **여기까지 도달 시 protocols 테이블은 v2 형태**. 다음 부팅 시 flag 가 'true' 인 채라 reseed 안 함 → protocols 빈 상태. **위험.** 그래서 metaKv 삭제는 트랜잭션 내 마지막 단계가 아니라 순서가 중요. | drift onUpgrade 전체가 트랜잭션 — drop/create 와 metaKv 삭제 같이 묶임. |
|
||||
| 다음 부팅 시 seed JSON 손상 | SeedImporter 가 CHECK 위배 throw → 부팅 실패. dev 단말 1대 영향이라 수용. | FormatException / SqliteException |
|
||||
|
||||
## 7. 엣지케이스
|
||||
|
||||
- **신규 설치 (`onCreate`)** — 본 함수 호출 0. createAll 이 v2 schema 그대로 적용 후 seed 가 v2 JSON 로드. 정상.
|
||||
- **v1 → v3+ 점프 (이론상 없음, 현재 schemaVersion=2)** — `from=1, to=3` 이면 v1→v2 → v2→v3 순차 실행 가정. `_migrateV2ToV3` 가 아직 없어 dispatch 가 발견 못 함 → assert false. v3 도입 시점에 명시.
|
||||
- **트랜잭션 중단** — drift 의 onUpgrade 는 db.transaction 안에서 실행. 부분 실패 시 자동 롤백 → 사용자 DB 는 v1 그대로. 다음 시도에서 재실행.
|
||||
- **사용자 데이터 보호** — 본 함수는 Protocols 만 건드림. Habits/TrackerEntries 등 user 테이블 0 영향. `migration_v1_to_v2_test.dart` 가 명시적 검증.
|
||||
- **인덱스 재생성 누락 시** — query latency ↓ 만 영향 (정상 동작). 본 함수가 명시적으로 createIndex 호출하므로 보호.
|
||||
|
||||
## 8. 복잡도 / 성능
|
||||
|
||||
- 시간: O(1) — DDL 4건.
|
||||
- 실측: < 50ms (dev 단말, drift 의 transaction overhead 포함).
|
||||
- 호출 빈도: **dev 단말 평생 1회** (v1 → v2 한 번). 사용자 신규 설치는 호출 0.
|
||||
|
||||
## 9. 의존성
|
||||
|
||||
- drift `Migrator` API (deleteTable, createTable, createIndex).
|
||||
- `kSeededV1Flag` 상수 (`core/constants.dart`).
|
||||
- AppDatabase 의 `protocols` getter (스키마 가져오기).
|
||||
|
||||
## 10. 테스트 케이스
|
||||
|
||||
- [ ] **smoke**: in-memory DB 를 v1 schema 로 raw SQL 로 생성 → `_migrateV1ToV2(m, db)` 호출 → protocols 테이블의 CHECK 제약이 v2 7 카테고리인지 검증 (PRAGMA / sqlite_master 조회)
|
||||
- [ ] **flag 클리어**: 사전에 metaKv 에 `seeded_v1='true'` insert → migrate → metaKv 조회 시 row 없음
|
||||
- [ ] **user 데이터 보호**: 사전에 Habits / Phases / TrackerEntries 에 row insert → migrate → 모두 그대로
|
||||
- [ ] **v2 CHECK 위배 negative**: migrate 후 `INSERT INTO protocols (..., category='health', ...)` 시도 → SqliteException
|
||||
- [ ] **v2 CHECK 통과 positive**: `category='light_circadian'` insert → 성공
|
||||
- [ ] **인덱스 존재**: migrate 후 `sqlite_master` 에서 `IDX_protocols_category` 발견
|
||||
- [ ] **이중 호출 안전성**: 동일 DB 에 migrate 2회 호출 → 두 번째도 성공 (idempotent 가정. drift `deleteTable` 이 미존재 테이블에 graceful 인지 확인 필요 — OQ)
|
||||
- [ ] **integration with onUpgrade**: schemaVersion=2 인 AppDatabase 로 v1 DB 열기 → onUpgrade 자동 호출 → 정상 동작
|
||||
|
||||
## 11. 추적성
|
||||
|
||||
- 인수조건: #226 AC-4 (8 카테고리 재분류 — DB CHECK 갱신 부분), AC-8 (user 테이블 무변화), AC-9 (마이그레이션 unit test).
|
||||
- 관련 ADR: **ADR-0004** (본 이슈에서 발행 — Catalog re-categorization + first schema migration policy).
|
||||
284
docs/design/260-gemma-tool-calling/README.md
Normal file
284
docs/design/260-gemma-tool-calling/README.md
Normal file
@@ -0,0 +1,284 @@
|
||||
# 설계서: On-device Gemma tool calling (#260)
|
||||
|
||||
> **상태**: Draft
|
||||
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
|
||||
> **추적성** — Redmine: #260 · 관련 ADR: ADR-0005 (신규)
|
||||
> · 구현 파일: `app/lib/ai/tools/`, `app/lib/ui/screens/chat_screen.dart`, `app/lib/data/ai/gemma_llm_service.dart` 일부 확장 · 테스트: `app/test/ai/tools/`, `app/test/ui/chat_screen_test.dart`
|
||||
|
||||
## 1. 목적 (Why)
|
||||
on-device Gemma 4 와의 대화만으로 **카탈로그 검색 → 습관 추가 → 체크 → 스트릭 조회**를 한 흐름에 끝낸다. MCP 와 동일한 capability 추상화를 in-process Dart 함수로 구현해 latency 를 거의 0 으로 만든다.
|
||||
|
||||
## 2. 범위 (Scope)
|
||||
- **포함**:
|
||||
- 6 개 tool 정의 + 핸들러 (`search_catalog`, `query_protocol`, `add_habit`, `list_active_habits`, `log_tracker_entry`, `get_streak`)
|
||||
- Multi-tool 대화 루프 (LLM 이 동적으로 tool 선택 → 핸들러 실행 → 결과 회신 → LLM 자연어 응답)
|
||||
- Destructive tool 의 모달 Confirm UI 게이트
|
||||
- Tool 응답 사이즈 가드 (≤ 2KB)
|
||||
- Tool 핸들러에서 R1~R10 enforce (모델 prompt 가 아닌 코드)
|
||||
- 신규 ChatScreen + AppBar entry
|
||||
- 6 핸들러 unit + 1 widget E2E 테스트
|
||||
- **제외 (out of scope)**:
|
||||
- 음성 인터페이스
|
||||
- 다국어 (한국어만)
|
||||
- 대화 history persistence (in-memory only, 앱 종료 시 휘발)
|
||||
- Streaming animation 의 고급 UI
|
||||
- 모델 prompt engineering 으로 R 규칙 학습
|
||||
- 자동 phase 전환 / reward 발급 tool
|
||||
|
||||
## 3. 인수조건 (Acceptance Criteria)
|
||||
Planner #260 의 AC-1~12 그대로:
|
||||
- [ ] AC-1: `search_catalog(category?, keyword?, limit≤10)` — summary (60자 truncate) 반환
|
||||
- [ ] AC-2: `query_protocol(id)` — Protocol/Break/Diet 전체 필드
|
||||
- [ ] AC-3: `add_habit(protocol_id, frame_level, framed_text, anchor?, dose?)` — Confirm UI 거침. 거부 시 `{cancelled: true}` 반환
|
||||
- [ ] AC-4: `list_active_habits()` — 활성 습관 id/title/type/protocol_id
|
||||
- [ ] AC-5: `log_tracker_entry(habit_id, value, date?)` — value ∈ {done, blank}
|
||||
- [ ] AC-6: `get_streak(habit_id)` — missing vs blank 구분 결과
|
||||
- [ ] AC-7: R1~R10 enforce 는 tool 핸들러 책임. 위반 시 `{error, reason}` 반환
|
||||
- [ ] AC-8: destructive tool 은 ConfirmDialog 의무. read-only 는 직접 실행
|
||||
- [ ] AC-9: tool result ≤ 2KB 가드. 초과 시 truncate + hint
|
||||
- [ ] AC-10: 기존 110 + 신규 (≥ 12 unit + 1 widget E2E) 통과
|
||||
- [ ] AC-11: tool 정의는 `lib/ai/tools/` 도메인별 분리
|
||||
- [ ] AC-12: 잘못된 args (스키마 위배) → validation error 반환, crash 금지
|
||||
|
||||
## 4. 컨텍스트 & 제약
|
||||
|
||||
### 의존성
|
||||
- **완료**: #218 (Gemma 4 통합 / `GemmaLlmService`), #226 (`CatalogRepository`, `DisplayCategory`)
|
||||
- **활용 surface**:
|
||||
- `HabitDao.insertWithVariants(HabitDraft)` — R8 + R9/R10 트랜잭션
|
||||
- `HabitDao.countActive({userId, type})` — R1/R2
|
||||
- `HabitDao.activeHabitsForUser(userId)` — list
|
||||
- `TrackerDao.recordCheckIn(TrackerEntryDraft)` — R5
|
||||
- `CatalogRepository.all()` / `byId()` — 카탈로그
|
||||
- `validateFrameLevel()` / `detectAvoidanceKeywords()` — R3/R7
|
||||
- `judgeActiveHabitQuota()` — R1/R2
|
||||
- `computeStreak(...)` — streak 계산
|
||||
- **라이브러리**: `flutter_gemma 0.16.5` — `Tool`, `ToolChoice.auto`, `FunctionCallResponse`, `ParallelFunctionCallResponse`, `TextResponse`
|
||||
|
||||
### 제약
|
||||
- **단일 사용자**: Phase 1 `kLocalDefaultUserId` 하드코딩. tool 인자에 user_id 없음.
|
||||
- **메모리**: Gemma 4 E2B 가 RAM 4GB+ 요구. tool args/result 는 추가로 모델 context 채움 → token budget 정책 필수.
|
||||
- **응답 시간**: in-process 호출이라 핸들러 자체는 < 50ms. 하지만 LLM round trip 2회 (tool call decision + 최종 응답) 가 user-perceived latency 의 95%.
|
||||
- **모달 race**: Confirm UI 가 떠 있는 동안 사용자가 chat 화면 dismiss 가능 — graceful cancellation 필요.
|
||||
|
||||
### 가정
|
||||
- `flutter_gemma 0.16.5` 의 `ToolChoice.auto` 가 multi-tool 동적 선택 지원 (← Developer 가 1차 검증).
|
||||
- 사용자는 한 turn 에 1 mutation 만 수행한다고 가정 (병렬 mutation tool 호출 시 첫 번째만 confirm, 나머지는 reject).
|
||||
|
||||
## 5. 아키텍처 개요
|
||||
|
||||
### 모듈 / 파일 구조 (신규)
|
||||
```
|
||||
app/lib/ai/tools/
|
||||
├── tool_registry.dart # 모든 ToolDefinition 모음 + Dispatcher 입구
|
||||
├── tool_dispatcher.dart # FunctionCallResponse → handler 라우팅 + result envelope
|
||||
├── tool_envelope.dart # ToolResult sealed (Ok, Err, Cancelled) + JSON 직렬화 + 2KB 가드
|
||||
├── confirm_gate.dart # destructive 호출 시 모달 표시 → bool 반환
|
||||
├── catalog_tools.dart # search_catalog, query_protocol 정의 + 핸들러
|
||||
├── habit_tools.dart # add_habit, list_active_habits 정의 + 핸들러
|
||||
└── tracker_tools.dart # log_tracker_entry, get_streak 정의 + 핸들러
|
||||
|
||||
app/lib/ui/screens/
|
||||
└── chat_screen.dart # ConsumerStatefulWidget. 메시지 리스트 + 입력 + tool call 표시
|
||||
|
||||
app/lib/state/
|
||||
└── chat_providers.dart # chatSessionProvider (StateNotifier), 등록 도구 리스트 provider
|
||||
|
||||
app/lib/data/ai/
|
||||
└── gemma_llm_service.dart # (확장) sendChatTurn(...) 새 메서드 — multi-tool loop 지원
|
||||
```
|
||||
|
||||
### 데이터 흐름
|
||||
```
|
||||
ChatScreen.send("아침 햇빛 프로토콜 알려줘")
|
||||
│
|
||||
▼
|
||||
ChatSessionController.userTurn(text)
|
||||
│ 1. 사용자 메시지 append
|
||||
│ 2. llm.sendChatTurn(history, tools) 호출 → stream
|
||||
▼
|
||||
GemmaLlmService.sendChatTurn(...) ← 신규 메서드
|
||||
│ 1. createChat(tools: registry.all(), toolChoice: auto) [세션 캐싱]
|
||||
│ 2. chat.add(userMessage)
|
||||
│ 3. stream = chat.generateResponseAsync()
|
||||
▼
|
||||
◇ ModelResponse 분기
|
||||
├─ TextResponse → controller.appendModelChunk(text)
|
||||
├─ ThinkingResponse → drop (memory: isThinking:false)
|
||||
└─ FunctionCallResponse(name, args)
|
||||
│
|
||||
▼
|
||||
ToolDispatcher.dispatch(name, args, context)
|
||||
│
|
||||
▼
|
||||
◇ ToolDefinition.isDestructive ?
|
||||
│ ├─ Y → ConfirmGate.show(context, tool, args) → bool
|
||||
│ │ └─ false → return ToolResult.cancelled()
|
||||
│ └─ N → 계속
|
||||
│
|
||||
▼
|
||||
handler(args, deps) async
|
||||
│ 1. args schema validate (try/catch)
|
||||
│ 2. R 규칙 enforce (R3/R5/R7/R8 등 호출)
|
||||
│ 3. Repository 호출
|
||||
│ 4. ToolResult.ok(payload) or ToolResult.err(code, reason)
|
||||
│
|
||||
▼
|
||||
ToolEnvelope.encode(result) → JSON String ≤ 2KB
|
||||
│ size > 2KB → truncate + hint
|
||||
▼
|
||||
chat.addToolResult(name, jsonString)
|
||||
│
|
||||
▼
|
||||
다시 generateResponseAsync() → TextResponse 로 마무리
|
||||
```
|
||||
|
||||
### I/O ↔ 순수 분리
|
||||
- **I/O**: `GemmaLlmService.sendChatTurn`, `ToolDispatcher.dispatch` (Repository 호출), `ConfirmGate.show` (UI)
|
||||
- **순수**:
|
||||
- `ToolEnvelope.encode/truncate` — JSON 직렬화 + 사이즈 가드
|
||||
- `ToolArgsValidator.validate(tool, args)` — schema 매칭
|
||||
- 각 도메인 R 규칙 함수 (기존)
|
||||
- **테스트 전략**: 핸들러 unit 테스트는 in-memory DB + 직접 호출. ConfirmGate 는 `WidgetTester` 로 모달 검증. Multi-tool loop 는 Mock LLM 으로 시뮬레이션 (모델 호출 안 함).
|
||||
|
||||
## 6. 데이터 모델
|
||||
|
||||
### ToolDefinition
|
||||
```dart
|
||||
class ToolDefinition {
|
||||
final String name; // 'search_catalog'
|
||||
final String description; // 모델이 보는 한국어 설명
|
||||
final Map<String, dynamic> parametersSchema; // JSON Schema (draft-07)
|
||||
final bool isDestructive; // true → ConfirmGate 거침
|
||||
final ToolHandler handler; // Future<ToolResult> Function(args, deps)
|
||||
}
|
||||
```
|
||||
|
||||
### ToolResult (sealed)
|
||||
```dart
|
||||
sealed class ToolResult {
|
||||
Map<String, dynamic> toJson();
|
||||
}
|
||||
final class ToolOk extends ToolResult {
|
||||
final Map<String, dynamic> data;
|
||||
}
|
||||
final class ToolErr extends ToolResult {
|
||||
final String code; // 'validation' | 'r3_quota' | 'r7_avoidance' | 'r8_xor' | ...
|
||||
final String reason; // 모델이 사용자에게 안내할 한국어
|
||||
}
|
||||
final class ToolCancelled extends ToolResult {
|
||||
// user dismissed confirm modal
|
||||
}
|
||||
```
|
||||
|
||||
### ChatMessage
|
||||
```dart
|
||||
sealed class ChatMessage {
|
||||
final DateTime ts;
|
||||
}
|
||||
final class UserChatMessage extends ChatMessage { final String text; }
|
||||
final class ModelChatMessage extends ChatMessage { final String text; }
|
||||
final class ToolCallChatMessage extends ChatMessage {
|
||||
final String toolName;
|
||||
final Map<String, dynamic> args;
|
||||
final ToolResult result;
|
||||
// UI 표시: "📦 search_catalog 호출 → 3개 결과"
|
||||
}
|
||||
```
|
||||
|
||||
### 입력 검증 규칙
|
||||
| Tool | 인자 | 제약 |
|
||||
|---|---|---|
|
||||
| search_catalog | category, keyword, limit | category ∈ DisplayCategory.values \| null, keyword ≤ 50자, limit ∈ [1,10] |
|
||||
| query_protocol | id | non-empty String |
|
||||
| add_habit | protocol_id, frame_level, framed_text, anchor?, dose? | protocol_id ∈ existing catalog ids, frame_level ∈ {L2, L3}, framed_text ≤ 200자 |
|
||||
| list_active_habits | (없음) | — |
|
||||
| log_tracker_entry | habit_id, value, date? | habit_id ∈ existing habits, value ∈ {done, blank}, date YYYY-MM-DD 또는 null(=today) |
|
||||
| get_streak | habit_id | habit_id ∈ existing habits |
|
||||
|
||||
## 7. 함수 명세 (Function Specs)
|
||||
복잡 함수는 별도 파일:
|
||||
- `fn-tool_dispatcher.md` — multi-tool 라우팅 + envelope + Confirm gate 통합
|
||||
- `fn-add_habit_handler.md` — destructive 핸들러 대표. R3/R7/R8 enforce + HabitDraft 빌드
|
||||
- `fn-confirm_gate.md` — 모달 UI 흐름 + race 조건
|
||||
- `fn-chat_session_controller.md` — Multi-tool loop 의 상태 머신
|
||||
|
||||
단순 함수 (직접 구현):
|
||||
- `search_catalog_handler` / `query_protocol_handler` / `list_active_habits_handler` / `get_streak_handler` — Repository 호출 후 envelope 만 씌움
|
||||
- `ToolEnvelope.encode` / `truncate` — JSON encode + 2KB cap + 말미 hint
|
||||
|
||||
## 8. 변경 영향 / 기존 코드 수정
|
||||
| 파일 | 변경 |
|
||||
|---|---|
|
||||
| `lib/data/ai/gemma_llm_service.dart` | `sendChatTurn(history, tools)` 신규 메서드. 기존 `generateStructured` 는 유지 (frame suggest 가 사용 중). |
|
||||
| `lib/data/ai/llm_service.dart` | `LlmService` 인터페이스에 `sendChatTurn(...)` 추가. `MockLlmService` 도 갱신. |
|
||||
| `lib/state/ai_providers.dart` | 변경 없음. |
|
||||
| `lib/ui/screens/habit_list_screen.dart` | AppBar 에 chat icon → ChatScreen 진입 (검색 옆). |
|
||||
| `lib/state/providers.dart` | 변경 없음. |
|
||||
| `lib/domain/rules/active_habit_quota.dart` | 기존 함수 그대로 활용. 호출 위치만 핸들러로 확장. |
|
||||
|
||||
## 9. 비기능 요구
|
||||
- **Latency**: tool 1 회당 LLM round trip 2 회. 모델 응답 평균 2~5 초 (E2B), 핸들러 자체 < 100ms.
|
||||
- **메모리**: tool args/result 가 모델 context 에 누적. 8 turn 후 reset 권장 (OQ-2 정책).
|
||||
- **A11y**: ConfirmDialog 는 Semantics + autofocus 첫 액션 버튼.
|
||||
- **i18n**: 한국어만. tool description 도 한국어.
|
||||
- **로깅**: tool call 이벤트 (name, success/err code) 만 — args/result payload 는 로깅 금지 (PII 누출 차단).
|
||||
|
||||
## 10. 테스트 전략
|
||||
### Unit (≥ 12)
|
||||
- `tool_envelope_test.dart` — encode/decode round-trip, 2KB truncate, error 직렬화 (3)
|
||||
- `catalog_tools_test.dart` — search_catalog (category 필터/keyword/limit) + query_protocol (정상/미존재) (4)
|
||||
- `habit_tools_test.dart` — add_habit 성공 + R3 차단 + R7 차단 + R8 차단, list_active_habits (5)
|
||||
- `tracker_tools_test.dart` — log_tracker_entry 성공 + R5 차단 + date 기본값, get_streak (4)
|
||||
- `tool_dispatcher_test.dart` — unknown tool, validation fail, ok 경로 (3)
|
||||
|
||||
### Widget (≥ 1)
|
||||
- `chat_screen_test.dart` — 시드 후 "아침 햇빛 추가해줘" → mock LLM 이 add_habit tool call → ConfirmDialog 노출 → 확인 → habit row 1개 증가 검증
|
||||
|
||||
### Integration (선택)
|
||||
- 실 단말 manual : 카탈로그 검색 + 습관 추가 + 체크 흐름 1회 (QA 단계).
|
||||
|
||||
## 11. Open Questions (구현 중 답)
|
||||
|
||||
### Planner 의 OQ
|
||||
- **OQ-1 (idempotency)**: 동일 mutation 연속 호출 처리
|
||||
- **결정**: tool 핸들러는 (구분 키, time window) 기반 dedup 없음. 대신 **응답에 의미 있는 ID 를 항상 포함** → 모델이 후속 turn 에서 "이미 추가됨" 인지. Repository 의 unique constraint 가 최종 안전망 (TrackerEntries (habit_id, date) UNIQUE 가정 — Developer 확인 필요).
|
||||
- 이유: 시간 기반 dedup 은 사용자가 의도적으로 "다시 시도" 한 경우와 구분 못함. 명시적 confirm UI 가 이미 안전 게이트.
|
||||
|
||||
- **OQ-2 (token budget)**:
|
||||
- **결정**: tool result 직렬화 후 **2048 bytes hard cap**. 초과 시 마지막 1KB 를 `"... (잘림) 더 보려면 query_protocol 사용"` 으로 대체. 카탈로그 list 응답은 항상 summary(60자) + id 만, 상세는 별도 tool 호출.
|
||||
- 추가: chat history 가 8 turn 초과 시 controller 가 "지난 대화를 정리할까요?" 안내 — 자동 reset 은 안 함 (사용자가 선택).
|
||||
|
||||
- **OQ-3 (Confirm UI)**: 모달 확정 (사용자 결정 2026-06-15)
|
||||
- `ConfirmGate.show(context, ToolDefinition, args)` 가 `showDialog<bool>` 으로 AlertDialog 표시. 제목 = "이 작업을 수행할까요?", 본문 = tool description + args 의 사람 친화 요약 (예: "프로토콜 '아침 햇빛'을 L2 프레임으로 새 습관 추가"), 액션 = "취소" / "수행". 모달이 뜨는 동안 chat 입력은 disable.
|
||||
|
||||
- **OQ-4 (schema SoT)**:
|
||||
- **결정**: **Dart 코드** 가 SoT. 각 `ToolDefinition` 의 `parametersSchema` 는 Dart 리터럴 Map. 이유:
|
||||
1. yaml 추가 시 codegen + 버전 동기화 부담
|
||||
2. 핸들러 시그니처 와 schema 가 같은 파일에 있어야 drift 방지
|
||||
3. 자동완성 + 리팩터링 도구 활용 (rename, find-usages)
|
||||
- 추후 schema 가 수십 개 이상이면 ADR 후속에서 재논의.
|
||||
|
||||
### 신규 OQ (Developer 가 구현 중 답)
|
||||
- **OQ-5**: `flutter_gemma 0.16.5` 의 `ToolChoice.auto` 가 multi-tool 동적 선택 + tool 호출 안 함 (TextResponse) 모두 지원하는가? — Developer 가 small probe 작성. 미지원 시 fallback = `ToolChoice.required` + meta-tool ("any_action"/"reply_only").
|
||||
- **OQ-6**: `chat.addToolResult(...)` API 명 + 시그니처 정확히 (`Tool result message`, `addFunctionResult` 등 변형 가능) — flutter_gemma changelog 확인.
|
||||
- **OQ-7**: `TrackerEntries(habit_id, date)` UNIQUE 제약 존재 여부 — 없으면 마이그레이션 추가 vs 핸들러 levelup 결정.
|
||||
|
||||
## 12. ADR 후보
|
||||
- **ADR-0005**: "In-app tool calling architecture (MCP-equivalent)" — 발행 예정.
|
||||
- 결정 1: tool runtime 은 in-process Dart (MCP 서버 별도 띄우지 않음)
|
||||
- 결정 2: R 규칙 enforce 는 tool 핸들러 책임 (모델 prompt 아님)
|
||||
- 결정 3: schema SoT = Dart 코드
|
||||
- 결정 4: destructive tool = 모달 Confirm 게이트 의무
|
||||
|
||||
## 13. 작업 분할 (Developer 가 참고)
|
||||
1. ADR-0005 발행 (Architect 발행 — 본 작업과 동시)
|
||||
2. `ToolDefinition` + `ToolResult` + `ToolEnvelope` 골격
|
||||
3. `catalog_tools` 2 핸들러 (read-only) + 테스트
|
||||
4. `tracker_tools` 2 핸들러 + 테스트
|
||||
5. `habit_tools` 2 핸들러 + R 규칙 enforce + 테스트
|
||||
6. `ToolDispatcher` + `ConfirmGate` + 테스트
|
||||
7. `GemmaLlmService.sendChatTurn` + `LlmService` 확장 + `MockLlmService` 갱신
|
||||
8. `ChatScreen` + `ChatSessionController` + 위젯 테스트
|
||||
9. `HabitListScreen` AppBar 진입점
|
||||
10. 회귀 (전체 110 + 신규)
|
||||
141
docs/design/260-gemma-tool-calling/fn-add_habit_handler.md
Normal file
141
docs/design/260-gemma-tool-calling/fn-add_habit_handler.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# 함수 설계서: `addHabitHandler` (#260)
|
||||
|
||||
> **부모 설계서**: ./README.md · **상태**: Draft
|
||||
> **작성**: [AI] Architect · **구현**: `lib/ai/tools/habit_tools.dart:addHabitHandler` · **테스트**: `test/ai/tools/habit_tools_test.dart`
|
||||
|
||||
## 1. 시그니처
|
||||
```dart
|
||||
Future<ToolResult> addHabitHandler(
|
||||
Map<String, dynamic> args,
|
||||
ToolDeps deps,
|
||||
);
|
||||
```
|
||||
|
||||
대표 destructive 핸들러. 다른 destructive (`log_tracker_entry`) 도 동일 패턴을 따름.
|
||||
|
||||
## 2. 책임 (단일 책임, 1줄)
|
||||
LLM 이 제안한 새 습관 args 를 검증 → R1~R10 enforce → `HabitDao.insertWithVariants` 호출 → 결과 envelope 반환.
|
||||
|
||||
## 3. 입력
|
||||
| 파라미터 | 타입 | 제약 | 설명 |
|
||||
|---|---|---|---|
|
||||
| `args.protocol_id` | String | non-empty, catalog 에 존재 | 사용자가 선택한 카탈로그 ID |
|
||||
| `args.frame_level` | String | ∈ {"L2", "L3"} | R3: L0/L1 reject |
|
||||
| `args.framed_text` | String | 1~200자 | 사용자가 보는 문장 |
|
||||
| `args.anchor_when` | String? | ≤ 50자 | 선택 — 예: "기상 후" |
|
||||
| `args.anchor_after_what` | String? | ≤ 50자 | 선택 — 예: "세수" |
|
||||
| `args.dose_text` | String? | ≤ 100자 | 선택 — variant 단일 생성 (없으면 시드의 default_dose) |
|
||||
| `deps` | ToolDeps | non-null | userId / HabitDao / CatalogRepository |
|
||||
|
||||
**`type` 인자 없음** — catalog item 의 종류 (Protocol/Break) 에 따라 자동 결정. 핸들러가 catalog lookup 으로 판정.
|
||||
|
||||
## 4. 출력
|
||||
- **반환**: `Future<ToolResult>`.
|
||||
- `ToolOk(data: {habit_id, title, type, frame_level})` 성공
|
||||
- `ToolErr(code: 'validation' | 'not_found' | 'r3_quota' | 'r7_avoidance' | 'r8_xor', reason: 한국어)`
|
||||
- **부수효과**: 성공 시 DB write (Habits + HabitDoseVariants 트랜잭션). 실패 시 무변화.
|
||||
|
||||
## 5. 동작 / 알고리즘
|
||||
```
|
||||
1. validate args (schema 는 dispatch 가 이미 했지만, 의미 검증 추가):
|
||||
- protocol_id = args['protocol_id'] (String)
|
||||
- frameLevel = FrameLevel.fromString(args['frame_level'])
|
||||
if frameLevel == null || frameLevel in {l0, l1}:
|
||||
return ToolErr('validation', 'frame_level 은 L2 또는 L3 이어야 합니다.')
|
||||
- framedText = args['framed_text']
|
||||
if framedText.trim().isEmpty:
|
||||
return ToolErr('validation', 'framed_text 가 비어있습니다.')
|
||||
|
||||
2. catalog lookup (1차 효과로 type 결정):
|
||||
item = await deps.catalog.byId(protocol_id)
|
||||
if item == null:
|
||||
return ToolErr('not_found', '카탈로그에서 \'$protocol_id\' 를 찾을 수 없습니다.')
|
||||
if item is ProtocolCatalogItem: habitType = build
|
||||
else if item is BreakCatalogItem: habitType = breakHabit
|
||||
else if item is DietCatalogItem: habitType = build // diet 는 build 로 매핑
|
||||
else: return ToolErr('validation', '지원되지 않는 카탈로그 타입')
|
||||
|
||||
3. R7 avoidance keyword 검증:
|
||||
patterns = await framePatterns() // ref 로 캐시된 것 활용 권장 (deps 에 주입)
|
||||
hits = detectAvoidanceKeywords(framedText, patterns)
|
||||
if hits.isNotEmpty:
|
||||
return ToolErr('r7_avoidance',
|
||||
'L${frameLevel} 프레임에 회피 키워드 "${hits.first.keyword}" 가 감지됐어요. '
|
||||
'"${hits.first.suggestion}" 같은 표현으로 다시 시도해주세요.')
|
||||
|
||||
4. R3 active habit quota 검증:
|
||||
count = await deps.habitDao.countActive(userId: deps.userId, type: habitType)
|
||||
quota = judgeActiveHabitQuota(type: habitType, currentActiveCount: count)
|
||||
if !quota.allowed:
|
||||
return ToolErr('r3_quota', quota.reason)
|
||||
|
||||
5. HabitDraft 빌드 (R8 XOR enforce 는 HabitDao 내부 assertion):
|
||||
draft = HabitDraft(
|
||||
userId: deps.userId,
|
||||
type: habitType,
|
||||
title: item.title,
|
||||
protocolId: habitType == HabitType.build ? protocol_id : null,
|
||||
breakProtocolId: habitType == HabitType.breakHabit ? protocol_id : null,
|
||||
frameLevel: frameLevel,
|
||||
frameFramedText: framedText,
|
||||
anchorWhen: args['anchor_when'],
|
||||
anchorAfterWhat: args['anchor_after_what'],
|
||||
startedAt: today_kst_yyyymmdd(),
|
||||
variants: dose_text != null
|
||||
? [VariantDraft(label: '기본', doseText: dose_text, isMinimum: false, sortOrder: 0)]
|
||||
: [], // 빈 variants 면 HabitDao 가 시드 default 로 채우거나 그대로 빈 채로 저장
|
||||
)
|
||||
|
||||
6. insert:
|
||||
habitId = await deps.habitDao.insertWithVariants(draft)
|
||||
// R8 위배 시 assertXorProtocol 가 throw → catch → return ToolErr('r8_xor', ...)
|
||||
|
||||
7. return ToolOk(data: {
|
||||
'habit_id': habitId,
|
||||
'title': item.title,
|
||||
'type': habitType.name,
|
||||
'frame_level': frameLevel.name,
|
||||
})
|
||||
```
|
||||
|
||||
## 6. 에러 & 실패 모드
|
||||
| 조건 | 처리 | 반환 |
|
||||
|---|---|---|
|
||||
| frame_level L0/L1 | 사용자 안내 | `ToolErr('validation', '...')` |
|
||||
| protocol_id 미존재 | 사용자 안내 | `ToolErr('not_found', '...')` |
|
||||
| 회피 키워드 감지 | 사용자 안내 + 대안 제시 | `ToolErr('r7_avoidance', '...')` |
|
||||
| R3 quota 초과 | 사용자 안내 | `ToolErr('r3_quota', '...')` |
|
||||
| R8 XOR 위배 (이론상 불가, 안전망) | 로깅 + 안내 | `ToolErr('r8_xor', '...')` |
|
||||
| HabitDao 예외 | dispatch 가 catch → `ToolErr('handler_error', ...)` | |
|
||||
|
||||
**불변식**: 모든 error path 는 한국어 reason 을 포함. 모델이 이를 받아 사용자에게 안내.
|
||||
|
||||
## 7. 엣지케이스
|
||||
- **공백 anchor**: trim 후 empty → null 로 변환.
|
||||
- **catalog 이 DietCatalogItem 인데 frame_level 이 L3**: 통과. (diet 도 정체성 프레임 가능)
|
||||
- **frame_level "l2" 소문자**: `FrameLevel.fromString` 이 정규화. 미지원 시 validation error.
|
||||
- **중복 추가**: 같은 protocol_id + framed_text 로 재호출 — 별도 dedup 없음. R3 quota 가 3 째에서 차단. (OQ-1)
|
||||
- **anchor_when 만 있고 anchor_after_what 없음**: 모두 허용 (어느 하나만 있어도 됨).
|
||||
|
||||
## 8. 복잡도 / 성능
|
||||
- DB query 2회 (catalog byId + countActive) + 1회 write 트랜잭션.
|
||||
- `framePatterns` 매번 로드 시 N (≈ 30) 패턴 keyword 매칭 — O(text_len × pattern_count). text ≤ 200자, patterns ≤ 30 → 무시 가능.
|
||||
- 평균 < 100ms.
|
||||
|
||||
## 9. 테스트 케이스 (필수)
|
||||
| 케이스 | 사전 | 입력 | 기대 |
|
||||
|---|---|---|---|
|
||||
| 정상 build | seed + 0 active | `{protocol_id: 'morning_sunlight', frame_level: 'L2', framed_text: '아침에 햇빛 보기'}` | ToolOk, habits row +1 |
|
||||
| 정상 break | seed + 0 active | `{protocol_id: 'alcohol', ...}` | ToolOk, type=breakHabit |
|
||||
| L0 reject | seed | `frame_level: 'L0'` | ToolErr('validation') |
|
||||
| 미존재 protocol | seed | `protocol_id: 'no_such'` | ToolErr('not_found') |
|
||||
| 회피 키워드 | seed | `framed_text: '술 끊기'` | ToolErr('r7_avoidance') |
|
||||
| R3 quota (build 3개) | seed + build 3개 | 새 build 추가 | ToolErr('r3_quota') |
|
||||
|
||||
## 10. 의존
|
||||
- `CatalogRepository.byId(id)`
|
||||
- `HabitDao.countActive(...)`, `HabitDao.insertWithVariants(draft)`
|
||||
- `judgeActiveHabitQuota(...)`
|
||||
- `detectAvoidanceKeywords(text, patterns)`
|
||||
- `framePatterns` (ToolDeps 에 주입)
|
||||
- `FrameLevel`, `HabitType`, `HabitDraft`, `VariantDraft`
|
||||
146
docs/design/260-gemma-tool-calling/fn-chat_session_controller.md
Normal file
146
docs/design/260-gemma-tool-calling/fn-chat_session_controller.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# 함수 설계서: `ChatSessionController.userTurn` (#260)
|
||||
|
||||
> **부모 설계서**: ./README.md · **상태**: Draft
|
||||
> **작성**: [AI] Architect · **구현**: `lib/state/chat_providers.dart:ChatSessionController.userTurn` · **테스트**: `test/state/chat_session_controller_test.dart`
|
||||
|
||||
## 1. 시그니처
|
||||
```dart
|
||||
class ChatSessionController extends StateNotifier<ChatSessionState> {
|
||||
Future<void> userTurn(String text, BuildContext context);
|
||||
}
|
||||
|
||||
class ChatSessionState {
|
||||
final List<ChatMessage> messages; // append-only in-memory
|
||||
final bool isStreaming; // 모델 응답 중 → 입력 disabled
|
||||
final String? streamingText; // 부분 텍스트 누적
|
||||
final String? error; // 마지막 에러 (null = OK)
|
||||
}
|
||||
```
|
||||
|
||||
## 2. 책임 (단일 책임, 1줄)
|
||||
사용자 메시지를 받아 LLM ↔ Tool Dispatcher 의 multi-turn loop 을 돌리고, 메시지 상태를 갱신한다.
|
||||
|
||||
## 3. 입력
|
||||
| 파라미터 | 타입 | 제약 | 설명 |
|
||||
|---|---|---|---|
|
||||
| `text` | String | non-empty after trim | 사용자가 입력한 자연어 |
|
||||
| `context` | BuildContext | mounted | ConfirmGate 가 사용 |
|
||||
|
||||
## 4. 출력
|
||||
- **반환**: `Future<void>`. 결과는 state.messages 와 streamingText 에 반영.
|
||||
- **부수효과**:
|
||||
- state 다중 갱신 (StateNotifier.state = ...)
|
||||
- LLM 호출 (I/O)
|
||||
- Tool dispatcher 호출 (DB write 가능)
|
||||
- ConfirmGate 모달 표시 가능
|
||||
|
||||
## 5. 동작 / 알고리즘
|
||||
```
|
||||
1. 입력 validate:
|
||||
if text.trim().isEmpty: return
|
||||
if state.isStreaming: return // 중복 호출 차단
|
||||
|
||||
2. user 메시지 append:
|
||||
state = state.copyWith(
|
||||
messages: [...state.messages, UserChatMessage(text)],
|
||||
isStreaming: true,
|
||||
streamingText: '',
|
||||
error: null,
|
||||
)
|
||||
|
||||
3. tool registry 와 deps 준비:
|
||||
tools = ToolRegistry.allDefinitions()
|
||||
deps = ref.read(toolDepsProvider)
|
||||
llm = ref.read(llmServiceProvider)
|
||||
|
||||
4. multi-turn loop (최대 MAX_TURNS=4 — tool 호출 chain 보호):
|
||||
for (var turn = 0; turn < MAX_TURNS; turn++) {
|
||||
stream = llm.sendChatTurn(
|
||||
userInput: turn == 0 ? text : null, // 0 turn 만 user text, 이후는 tool result
|
||||
toolResultToSubmit: turn == 0 ? null : pendingToolResult,
|
||||
tools: tools,
|
||||
)
|
||||
|
||||
toolCallToHandle = null
|
||||
accumulatedText = ''
|
||||
|
||||
await for (event in stream) {
|
||||
switch event:
|
||||
case TextResponse(text):
|
||||
accumulatedText += text
|
||||
state = state.copyWith(streamingText: accumulatedText)
|
||||
case FunctionCallResponse(name, args):
|
||||
toolCallToHandle = (name, args)
|
||||
break // 스트림 stop, tool 처리로 분기
|
||||
case ThinkingResponse: skip
|
||||
}
|
||||
|
||||
if toolCallToHandle == null:
|
||||
// 모델이 자연어 응답으로 마무리
|
||||
state = state.copyWith(
|
||||
messages: [...state.messages, ModelChatMessage(accumulatedText)],
|
||||
streamingText: null,
|
||||
isStreaming: false,
|
||||
)
|
||||
return
|
||||
|
||||
// tool 처리
|
||||
result = await ToolDispatcher.dispatch(
|
||||
toolName: toolCallToHandle.name,
|
||||
rawArgs: toolCallToHandle.args,
|
||||
confirmContext: context,
|
||||
deps: deps,
|
||||
)
|
||||
state = state.copyWith(
|
||||
messages: [...state.messages,
|
||||
ToolCallChatMessage(toolCallToHandle.name, toolCallToHandle.args, result)],
|
||||
streamingText: '',
|
||||
)
|
||||
pendingToolResult = (toolCallToHandle.name, result.toJson())
|
||||
}
|
||||
|
||||
// MAX_TURNS 초과 → 안전 종료
|
||||
state = state.copyWith(
|
||||
error: '도구 호출 루프가 너무 길어 중단했습니다.',
|
||||
isStreaming: false,
|
||||
streamingText: null,
|
||||
)
|
||||
```
|
||||
|
||||
## 6. 에러 & 실패 모드
|
||||
| 조건 | 처리 | state |
|
||||
|---|---|---|
|
||||
| 빈 입력 | early return | unchanged |
|
||||
| 동시 호출 (isStreaming) | early return | unchanged |
|
||||
| LLM stream 예외 | catch | `error: 'LLM 응답 실패: ${e.type}'`, isStreaming:false |
|
||||
| MAX_TURNS 초과 | safety break | `error: '...너무 길어...'` |
|
||||
| tool result 직렬화 실패 (이론상 없음) | catch | tool ToolErr 대체 |
|
||||
|
||||
## 7. 엣지케이스
|
||||
- **사용자가 stream 중 chat 화면 dismiss**: StateNotifier 가 dispose 되어도 진행 중인 `await` 는 계속됨. side effect (DB write) 가 이미 시작됐을 수 있으니 graceful 하게 무시 — `mounted` 체크로 state 갱신만 skip.
|
||||
- **연속 tool 호출**: LLM 이 search_catalog → query_protocol → add_habit 같이 3 turn 돌 수 있음. MAX_TURNS=4 가 안전망. AC 시나리오 대부분 1~2 turn 종료.
|
||||
- **tool 호출 후 LLM 이 또 같은 tool 호출 (loop)**: MAX_TURNS 가 차단. 추가로 핸들러는 idempotent 결과 반환하지만 R3 quota 등이 2번째 호출에서 차단.
|
||||
- **chat history 8 turn 초과**: 현재 turn 끝나면 "지난 대화를 정리할까요?" 안내 메시지 (`SystemChatMessage`) 자동 append. clear 는 사용자 액션.
|
||||
- **모달 confirm 대기 중 사용자가 화면 dismiss**: ConfirmGate 내부 mounted 가드가 false 반환 → ToolCancelled → loop 계속 (또는 LLM 이 마무리).
|
||||
|
||||
## 8. 복잡도 / 성능
|
||||
- per-turn: LLM round trip (~2~5초 E2B) + handler (<100ms).
|
||||
- 총 latency: 2~3 turn 으로 끝나는 시나리오 평균 5~10 초.
|
||||
- 메모리: messages 리스트가 메모리 누적. clear 안 하면 무한 — 단 chat history persist X 이므로 앱 종료 시 GC.
|
||||
|
||||
## 9. 테스트 케이스 (필수)
|
||||
Mock LLM 으로 시뮬레이션. 실 모델 호출 안 함.
|
||||
| 케이스 | LLM mock 시퀀스 | 기대 state |
|
||||
|---|---|---|
|
||||
| 자연어 응답만 | `[TextResponse('안녕!')]` | messages = [user, model], isStreaming=false |
|
||||
| 1 tool call + 응답 | `[FunctionCallResponse('search_catalog', {category:'sleep'})]` → tool result → `[TextResponse('카페인 protocol...')]` | messages = [user, toolCall, model] |
|
||||
| destructive cancel | `add_habit` call → ConfirmGate mock false | toolCall message 의 result = ToolCancelled |
|
||||
| MAX_TURNS 초과 | LLM 이 매번 tool call | error 세팅, 안전 종료 |
|
||||
| 중복 호출 차단 | isStreaming=true 일 때 userTurn 재호출 | early return, state unchanged |
|
||||
|
||||
## 10. 의존
|
||||
- `LlmService.sendChatTurn(...)` (확장 인터페이스)
|
||||
- `ToolDispatcher.dispatch(...)`
|
||||
- `ToolRegistry.allDefinitions()`
|
||||
- `ToolDeps` (toolDepsProvider)
|
||||
- Flutter `BuildContext.mounted`
|
||||
106
docs/design/260-gemma-tool-calling/fn-confirm_gate.md
Normal file
106
docs/design/260-gemma-tool-calling/fn-confirm_gate.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# 함수 설계서: `ConfirmGate.show` (#260)
|
||||
|
||||
> **부모 설계서**: ./README.md · **상태**: Draft
|
||||
> **작성**: [AI] Architect · **구현**: `lib/ai/tools/confirm_gate.dart:show` · **테스트**: `test/ui/confirm_gate_test.dart`
|
||||
|
||||
## 1. 시그니처
|
||||
```dart
|
||||
class ConfirmGate {
|
||||
static Future<bool> show(
|
||||
BuildContext context,
|
||||
ToolDefinition tool,
|
||||
Map<String, dynamic> args,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 2. 책임 (단일 책임, 1줄)
|
||||
destructive tool 실행 직전 모달 AlertDialog 을 띄워 사용자 confirm 여부를 `Future<bool>` 로 반환한다.
|
||||
|
||||
## 3. 입력
|
||||
| 파라미터 | 타입 | 제약 | 설명 |
|
||||
|---|---|---|---|
|
||||
| `context` | BuildContext | `context.mounted == true` | chat screen 의 context |
|
||||
| `tool` | ToolDefinition | isDestructive=true | 어떤 도구인가 |
|
||||
| `args` | Map<String, dynamic> | 이미 validate 통과 | 사용자에게 보여줄 인자 |
|
||||
|
||||
## 4. 출력
|
||||
- **반환**: `Future<bool>`
|
||||
- `true` = 사용자가 "수행" 탭
|
||||
- `false` = 사용자가 "취소" 또는 outside-tap dismiss 또는 `context.mounted == false`
|
||||
- **부수효과**: 모달 표시 (UI). DB 변경 없음.
|
||||
|
||||
## 5. 동작 / 알고리즘
|
||||
```
|
||||
1. if !context.mounted:
|
||||
return false
|
||||
|
||||
2. summary = _summarize(tool.name, args)
|
||||
// tool 별 사람 친화 요약 함수 (per-tool overridable)
|
||||
|
||||
3. result = await showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: true, // outside-tap = 취소
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text('이 작업을 수행할까요?'),
|
||||
content: Column(mainAxisSize: min, crossAxisAlignment: start, children: [
|
||||
Text(tool.description, style: bodyMedium),
|
||||
SizedBox(height: 12),
|
||||
Container(
|
||||
padding: EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(summary),
|
||||
),
|
||||
]),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text('취소')),
|
||||
FilledButton(
|
||||
autofocus: true,
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: Text('수행'),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
4. return result ?? false // dismiss 시 null → false
|
||||
```
|
||||
|
||||
### `_summarize` 규칙 (tool 별)
|
||||
- `add_habit` → "프로토콜 '$title'을 ${frame_level} 프레임으로 새 습관으로 추가합니다.\n • 문장: \"$framed_text\"\n • 앵커: ${anchor_when ?? '-'} / ${anchor_after_what ?? '-'}"
|
||||
- `log_tracker_entry` → "$habit_title 의 ${date ?? '오늘'} 기록을 '${value == 'done' ? '완료' : '공란'}' 으로 저장합니다."
|
||||
- 기타 → JSON pretty (fallback)
|
||||
|
||||
`title` 은 핸들러가 호출 직전 lookup 해서 args 에 채워줄 수 있지만, 단순화를 위해 ConfirmGate 가 직접 catalog/habit 조회는 안 함 — args 에 이미 있는 값만 사용. (안 채워졌으면 protocol_id 그대로 노출 — 트레이드오프 수용.)
|
||||
|
||||
## 6. 에러 & 실패 모드
|
||||
| 조건 | 처리 | 반환 |
|
||||
|---|---|---|
|
||||
| context dispose 후 호출 | guard 즉시 | `false` |
|
||||
| showDialog 자체 예외 (이론상 없음) | rethrow X — catch 후 false 반환 | `false` |
|
||||
| args 가 _summarize 가 기대 안 한 형태 | toString fallback | 정상 동작 (dialog 노출) |
|
||||
|
||||
## 7. 엣지케이스
|
||||
- **chat 모달 위에 chat 모달**: 동시에 호출되지 않도록 ChatSessionController 가 직렬화 (한 turn = 한 tool call 만). 다중 destructive tool 병렬 호출 시 첫 confirm 만 처리, 나머지는 `ToolCancelled` 자동 반환 (OQ-1 영향).
|
||||
- **시스템 back press**: Android 뒤로가기 → dialog dismiss → false. 의도된 cancel.
|
||||
- **autofocus + 키보드 enter**: 수행 버튼 기본 포커스. 의도치 않은 enter 누름 위험 — 사용자 결정으로 수용 (단축키 활용성 ↑).
|
||||
|
||||
## 8. 복잡도 / 성능
|
||||
- O(1). 사용자 대기 시간 = 무한 (사용자 입력 대기).
|
||||
- 호출 빈도: 사용자 대화 turn 당 0 또는 1.
|
||||
|
||||
## 9. 테스트 케이스 (필수)
|
||||
| 케이스 | 셋업 | 입력 | 기대 |
|
||||
|---|---|---|---|
|
||||
| confirm | MaterialApp + 모달 진입 → "수행" tap | add_habit args | true |
|
||||
| cancel | "취소" tap | 동일 | false |
|
||||
| outside dismiss | barrier tap | 동일 | false |
|
||||
| unmounted context | context dispose 후 호출 | — | false 즉시 |
|
||||
|
||||
## 10. 의존
|
||||
- Flutter `showDialog`, `AlertDialog`
|
||||
- `ToolDefinition` (description 출처)
|
||||
- per-tool summary 규칙은 별도 함수로 분리 (`_summarize(toolName, args)`) — 단위 테스트 용이.
|
||||
100
docs/design/260-gemma-tool-calling/fn-tool_dispatcher.md
Normal file
100
docs/design/260-gemma-tool-calling/fn-tool_dispatcher.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# 함수 설계서: `ToolDispatcher.dispatch` (#260)
|
||||
|
||||
> **부모 설계서**: ./README.md · **상태**: Draft
|
||||
> **작성**: [AI] Architect · **구현**: `lib/ai/tools/tool_dispatcher.dart:dispatch` · **테스트**: `test/ai/tools/tool_dispatcher_test.dart`
|
||||
|
||||
## 1. 시그니처
|
||||
```dart
|
||||
Future<ToolResult> dispatch({
|
||||
required String toolName,
|
||||
required Map<String, dynamic> rawArgs,
|
||||
required BuildContext? confirmContext, // null 이면 destructive tool 자동 cancel
|
||||
required ToolDeps deps,
|
||||
});
|
||||
```
|
||||
|
||||
`ToolDeps` = `{ HabitDao habitDao, TrackerDao trackerDao, CatalogRepository catalog, String userId }`.
|
||||
|
||||
## 2. 책임 (단일 책임, 1줄)
|
||||
`toolName` 으로 핸들러를 찾아, args 검증 → Confirm gate → 핸들러 호출 → 결과를 envelope 으로 감싸 반환한다.
|
||||
|
||||
## 3. 입력
|
||||
| 파라미터 | 타입 | 제약/검증 | 설명 |
|
||||
|---|---|---|---|
|
||||
| `toolName` | String | non-empty, registry 에 등록된 이름 | LLM 의 `FunctionCallResponse.name` |
|
||||
| `rawArgs` | Map<String, dynamic> | 어떤 타입이든 — 검증은 내부에서 | LLM 의 `FunctionCallResponse.args` |
|
||||
| `confirmContext` | BuildContext? | 살아있는 widget context | destructive 가 아니면 무관. null + destructive = 자동 cancel |
|
||||
| `deps` | ToolDeps | non-null | 핸들러가 호출할 Repository 묶음 |
|
||||
|
||||
## 4. 출력
|
||||
- **반환**: `Future<ToolResult>` — `ToolOk` / `ToolErr` / `ToolCancelled`. 절대 throw 하지 않음.
|
||||
- **부수효과**:
|
||||
- Confirm gate 호출 시 모달 표시 (UI side effect)
|
||||
- 핸들러 내부에서 DB write 가능 (destructive 인 경우만)
|
||||
|
||||
## 5. 동작 / 알고리즘
|
||||
```
|
||||
1. tool = ToolRegistry.byName(toolName)
|
||||
if tool == null:
|
||||
return ToolErr(code: 'unknown_tool', reason: '알 수 없는 도구: $toolName')
|
||||
|
||||
2. validatedArgs = ToolArgsValidator.validate(tool.parametersSchema, rawArgs)
|
||||
if validatedArgs is ValidationError:
|
||||
return ToolErr(code: 'validation', reason: '인자 오류: ${err.message}')
|
||||
|
||||
3. if tool.isDestructive:
|
||||
if confirmContext == null:
|
||||
return ToolCancelled()
|
||||
ok = await ConfirmGate.show(confirmContext, tool, validatedArgs)
|
||||
if !ok:
|
||||
return ToolCancelled()
|
||||
|
||||
4. try:
|
||||
payload = await tool.handler(validatedArgs, deps)
|
||||
// handler 가 이미 ToolResult 를 반환하는 형태이므로 passthrough
|
||||
return payload
|
||||
catch (e, st):
|
||||
log('tool_error', tool=$toolName, err=$e)
|
||||
return ToolErr(code: 'handler_error', reason: '도구 실행 실패: ${e.runtimeType}')
|
||||
```
|
||||
|
||||
## 6. 에러 & 실패 모드
|
||||
| 조건 | 처리 | 반환 |
|
||||
|---|---|---|
|
||||
| `toolName` 미등록 | log warn | `ToolErr('unknown_tool', ...)` |
|
||||
| `rawArgs` schema 위배 | log info | `ToolErr('validation', ...)` |
|
||||
| destructive + confirmContext null | 조용히 | `ToolCancelled()` |
|
||||
| 사용자 모달 거부 | 조용히 | `ToolCancelled()` |
|
||||
| 핸들러 예외 | log error + stacktrace | `ToolErr('handler_error', ...)` — 사용자 메시지엔 타입만 |
|
||||
| 핸들러가 R 규칙 위배 detect | 핸들러 자체에서 반환 | passthrough `ToolErr('r3_quota', ...)` 등 |
|
||||
|
||||
**불변식**: dispatch 는 throw 하지 않는다. 모든 실패 경로는 ToolResult 로 환원.
|
||||
|
||||
## 7. 엣지케이스
|
||||
- **빈 args**: `{}` 가 들어와도 schema 가 required 필드 검증으로 잡음.
|
||||
- **redundant args** (스키마에 없는 키): 무시 — 모델이 환각해도 통과시키되 로깅.
|
||||
- **모달 race**: confirmContext 가 dispatch 호출 후 dispose 되는 경우 → `ConfirmGate` 내부에서 `context.mounted` 체크 후 false 반환.
|
||||
- **dispatch 중 사용자가 chat 화면 dismiss**: 핸들러는 계속 실행됨 (취소 안 함). 결과는 폐기되지만 DB write 는 commit 된 채 남음. ChatSessionController 가 lifecycle 책임짐 (Architect 결정: side effect 보존 = 사용자가 의도적으로 chat 닫았다고 가정).
|
||||
|
||||
## 8. 복잡도 / 성능
|
||||
- O(1) registry lookup (`Map<String, ToolDefinition>`).
|
||||
- args validate ≤ 50자 keyword 등 small payload — O(n) JSON schema 매칭.
|
||||
- 호출 빈도: 사용자 대화 turn 당 0~N (보통 0 또는 1). 폴링 루프 아님.
|
||||
- 메모리: stateless — instance 변수 없음.
|
||||
|
||||
## 9. 테스트 케이스 (필수)
|
||||
| 케이스 | 입력 | 기대 |
|
||||
|---|---|---|
|
||||
| unknown tool | `dispatch('foo', {}, ...)` | `ToolErr('unknown_tool', ...)` |
|
||||
| validation fail | `dispatch('add_habit', {'protocol_id': 123}, ...)` | `ToolErr('validation', ...)` (123 is int not string) |
|
||||
| destructive + null context | `dispatch('add_habit', validArgs, null, ...)` | `ToolCancelled()` |
|
||||
| destructive + user accept | mock ConfirmGate → true | handler 결과 그대로 |
|
||||
| destructive + user reject | mock ConfirmGate → false | `ToolCancelled()` |
|
||||
| handler throw | mock handler throws | `ToolErr('handler_error', ...)` |
|
||||
| read-only normal | `dispatch('search_catalog', validArgs, null, ...)` | `ToolOk(data:...)` |
|
||||
|
||||
## 10. 의존
|
||||
- `ToolRegistry` (정적 lookup)
|
||||
- `ToolArgsValidator` (JSON schema validator — 간단 자체 구현 권장)
|
||||
- `ConfirmGate.show` (UI)
|
||||
- `ToolDeps` 내부의 Repository 들 (각 핸들러가 사용)
|
||||
244
docs/design/311-llm-warmup/README.md
Normal file
244
docs/design/311-llm-warmup/README.md
Normal file
@@ -0,0 +1,244 @@
|
||||
# 설계서: ChatScreen LLM warm-up (#311)
|
||||
|
||||
> **상태**: Approved (v0.4.1, 2026-06-15)
|
||||
> **작성**: [AI] Architect · **최종수정**: 2026-06-15 (08-Documenter 마감)
|
||||
> **추적성** — Redmine: #311 · 관련 ADR: 없음 (Backward-compatible 추가) · Release: v0.4.1 (`121108f`)
|
||||
> · 구현 파일: `app/lib/state/chat_warmup_provider.dart` (신규) · `app/lib/data/ai/llm_service.dart` (수정) · `app/lib/data/ai/model_lifecycle.dart` (`quickCheck` 추가) · `app/lib/data/ai/gemma_llm_service.dart` (concurrent load guard) · `app/lib/ui/screens/chat_screen.dart` (warmup binding)
|
||||
> · 테스트: `app/test/state/chat_warmup_test.dart` (신규) · `app/test/data/ai/model_lifecycle_test.dart` (quickCheck 케이스 추가) · `app/test/ui/chat_screen_test.dart` (warmup 라벨/disabled 케이스 추가)
|
||||
|
||||
## 1. 목적 (Why)
|
||||
> Planner 목표 인용: ChatScreen 진입 시 LLM 모델을 백그라운드로 warm-up 하여, 첫 send 의 perceived latency 에서 cold load (수 초) 를 제거한다.
|
||||
|
||||
현재 `chat_providers.dart:131` 의 `llm.load()` 가 첫 `userTurn` 시점에 lazy 실행된다. Gemma 4 E2B 의 native runtime 초기화 + `installModel.fromFile().install()` + `getActiveModel(maxTokens: 2048)` 가 합쳐 수 초가 걸려, 사용자는 첫 메시지 send 직후 빈 화면을 본다. 본 이슈는 그 비용을 사용자 입력 전(ChatScreen mount 시점) 으로 이동시킨다.
|
||||
|
||||
## 2. 범위 (Scope)
|
||||
- **포함**:
|
||||
- ChatScreen mount 시 백그라운드 `llm.load()` 트리거.
|
||||
- 로드 상태(idle/loading/ready/failed/unavailable) 노출 + 입력창 binding.
|
||||
- `GemmaLlmService.load()` / `MockLlmService.load()` 의 concurrent-call 가드.
|
||||
- `ModelLifecycle.quickCheck()` — SHA-256 해싱 없이 ready 여부 추정 (warmup gate 전용).
|
||||
- Widget 테스트 (loading 라벨 노출 → 완료 → 사라짐).
|
||||
- **제외 (out of scope)**:
|
||||
- `HabitCreateScreen` 의 AI 제안 (frame suggestion) warm-up — 동일 패턴 필요 시 별도 후속 이슈.
|
||||
- 다운로드 자체 진행률 UI — 이미 SettingsScreen 에 존재 (#218).
|
||||
- `#219` idle auto-unload 구현 — 본 이슈는 entry point 만 정의.
|
||||
- 추론 자체 (KV-cache warm 등) 의 추가 최적화 — `load()` 호출까지만.
|
||||
|
||||
## 3. 인수조건 (Acceptance Criteria)
|
||||
> Planner AC 8개 + UX-Reviewer 신규 AC 4개 (UX-REVIEW.md 흡수). QA 가 이걸로 판정.
|
||||
|
||||
- [ ] **AC1** ChatScreen mount → background `llm.load()` 시작. 이미 `isLoaded` 면 no-op.
|
||||
- [ ] **AC2** `ModelLifecycle.quickCheck() != ready` 일 때 warmup 시도하지 않음. 다운로드 미완 / opt-out / corrupt 상태에서 spurious load 방지.
|
||||
- [ ] **AC3** 로드 진행 중 입력창은 `enabled: true` 유지 (사용자가 메시지를 미리 작성할 수 있음). send 버튼만 disabled + `CircularProgressIndicator(strokeWidth:2)`. 입력창 `hintText` 가 `"AI 준비 중… 첫 시작은 몇 초 걸려요"` 로 교체. ← UX R1+R3 흡수.
|
||||
- [ ] **AC4** 로드 완료 시 send 버튼 활성, `hintText` 가 평상시 `"습관 추가, 기록, 카탈로그 질문…"` 로 복귀. 첫 send 가 cold load 비포함 수준 latency 로 응답.
|
||||
- [ ] **AC5** 로드 실패 시 기존 chat_screen error container 재사용. **메시지는 상태만 기술**, 행동은 별도 `OutlinedButton('다시 시도')` 가 담당 (error container 내부, 우측 정렬). ← UX R5+R6 흡수.
|
||||
- [ ] **AC6** Warmup 진행 중 사용자가 ChatScreen 을 떠나도 race / leak 없음. autoDispose StateNotifier + `_disposed` 가드.
|
||||
- [ ] **AC7** `ChatSessionController.userTurn` 의 lazy load 와 백그라운드 warmup 이 동시 호출되어도 안전. `GemmaLlmService._loadingFuture` 가드로 중복 native init 차단.
|
||||
- [ ] **AC8** Widget 테스트:
|
||||
- delay mock → spinner + hintText 교체 노출 → 완료 후 send 활성 + hintText 복귀.
|
||||
- error mock → error container + [다시 시도] 버튼 노출 + 클릭 시 retry.
|
||||
- `quickCheck() = missing` → warmup 시도 안 함 + UI 변화 없음 (첫 send 시 기존 lazy 경로).
|
||||
- [ ] **AC9** (UX R1+R2) Warmup 중 입력창은 타이핑 가능. send 만 disabled.
|
||||
- [ ] **AC10** (UX R2) Warmup ready 전이 시점에 입력창에 비어있지 않은 텍스트가 있으면 send 자동 활성화. 자동 send 는 X.
|
||||
- [ ] **AC11** (UX R4) `isLoaded=true` 인 재진입 시 Loading state 가 1 frame 이라도 노출되지 않음 (위젯 테스트로 verify).
|
||||
- [ ] **AC12** (UX R5) 실패 메시지 본문에 "다시 시도해주세요" 같은 명령형 문구 금지. 행동은 버튼이 담당.
|
||||
|
||||
## 4. 컨텍스트 & 제약
|
||||
- **의존성**:
|
||||
- `LlmService` (`load`/`isLoaded`/`startChat`) — 기존 인터페이스 유지.
|
||||
- `ModelLifecycle` (`checkAvailability` 기존, `quickCheck` 신규) — meta_kv DAO 의존.
|
||||
- Riverpod (`StateNotifier`, `FutureProvider`, `autoDispose`).
|
||||
- **제약**:
|
||||
- `flutter_gemma 0.16.5` 의 `FlutterGemma.initialize` 는 isolate 당 1회 (`_initialized` 가드 있음). `installModel` + `getActiveModel` 은 idempotent 가 아니다 — 두 번째 호출 시 동작 미정의. **concurrent load 가드 필수**.
|
||||
- `ModelLifecycle.checkAvailability()` 가 SHA-256 ~2.4GB 해싱을 포함. ChatScreen mount 마다 호출하면 비용 과대. quickCheck 분리.
|
||||
- autoDispose StateNotifier 라이프사이클: ChatScreen pop 시 dispose 호출, 진행 중 future 가 unmounted state 변경 시도하면 안 됨.
|
||||
- **가정**:
|
||||
- `GemmaLlmService.load()` 비용 ≈ native init + mmap + `getActiveModel`. KV-cache warm 은 첫 inference 시 발생 (별도 트랙). → R3 해소.
|
||||
- 사용자가 SettingsScreen 에서 모델을 ready 상태로 만든 적이 있다 (meta_kv 의 `ai_model_sha256` 가 채워져 있다). 그렇지 않으면 quickCheck=missing → warmup skip → 기존 lazy 경로 fallback.
|
||||
|
||||
## 5. 아키텍처 개요
|
||||
- **모듈/파일**:
|
||||
- 신규: `app/lib/state/chat_warmup_provider.dart` — `ChatWarmupController` (StateNotifier) + `chatWarmupProvider`.
|
||||
- 수정: `app/lib/data/ai/llm_service.dart` — `MockLlmService._loadingFuture` 가드 추가.
|
||||
- 수정: `app/lib/data/ai/gemma_llm_service.dart` — `_loadingFuture` 가드 추가.
|
||||
- 수정: `app/lib/data/ai/model_lifecycle.dart` — `quickCheck()` 메서드 추가.
|
||||
- 수정: `app/lib/ui/screens/chat_screen.dart` — `initState` 에서 warmup 트리거, body 에 상태 binding.
|
||||
|
||||
- **데이터 흐름**:
|
||||
```
|
||||
ChatScreen.initState
|
||||
└─> ref.read(chatWarmupProvider.notifier).start()
|
||||
├─> meta_kv 의 ai_opt_in / ai_model_path / ai_model_sha256 조회 (ModelLifecycle.quickCheck)
|
||||
│ ├─> ready 아님 → state = unavailable, 종료 (기존 lazy 경로 fallback)
|
||||
│ └─> ready
|
||||
├─> state = loading
|
||||
├─> llm.load() ── concurrent 가드 (_loadingFuture 공유) ──┐
|
||||
│ ↓
|
||||
│ ChatSessionController.userTurn 의 llm.load() 호출 시 동일 future 반환
|
||||
├─> 성공 → state = ready
|
||||
└─> 실패 → state = failed(message)
|
||||
|
||||
ChatScreen.build (Consumer)
|
||||
├─> warmup.state == loading
|
||||
│ ├─ 입력창: enabled:true, hintText="AI 준비 중… 첫 시작은 몇 초 걸려요"
|
||||
│ └─ send: disabled + spinner
|
||||
├─> warmup.state == failed
|
||||
│ ├─ error container: 상태 메시지만
|
||||
│ └─ [다시 시도] OutlinedButton (container 내부, 우측 정렬) → controller.retry()
|
||||
├─> warmup.state == ready
|
||||
│ ├─ 입력창: hintText="습관 추가, 기록, 카탈로그 질문…" (평상)
|
||||
│ └─ send: 텍스트 비어있지 않으면 즉시 활성 (AC10)
|
||||
├─> warmup.state == unavailable → 정상 입력창 (warmup 라벨 X, 첫 send 시 lazy 경로)
|
||||
└─> warmup.state == idle (lifecycle race) → 정상 입력창 (안전 기본값)
|
||||
```
|
||||
|
||||
- **I/O ↔ 순수 로직 경계**:
|
||||
- I/O: `ModelLifecycle.quickCheck()` (meta_kv read + file existsSync), `LlmService.load()` (native init).
|
||||
- 순수: `ChatWarmupController` 의 state 전이는 의존성 주입된 함수만 호출 — 단위 테스트로 모든 분기 검증.
|
||||
|
||||
## 6. 데이터 모델
|
||||
|
||||
### `ChatWarmupState` (sealed, `chat_warmup_provider.dart`)
|
||||
```dart
|
||||
sealed class ChatWarmupState { const ChatWarmupState(); }
|
||||
final class ChatWarmupIdle extends ChatWarmupState { const ChatWarmupIdle(); }
|
||||
final class ChatWarmupLoading extends ChatWarmupState { const ChatWarmupLoading(); }
|
||||
final class ChatWarmupReady extends ChatWarmupState { const ChatWarmupReady(); }
|
||||
final class ChatWarmupFailed extends ChatWarmupState {
|
||||
final String message;
|
||||
const ChatWarmupFailed(this.message);
|
||||
}
|
||||
/// quickCheck != ready — warmup 자체를 시도하지 않은 상태.
|
||||
/// UI 는 정상 입력창 표시 (첫 send 시 기존 lazy 경로).
|
||||
final class ChatWarmupUnavailable extends ChatWarmupState { const ChatWarmupUnavailable(); }
|
||||
```
|
||||
|
||||
### `ModelLifecycle.quickCheck()` 반환 타입
|
||||
- 재사용: 기존 `ModelAvailability` enum (`ready` / `missing` / `corrupt` / `downloading`).
|
||||
- 차이: SHA-256 재해싱을 건너뛴다. 파일 존재 + meta_kv 의 `ai_model_path` / `ai_model_sha256` 가 모두 채워져 있으면 `ready` 로 간주. 손상 감지는 `checkAvailability()` (cold 경로) 에 위임.
|
||||
|
||||
### 입력창 binding (UX 흡수)
|
||||
- `chat_screen.dart` 의 TextField `enabled` = `state.isStreaming == false`.
|
||||
- **warmupState 와 무관** — 사용자가 warmup 중에도 메시지를 미리 작성 가능 (UX R1).
|
||||
- TextField `hintText`:
|
||||
- `warmupState is ChatWarmupLoading` → `"AI 준비 중… 첫 시작은 몇 초 걸려요"`
|
||||
- 그 외 → `"습관 추가, 기록, 카탈로그 질문…"`
|
||||
- send 버튼: `state.isStreaming || warmupState is ChatWarmupLoading || textIsEmpty` 면 disabled. Loading 일 때는 spinner 표시.
|
||||
- send 자동 활성 (AC10): warmup 가 ready 로 전이될 때 입력창 텍스트가 비어있지 않으면 send 가 자동으로 enabled 로 바뀜 (텍스트 controller listener 가 이미 처리하므로 별도 코드 거의 없음).
|
||||
|
||||
### 마이크로카피 사전 (UX-Reviewer 채택본)
|
||||
|
||||
| 상태 | 한국어 라벨 | 위치 |
|
||||
|---|---|---|
|
||||
| warmup loading | hintText: `AI 준비 중… 첫 시작은 몇 초 걸려요` | 입력창 |
|
||||
| warmup ready | hintText: `습관 추가, 기록, 카탈로그 질문…` | 입력창 (기존 유지) |
|
||||
| warmup unavailable | (라벨 변경 없음) | — |
|
||||
| warmup failed (file missing) | error container 본문: `AI 모델 파일을 찾을 수 없어요.` + [설정으로 가기] | error container |
|
||||
| warmup failed (other) | error container 본문: `AI 를 시작하지 못했어요.` + [다시 시도] | error container |
|
||||
|
||||
## 7. 함수 명세 (Function Specs)
|
||||
|
||||
| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? |
|
||||
|------|-----------|----------------|------|------|-----------|-------|
|
||||
| `ChatWarmupController.start` | 모델 ready 체크 → load 호출 → state 전이 | `Future<void> start()` | (deps via ctor) | `void` | failed → `ChatWarmupFailed(msg)` | **복잡** (fn-chat_warmup_controller.md) |
|
||||
| `ChatWarmupController.retry` | failed/idle 에서 start 재호출 | `Future<void> retry()` | — | `void` | (start 동일) | 단순 |
|
||||
| `ModelLifecycle.quickCheck` | SHA 해싱 없이 meta_kv + file existence 만으로 ready 추정 | `Future<ModelAvailability> quickCheck()` | (this.meta) | `ModelAvailability` | DB 예외 → `corrupt` (보수적) | 단순 |
|
||||
| `GemmaLlmService.load` (수정) | concurrent 호출 시 같은 Future 반환 | `Future<void> load()` | — | `void` | (기존 동일) | **복잡** (fn-concurrent_load_guard.md) |
|
||||
| `MockLlmService.load` (수정) | 동일한 concurrent 가드 적용 (테스트 일관성) | `Future<void> load()` | — | `void` | (기존 동일) | 단순 |
|
||||
| `_ChatScreenState.initState` (수정) | mount 시 `chatWarmupProvider.notifier.start()` 호출 | `void initState()` | — | `void` | (controller 가 흡수) | 단순 |
|
||||
| `_ChatScreenState._buildInputRow` (신규 추출) | warmup 상태 ↔ TextField/send 버튼 binding | `Widget _buildInputRow(ChatWarmupState, ChatSessionState)` | states | `Widget` | — | 단순 |
|
||||
|
||||
> 복잡 함수 2 개 → 개별 `fn-*.md`. 단순 함수는 본 표로 충분.
|
||||
|
||||
## 8. 흐름 / 알고리즘
|
||||
|
||||
### Happy path (사용자가 SettingsScreen 에서 옵트인 + 다운로드 완료한 상태)
|
||||
1. 사용자가 `HabitListScreen` AppBar 의 🤖 탭 → ChatScreen push.
|
||||
2. `initState` → `chatWarmupProvider.notifier.start()`.
|
||||
3. `quickCheck()` 반환 `ready` → state = `ChatWarmupLoading`.
|
||||
4. UI rebuild → 입력창 자리에 "AI 준비 중…" + spinner. send 버튼 영역에는 작은 spinner.
|
||||
5. `llm.load()` 백그라운드 진행 (수 초). 그동안 사용자는 메시지 입력 불가능.
|
||||
6. `load()` 성공 → state = `ChatWarmupReady` → UI rebuild → 정상 입력창.
|
||||
7. 사용자 send → `ChatSessionController.userTurn` 내부 `llm.load()` 가 `isLoaded` 체크로 즉시 통과 → 곧바로 inference.
|
||||
|
||||
### quickCheck 가 ready 가 아닌 경우 (다운로드 미완 / opt-out / first-run)
|
||||
1. `quickCheck()` 반환 `missing`/`downloading`/`corrupt` → state = `ChatWarmupUnavailable`.
|
||||
2. UI 는 정상 입력창 (warmup 라벨 X). 사용자 send 시 기존 lazy `userTurn` 경로 → `llm.load()` 가 호출되면 어차피 `FileSystemException('model file missing')` 등으로 실패 → 기존 chat_screen error container 에 표시.
|
||||
3. 즉, **warmup 은 "사용자가 이미 옵트인+다운로드 완료한 케이스" 만 최적화**. 다른 케이스는 기존 동작 유지 (변화 없음).
|
||||
|
||||
### Concurrent load
|
||||
1. ChatScreen mount → warmup → `llm.load()` (Future A 진행 중).
|
||||
2. (race) 사용자가 매우 빠르게 send → `userTurn` 내부 `llm.load()` 호출.
|
||||
3. `GemmaLlmService.load()` 내부 `_loadingFuture != null` 이면 **그 future 를 반환**. native init 중복 X.
|
||||
4. Future A 완료 시 두 caller 모두 정상 진행.
|
||||
|
||||
### Failure + retry
|
||||
1. `load()` 가 throw (예: native init 실패, 파일 권한 변경, OOM) → catch.
|
||||
2. state = `ChatWarmupFailed("AI 모델 준비에 실패했어요. 다시 시도해주세요.")` + 내부 error code 로깅 (사용자 노출 X).
|
||||
3. UI: error container + `OutlinedButton('다시 시도')`. 탭 → `controller.retry()`.
|
||||
4. retry = 단순히 state = `ChatWarmupIdle` 로 reset 후 `start()` 재호출.
|
||||
|
||||
### Unmount race
|
||||
1. `start()` 진행 중 사용자가 back 버튼 → ChatScreen.dispose() → autoDispose → controller.dispose().
|
||||
2. dispose() 에서 `_disposed = true` 플래그.
|
||||
3. start() 의 `await llm.load()` 완료 후 `if (_disposed) return;` 가드 → state 변경 시도 skip.
|
||||
|
||||
## 9. 엣지케이스 & 에러 처리
|
||||
|
||||
| 케이스 | 처리 |
|
||||
|---|---|
|
||||
| `meta_kv` DB 가 lock / 손상 | `quickCheck` catch → `corrupt` 반환 → state = `ChatWarmupUnavailable` (warmup skip). 첫 send 시 정상 에러 경로. |
|
||||
| `LlmService.load()` 가 throw (FileSystemException) | state = `ChatWarmupFailed`. 메시지: "AI 모델 파일을 찾을 수 없어요. 설정에서 다시 다운로드해주세요." |
|
||||
| `load()` 가 throw (Native init 실패 — OOM / 런타임 호환성) | state = `ChatWarmupFailed`. 메시지: "AI 시작 중 오류가 발생했어요. 잠시 후 다시 시도해주세요." |
|
||||
| concurrent load — 두 caller 동시 호출 | `_loadingFuture` 가드로 단일 future 공유. 두 caller 모두 완료 시점에 unblocked. |
|
||||
| 사용자가 warmup 중 ChatScreen 떠남 | `_disposed` 가드 → state 변경 skip. 메모리는 native runtime 이 보유 (다음 진입 시 `isLoaded=true` → no-op). |
|
||||
| AI opt-in 이 false 인 상태에서 ChatScreen 직접 진입 (불가능한 케이스 — 🤖 아이콘 자체가 hidden) | 안전 기본값으로 quickCheck=missing → unavailable. |
|
||||
| ChatScreen 재진입 (앞서 load 됨) | `isLoaded=true` → `llm.load()` 즉시 return → state = ready 빠르게 전이 (사용자 인지 어려운 수 ms). 라벨 깜빡임 방지 위해 — **race 처리**: state 초기값을 `ChatWarmupIdle` 로 두고, `start()` 가 quickCheck 직후 isLoaded 체크해서 이미 loaded 면 곧바로 `ChatWarmupReady` (Loading 단계 skip). |
|
||||
|
||||
## 10. 테스트 계획
|
||||
|
||||
| 테스트 | 케이스 | AC mapping |
|
||||
|---|---|---|
|
||||
| `chat_warmup_test.dart` — `start() happy` | quickCheck=ready + load delay 100ms → state 시퀀스 [Idle → Loading → Ready] | AC1, AC3, AC4 |
|
||||
| `chat_warmup_test.dart` — `start() skip when already loaded` | isLoaded=true → state 시퀀스 [Idle → Ready] (Loading 없음) | AC1 |
|
||||
| `chat_warmup_test.dart` — `start() unavailable` | quickCheck=missing → state = Unavailable, load 호출 안 됨 | AC2 |
|
||||
| `chat_warmup_test.dart` — `start() failure` | load throws → state = Failed(msg) | AC5 |
|
||||
| `chat_warmup_test.dart` — `retry after failure` | Failed → retry() → Loading → Ready | AC5 |
|
||||
| `chat_warmup_test.dart` — `unmount race` | start() 진행 중 dispose() → state 변경 시도 skip | AC6 |
|
||||
| `chat_warmup_test.dart` — `concurrent load shares future` | start() + userTurn 시뮬 동시 → load 1회만 호출 | AC7 |
|
||||
| `model_lifecycle_test.dart` — `quickCheck ready` (신규) | meta_kv 채워짐 + 파일 존재 → ready (SHA 안 함) | AC2 |
|
||||
| `model_lifecycle_test.dart` — `quickCheck missing` (신규) | 파일 없음 → missing | AC2 |
|
||||
| `chat_screen_test.dart` — `warmup loading label` (신규) | delay mock → "AI 준비 중…" 라벨 + spinner 노출 | AC3 |
|
||||
| `chat_screen_test.dart` — `warmup ready hides label` (신규) | 완료 후 라벨 사라지고 send 활성 | AC4 |
|
||||
| `chat_screen_test.dart` — `warmup failed shows retry` (신규) | error mock → error container + 재시도 버튼 | AC5 |
|
||||
|
||||
> 모킹 전략: `MockLlmService` 에 `loadDelay` / `loadThrows` 필드 추가 (테스트 helper). `ModelLifecycle` 은 in-memory `MetaDao` + `MemoryFileSystem` 패턴 (기존 `model_lifecycle_test.dart` 의 fake storage 재사용).
|
||||
|
||||
## 11. 리스크 & 대안 검토
|
||||
|
||||
| 리스크 | 대안 | 선택 | 근거 |
|
||||
|---|---|---|---|
|
||||
| **R1: concurrent load race** | (a) controller-level coordination (b) service-level `_loadingFuture` guard | **(b)** | frame suggestion 등 다른 caller 도 보호. service 가 진실의 원천. |
|
||||
| **R3: load() 비용 정의** | (a) load = mmap only (b) load = mmap + dummy inference (KV-cache warm) | **(a)** | 코드 확인 결과 현재 `load()` = native init + mmap + getActiveModel. KV-cache warm 은 첫 inference 시 발생. (b) 는 별도 트랙 (#312 이슈와 묶일 수 있음). |
|
||||
| **R4: SHA-256 재해싱 비용** | (a) quickCheck 메서드 신설 (file existence + meta_kv 만) (b) checkAvailability 결과를 Riverpod 캐시 | **(a)** | (b) 는 무효화 시점 (다운로드 완료/재시작) 관리 부담. (a) 는 명시적 의도 표현 + SHA 검증은 SettingsScreen 의 cold path 에 남김. |
|
||||
| **R-extra: warmup 비용이 너무 커서 사용자가 chat 안 쓸 때도 GPU/RAM 점유** | (a) ChatScreen 진입 시 warmup (이 설계) (b) HabitListScreen 🤖 hover/long-press 시 (c) opt-in tier (사용자 선택) | **(a)** | (b) 모바일 hover 없음. (c) 옵션 폭증. ChatScreen 진입 = "사용자가 곧 쓸 의도 명시" 의 가장 강한 신호. |
|
||||
|
||||
> ADR 분리 안 함: 모든 결정이 backward-compatible 추가. `LlmService` 인터페이스 변경 없음, `ModelLifecycle.quickCheck` 도 추가 메서드. 되돌리기 어렵지 않음.
|
||||
|
||||
## 12. 미해결 질문 (Open Questions)
|
||||
> UX-Reviewer 패스로 모두 해소. 본 섹션은 의도적으로 비어 있음.
|
||||
|
||||
- ~~OQ-1 microcopy 결정~~ → UX R3 채택, 마이크로카피 사전 §6 으로 이관.
|
||||
- ~~OQ-2 재시도 버튼 위치~~ → UX R6 채택, error container 내부 우측 정렬.
|
||||
- ~~OQ-3 재진입 깜빡임~~ → UX R4 endorse, fn-spec 의 빠른 경로로 Loading skip. min display time 같은 인위 지연은 금지 (안티패턴).
|
||||
|
||||
## 13. UX 리뷰 흡수 노트
|
||||
- 본 설계서는 UX-REVIEW.md 의 Strong 4건 (R1, R2, R4, R5) 모두 채택, Suggest 2건 (R3, R6) 채택.
|
||||
- 신규 AC4건 (AC9-AC12) 통합.
|
||||
- 마이크로카피 사전 §6 으로 이관.
|
||||
- 다음 페르소나 (03-Developer) 는 README 만 보면 충분. UX-REVIEW.md 는 결정 과정의 기록 으로 보존.
|
||||
|
||||
## 14. 참조
|
||||
- Planner 산출물: Redmine #311 `## [AI] Planner` 섹션.
|
||||
- 관련 follow-up: #219 (idle auto-unload), #220 (purge try/catch — 본 이슈의 concurrent load guard 와 동일 정신).
|
||||
- 기존 설계: `docs/design/218-gemma-real-integration/`, `docs/design/260-gemma-tool-calling/`.
|
||||
142
docs/design/311-llm-warmup/UX-REVIEW.md
Normal file
142
docs/design/311-llm-warmup/UX-REVIEW.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# UX 리뷰: ChatScreen LLM warm-up (#311)
|
||||
|
||||
> **검토**: [AI] UX-Reviewer · **대상**: `./README.md` v1 + Planner AC 8개 · **날짜**: 2026-06-15
|
||||
> **위치**: 02-Architect 단계 내 parallel review. Architect 가 흡수 후 03-Developer 인계.
|
||||
|
||||
## 요약
|
||||
|
||||
설계서가 백엔드 흐름 (state 머신, concurrent guard, lifecycle) 은 견고하다. 그러나 사용자가 실제로 보는 표면 — 라벨 톤, spinner 위치, 빠른 전이 시 깜빡임, 실패 시 다음 행동 — 에는 **결정 안 된 것이 너무 많고 (OQ 3건), 결정된 부분도 마찰을 만들 위험**이 있다. 6가지 권고를 아래에 정리.
|
||||
|
||||
---
|
||||
|
||||
## [Strong] R1 — "AI 준비 중…" 을 입력창 자리에 박으면 안 됨
|
||||
|
||||
**관점**: 마찰 / 흐름 / 정신 모델
|
||||
**근거**:
|
||||
- README §5 / §6 은 "입력창 자리에 라벨 + spinner" 를 기본안으로 둠.
|
||||
- 그러면 **사용자가 메시지를 미리 타이핑해두는 행동 자체가 차단**된다. 사용자는 모델 로드를 기다리는 동안에도 "어떤 질문을 할지" 머릿속에서 정리하면서 손가락은 이미 키보드 위에 있다.
|
||||
- 입력창이 사라지면 사용자는 "왜 안 보이지?" 하고 한 번 더 추론해야 한다 (마찰 +1).
|
||||
- 더 큰 문제: ChatScreen 의 ListView 영역이 비어 있는 첫 진입 시점에 입력창까지 사라지면 **화면 전체가 spinner 하나뿐**이 된다 — "이 앱이 멈췄나?" 시그널.
|
||||
|
||||
**제안 (강력 권고)**:
|
||||
- 입력창은 항상 보이게 유지. `enabled: false` 로만 잠그고 `hintText` 만 교체:
|
||||
- 평상: `"습관 추가, 기록, 카탈로그 질문…"`
|
||||
- warmup: `"AI 준비 중… 잠시만요"`
|
||||
- send 버튼 자리에 `CircularProgressIndicator(strokeWidth: 2)` 표시 (현재 isStreaming 처리와 동일 패턴 — 일관성 ↑).
|
||||
- 별도 상단 라벨/배지 추가 X. 사용자는 send 버튼이 spinner 인 것 + hint 한 줄로 충분히 추론 가능.
|
||||
|
||||
**README 영향**: §3 AC3, §5 다이어그램, §6 binding 절 모두 수정.
|
||||
|
||||
---
|
||||
|
||||
## [Strong] R2 — 사용자가 텍스트 입력하고 send 누르면 어떻게 되나? (현재 설계는 침묵)
|
||||
|
||||
**관점**: 정신 모델 / 마찰
|
||||
**근거**:
|
||||
- R1 권고를 받아들이면 입력창은 보이지만 `enabled: false`. 사용자가 키보드를 띄우고 타이핑하려 하면 → **반응 없음**. 또 다른 마찰.
|
||||
- 만약 `enabled: true` 로 두고 send 만 disable 하면, 사용자가 메시지를 친 뒤 send 를 누르려는 순간 "왜 안 가지?" 로 또 다른 마찰.
|
||||
- 어느 쪽이든 **사용자의 의도 (메시지를 보내고 싶음) 와 시스템의 상태 (아직 못 받음) 사이의 간극** 이 풀리지 않음.
|
||||
|
||||
**제안 (강력 권고)**:
|
||||
- 입력창은 `enabled: true` 로 두어 **타이핑은 허용**한다. 사용자가 미리 메시지를 작성하도록.
|
||||
- send 버튼은 disabled + spinner. 누를 수는 없음.
|
||||
- **warmup 완료 시점**에 사용자가 이미 타이핑해둔 메시지가 있으면 → send 버튼 자동 활성화. (자동 send 까지는 X — 사용자 의도 확인 필요)
|
||||
- AC4 에 한 줄 추가: "warmup ready 시점에 입력창의 텍스트가 비어있지 않으면 send 활성화."
|
||||
|
||||
**README 영향**: §3 AC3/AC4 보강.
|
||||
|
||||
---
|
||||
|
||||
## [Suggest] R3 — 첫 warmup 은 "예상 시간" 한 마디 더
|
||||
|
||||
**관점**: 정신 모델 / 인지된 지연
|
||||
**근거**:
|
||||
- 사용자에게 "AI 준비 중" 만 보여주면 — 0.5초 후에도, 5초 후에도, 10초 후에도 같은 라벨. 정신 모델은 점점 "이게 멈췄나?" 로 기운다.
|
||||
- Gemma 4 E2B native init + mmap 은 디바이스에 따라 **2-8초** 범위로 추정 (cold launch). 첫 진입 시 한 번뿐이고 두 번째 진입부터는 거의 즉시 (`isLoaded=true`) — 즉 사용자가 이 라벨을 길게 보는 건 **첫 진입 단 한 번**.
|
||||
- 그 한 번을 부드럽게 만들 가치가 있다.
|
||||
|
||||
**제안**:
|
||||
- hint 를 `"AI 준비 중… 첫 시작은 몇 초 걸려요"` 로 한 번만 명시.
|
||||
- 1회성 SnackBar 도 검토할 수 있으나 — 사용자가 곧바로 입력창 영역으로 시선이 가므로 hint 한 줄로 통합하는 게 단순.
|
||||
|
||||
**README 영향**: §3 AC3 의 라벨 문안, §12 OQ-1 해소.
|
||||
|
||||
---
|
||||
|
||||
## [Strong] R4 — 빠른 경로 (이미 loaded) 의 라벨 깜빡임을 명시적으로 차단
|
||||
|
||||
**관점**: 정신 모델
|
||||
**근거**:
|
||||
- README §9 의 "ChatScreen 재진입" 케이스 + fn-spec 의 "빠른 경로" 분기로 Loading state skip 처리가 들어가 있음 — 좋다.
|
||||
- 하지만 §12 OQ-3 에 "라벨 깜빡임 가능 — 미해결" 이 남아있어 모순. fn-spec 의 빠른 경로가 Loading 을 스킵하므로 깜빡임은 일어나지 않음.
|
||||
- 명확히 못 박을 것.
|
||||
|
||||
**제안**:
|
||||
- OQ-3 를 OQ 에서 제거하고 §9 의 "빠른 경로" 분기 + fn-spec 의 step 2 를 명시적으로 인용한 **결정 노트**로 전환.
|
||||
- min display time (300ms 등) 같은 인위 지연은 도입 **금지** — 사용자에게 거짓 작업을 보여주는 안티패턴.
|
||||
|
||||
**README 영향**: §9 endorse 표현, §12 OQ-3 삭제.
|
||||
|
||||
---
|
||||
|
||||
## [Strong] R5 — 실패 메시지의 다음 행동이 약함
|
||||
|
||||
**관점**: 에러 회복 / 마찰
|
||||
**근거**:
|
||||
- README §9 의 실패 메시지:
|
||||
- "AI 모델 파일을 찾을 수 없어요. 설정에서 다시 다운로드해주세요." — 좋음 (다음 행동 명시).
|
||||
- "AI 시작 중 오류가 발생했어요. 잠시 후 다시 시도해주세요." — 약함. "잠시 후" 가 얼마인지, "다시 시도" 가 어떻게 인지 불명.
|
||||
- 사용자는 두 가지 의문: ① 이게 일시적 문제인가, 영구적 문제인가 ② 내가 뭘 해야 하나.
|
||||
|
||||
**제안**:
|
||||
- "다시 시도" 버튼이 있으니, 메시지에서 "잠시 후 다시 시도해주세요" 는 빼고 **상태 + 행동 분리**:
|
||||
- 상태: `"AI 를 시작하지 못했어요."`
|
||||
- 행동: 별도 [다시 시도] 버튼 (이미 설계됨).
|
||||
- AC5 에 "한국어 메시지는 상태만 기술, 행동은 버튼이 담당" 명시.
|
||||
- 3회 연속 실패 시점에는 보조 안내 ("문제가 계속되면 앱을 재시작해보세요") — 후속 polish 로 deferrable.
|
||||
|
||||
**README 영향**: §9 메시지 사전 갱신, §3 AC5 보강.
|
||||
|
||||
---
|
||||
|
||||
## [Suggest] R6 — 재시도 버튼 위치는 error container 안
|
||||
|
||||
**관점**: 흐름 / 접근성
|
||||
**근거**:
|
||||
- README §12 OQ-2 가 "error container 내부 vs 입력창 옆 icon" 으로 열어둠.
|
||||
- 입력창 옆 icon 은 평상시에는 없는 자리에 갑자기 나타나 사용자가 학습해야 함. 게다가 send 자리 근처에 또 다른 액션 = 오탭 위험.
|
||||
- error container 는 이미 실패 메시지 영역이라 컨텍스트 일관 + 사용자가 "여기서 다음 행동" 학습.
|
||||
|
||||
**제안**:
|
||||
- OQ-2 → 결정: **error container 내부 OutlinedButton('다시 시도')**.
|
||||
- container 좌우 패딩, 메시지와 버튼은 column 으로 분리, 버튼은 우측 정렬.
|
||||
|
||||
**README 영향**: §12 OQ-2 해소.
|
||||
|
||||
---
|
||||
|
||||
## AC 보강 권고 (UX-Reviewer 가 작성한 추가 AC)
|
||||
|
||||
UX 관점에서 검증 가능한 새 AC 를 제안 (Architect 가 흡수 시 README §3 에 추가):
|
||||
|
||||
- [ ] **AC9 (신규)** Warmup 중 입력창은 `enabled: true` 로 타이핑 가능. send 만 disabled + spinner. → R1+R2.
|
||||
- [ ] **AC10 (신규)** Warmup ready 시점에 입력창에 비어있지 않은 텍스트가 있으면 send 자동 활성화 (자동 send 는 X). → R2.
|
||||
- [ ] **AC11 (신규)** `isLoaded=true` 인 재진입 시 Loading state 가 1 frame 이라도 노출되지 않는다 (위젯 테스트로 verify). → R4.
|
||||
- [ ] **AC12 (신규)** 실패 메시지는 상태 기술만, 행동은 [다시 시도] 버튼이 담당. 메시지 본문에 "다시 시도해주세요" 같은 명령형 X. → R5.
|
||||
|
||||
## 마이크로카피 사전 (Architect 가 채택 시 README §6 또는 별도 부록)
|
||||
|
||||
| 상태 | 한국어 라벨 | 위치 |
|
||||
|---|---|---|
|
||||
| warmup loading | 입력창 `hintText`: `AI 준비 중… 첫 시작은 몇 초 걸려요` | 입력창 |
|
||||
| warmup ready | `hintText`: `습관 추가, 기록, 카탈로그 질문…` | 입력창 (기존 유지) |
|
||||
| warmup unavailable | (라벨 없음 — 평상시와 동일) | — |
|
||||
| warmup failed (file missing) | error container: `AI 모델 파일을 찾을 수 없어요. 설정에서 다시 다운로드해주세요.` + [설정으로 가기] | error container |
|
||||
| warmup failed (other) | error container: `AI 를 시작하지 못했어요.` + [다시 시도] | error container |
|
||||
|
||||
> [설정으로 가기] 는 R5 의 file-missing 케이스에서 "설정에서 다시 다운로드" 문구의 다음 행동을 한 탭으로 짧게 만드는 보조 권고. 채택은 Architect 재량.
|
||||
|
||||
## Architect 가 결정해야 할 것 (요약)
|
||||
- Strong R1, R2, R4, R5 — 채택 또는 명시 거절 (OQ 로 남기지 말 것).
|
||||
- Suggest R3, R6 + 마이크로카피 사전 + [설정으로 가기] — 재량.
|
||||
- 새 AC 4건 — 채택 시 README §3 에 통합.
|
||||
103
docs/design/311-llm-warmup/fn-chat_warmup_controller.md
Normal file
103
docs/design/311-llm-warmup/fn-chat_warmup_controller.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# 함수 설계서: `ChatWarmupController.start` (#311)
|
||||
|
||||
> **부모 설계서**: ./README.md · **상태**: Approved (v0.4.1, 2026-06-15)
|
||||
> **작성**: [AI] Architect · **구현**: `app/lib/state/chat_warmup_provider.dart:start` · **테스트**: `app/test/state/chat_warmup_test.dart`
|
||||
|
||||
## 1. 시그니처
|
||||
```dart
|
||||
class ChatWarmupController extends StateNotifier<ChatWarmupState> {
|
||||
ChatWarmupController({
|
||||
required this.llm,
|
||||
required this.lifecycle,
|
||||
}) : super(const ChatWarmupIdle());
|
||||
|
||||
final LlmService llm;
|
||||
final ModelLifecycle lifecycle;
|
||||
bool _disposed = false;
|
||||
|
||||
Future<void> start();
|
||||
Future<void> retry();
|
||||
|
||||
@override
|
||||
void dispose() { _disposed = true; super.dispose(); }
|
||||
}
|
||||
```
|
||||
|
||||
## 2. 책임 (단일 책임)
|
||||
모델 ready 추정 → background `load()` → state 전이까지를 한 번의 트랜잭션으로 묶고, 모든 실패/취소 분기에서 안전하게 state 만 갱신한다.
|
||||
|
||||
## 3. 입력
|
||||
| 파라미터 | 타입 | 제약/검증 | 설명 |
|
||||
|----------|------|-----------|------|
|
||||
| (ctor) `llm` | `LlmService` | non-null | `load`/`isLoaded` 만 사용. |
|
||||
| (ctor) `lifecycle` | `ModelLifecycle` | non-null | `quickCheck` 만 사용. |
|
||||
|
||||
## 4. 출력
|
||||
- **반환**: `Future<void>` — 완료 시점에 state 가 ready/failed/unavailable 중 하나로 확정.
|
||||
- **부수효과**: `state =` 설정. 다른 I/O 없음.
|
||||
|
||||
## 5. 동작 / 알고리즘
|
||||
```
|
||||
1. 현재 state 가 Loading 이면 즉시 return (재진입 가드, retry 외에는 발생 X).
|
||||
2. state = ChatWarmupLoading() 임시 설정 (단, 아래 빠른 경로 확인 전이라 안전).
|
||||
→ ChatScreen 재진입 시 깜빡임 방지 위해 isLoaded 빠른 경로를 먼저 확인:
|
||||
|
||||
if (llm.isLoaded) {
|
||||
_safeSet(const ChatWarmupReady());
|
||||
return;
|
||||
}
|
||||
|
||||
3. quickCheck = await lifecycle.quickCheck();
|
||||
4. quickCheck != ready:
|
||||
_safeSet(const ChatWarmupUnavailable());
|
||||
return;
|
||||
5. _safeSet(const ChatWarmupLoading()); // 본격 로드 시작
|
||||
6. try { await llm.load(); }
|
||||
catch (e) {
|
||||
_safeSet(ChatWarmupFailed(_messageFor(e)));
|
||||
return;
|
||||
}
|
||||
7. _safeSet(const ChatWarmupReady());
|
||||
|
||||
_safeSet(s) = if (_disposed) return; state = s;
|
||||
```
|
||||
|
||||
`retry()` = `state = ChatWarmupIdle();` 후 `await start();`.
|
||||
|
||||
## 6. 에러 & 실패 모드
|
||||
| 조건 | 처리 | 반환/예외 |
|
||||
|------|------|-----------|
|
||||
| `quickCheck` 가 DB lock 등으로 throw | `lifecycle.quickCheck` 내부 catch → `corrupt` 반환 | state = `Unavailable` (보수적) |
|
||||
| `llm.load()` 가 `FileSystemException('model file missing')` | _messageFor 가 매핑 → "AI 모델 파일을 찾을 수 없어요. 설정에서 다시 다운로드해주세요." | state = `Failed` |
|
||||
| `llm.load()` 가 기타 throw (native init 실패, OOM) | _messageFor → "AI 시작 중 오류가 발생했어요. 잠시 후 다시 시도해주세요." | state = `Failed` |
|
||||
| start() 진행 중 dispose() | `_disposed = true` → `_safeSet` 가 no-op | state 변경 안 함 (마지막 set 유지) |
|
||||
| concurrent start() 호출 | step 1 의 Loading 가드 — 외부에서는 retry() 만 사용하므로 정상 흐름에서 미발생 | early return |
|
||||
|
||||
## 7. 엣지케이스
|
||||
- **ChatScreen 재진입 (이미 loaded)**: step 2 의 빠른 경로로 Loading 단계 skip → 라벨 깜빡임 없음.
|
||||
- **start() 진행 중 ChatScreen pop → push (빠른 재진입)**: 첫 인스턴스 dispose, 두 번째 인스턴스의 start() 가 새로 호출. `_disposed` 가 인스턴스별이라 race 없음. `llm._loadingFuture` 가 native init 중복 차단.
|
||||
- **opt-in 토글 race**: 사용자가 ChatScreen 진입과 동시에 SettingsScreen 에서 opt-out → ChatScreen 의 🤖 entry 가 hidden 되며 즉시 pop. dispose 가드로 안전.
|
||||
|
||||
## 8. 복잡도 / 성능
|
||||
- 시간: O(1) + `lifecycle.quickCheck` O(1) (meta_kv 4 쿼리 + 1 stat) + `llm.load()` (수 초).
|
||||
- 공간: state object 1개.
|
||||
- 호출 빈도: ChatScreen mount 당 1회 (+retry 횟수).
|
||||
|
||||
## 9. 의존성
|
||||
- `LlmService` (`load`, `isLoaded`) — 인터페이스 안정.
|
||||
- `ModelLifecycle.quickCheck` (신규).
|
||||
- `flutter_riverpod` `StateNotifier`.
|
||||
|
||||
## 10. 테스트 케이스
|
||||
- [ ] 정상 happy: quickCheck=ready, load delay 100ms → 시퀀스 [Idle → Loading → Ready].
|
||||
- [ ] 빠른 경로: isLoaded=true → 시퀀스 [Idle → Ready] (Loading 없음).
|
||||
- [ ] unavailable: quickCheck=missing → 시퀀스 [Idle → Unavailable], load 호출 안 됨.
|
||||
- [ ] failure: load throws FileSystemException → state = Failed + 매핑된 한국어 메시지.
|
||||
- [ ] failure: load throws StateError → state = Failed + generic 메시지.
|
||||
- [ ] retry: Failed → retry() → Loading → Ready.
|
||||
- [ ] unmount race: start() 진행 중 dispose() → state 변경 시도 무시 (마지막 state = Loading 유지).
|
||||
- [ ] DB 예외: quickCheck 가 throw → Unavailable.
|
||||
|
||||
## 11. 추적성
|
||||
- 인수조건: AC1, AC2, AC3, AC4, AC5, AC6.
|
||||
- 관련 ADR: 없음.
|
||||
85
docs/design/311-llm-warmup/fn-concurrent_load_guard.md
Normal file
85
docs/design/311-llm-warmup/fn-concurrent_load_guard.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# 함수 설계서: `GemmaLlmService.load` concurrent guard (#311)
|
||||
|
||||
> **부모 설계서**: ./README.md · **상태**: Approved (v0.4.1, 2026-06-15)
|
||||
> **작성**: [AI] Architect · **구현**: `app/lib/data/ai/gemma_llm_service.dart:load` (수정) · **테스트**: `app/test/data/ai/gemma_llm_service_test.dart` (concurrent 케이스 추가) / `chat_warmup_test.dart` (시뮬)
|
||||
|
||||
## 1. 시그니처
|
||||
```dart
|
||||
class GemmaLlmService implements LlmService {
|
||||
Future<void>? _loadingFuture; // 신규 필드
|
||||
|
||||
@override
|
||||
Future<void> load() {
|
||||
if (_loaded) return Future.value();
|
||||
final existing = _loadingFuture;
|
||||
if (existing != null) return existing;
|
||||
final future = _doLoad();
|
||||
_loadingFuture = future;
|
||||
return future.whenComplete(() {
|
||||
_loadingFuture = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _doLoad() async {
|
||||
// 기존 load() 본문 (initialize → installModel → getActiveModel).
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> `MockLlmService.load()` 도 같은 패턴 적용 (`_loadingFuture` 필드 추가). 테스트의 동시성 검증 일관성.
|
||||
|
||||
## 2. 책임 (단일 책임)
|
||||
`load()` 가 진행 중일 때 다른 caller 가 호출하면 새 작업을 시작하지 않고 같은 Future 를 반환한다. native runtime 의 `FlutterGemma.installModel` + `getActiveModel` 가 두 번 불리지 않도록 보호.
|
||||
|
||||
## 3. 입력
|
||||
- 없음 (메서드).
|
||||
|
||||
## 4. 출력
|
||||
- **반환**: `Future<void>` — 단일 native init 작업의 완료 future. 모든 caller 가 같은 인스턴스 공유.
|
||||
- **부수효과**: `_loadingFuture`, `_loaded`, `_model` 필드 변경.
|
||||
|
||||
## 5. 동작 / 알고리즘
|
||||
```
|
||||
1. _loaded == true → 즉시 완료 Future 반환.
|
||||
2. _loadingFuture != null → 그 future 그대로 반환. (새 작업 시작 X)
|
||||
3. 그 외:
|
||||
a. future = _doLoad();
|
||||
b. _loadingFuture = future;
|
||||
c. future.whenComplete(() => _loadingFuture = null);
|
||||
d. return future;
|
||||
```
|
||||
|
||||
`_doLoad()` 내부 = 기존 `load()` 본문 그대로 (initialize → installModel → getActiveModel → _loaded=true).
|
||||
|
||||
## 6. 에러 & 실패 모드
|
||||
| 조건 | 처리 | 반환/예외 |
|
||||
|------|------|-----------|
|
||||
| `_doLoad()` 가 throw | `whenComplete` 가 `_loadingFuture = null` 처리 후 throw 전파 | 모든 caller 가 같은 exception 받음 |
|
||||
| caller A 가 await 중에 caller B 도 호출 | 같은 future 반환 (step 2) | 둘 다 동일하게 완료 또는 fail |
|
||||
| 첫 호출 실패 후 재시도 | `_loadingFuture=null` 로 cleared → 다음 호출 시 새 `_doLoad()` 시작 | 정상 재시도 가능 |
|
||||
|
||||
## 7. 엣지케이스
|
||||
- **load() 와 unload() race**: caller A 가 load → 진행 중 caller B 가 unload() 호출. `_doLoad()` 가 _model 설정 직후 unload 가 _model.close() 호출. 본 이슈 범위 외 — 현재 시점에 unload() 호출 경로 없음 (#219 가 다룰 영역). 본 설계는 load 의 concurrent 만 다룬다.
|
||||
- **whenComplete 실행 시점**: future 가 동기 완료 (이미 _loaded=true 인 첫 분기) 시에도 `_loadingFuture=null` 보장. 단, step 1 에서 early return 이라 _loadingFuture 는 손대지 않음.
|
||||
|
||||
## 8. 복잡도 / 성능
|
||||
- 시간: 첫 호출 = 기존 _doLoad 비용. 후속 caller = O(1) future 공유.
|
||||
- 공간: future 1개 + null 가능 필드.
|
||||
- 호출 빈도: ChatScreen mount + userTurn 첫 호출 + frame suggestion (#215) — 모두 일생에 몇 회.
|
||||
|
||||
## 9. 의존성
|
||||
- `flutter_gemma` `FlutterGemma.initialize` / `installModel` / `getActiveModel` (기존).
|
||||
- `_loaded` / `_model` 필드 (기존).
|
||||
|
||||
## 10. 테스트 케이스
|
||||
- [ ] 정상: load() 1회 호출 → _doLoad() 1회 실행.
|
||||
- [ ] concurrent: load() 두 번 await 동시 호출 → _doLoad() 1회만 실행, 두 future 같은 Future 인스턴스.
|
||||
- [ ] 실패 후 재시도: 첫 _doLoad throws → caller A 에게 propagate → _loadingFuture cleared → 두 번째 load() 새 _doLoad 시작.
|
||||
- [ ] isLoaded 이미 true: load() → 즉시 완료, _doLoad 미실행.
|
||||
|
||||
> Gemma native 는 통합 테스트에서만 검증 가능. 단위 테스트는 MockLlmService 의 동일 가드로 시뮬.
|
||||
|
||||
## 11. 추적성
|
||||
- 인수조건: AC7.
|
||||
- 관련 follow-up: #220 (purge try/catch — 동일 정신).
|
||||
- 관련 ADR: 없음.
|
||||
207
docs/design/312-tool-prefix-corpus/README.md
Normal file
207
docs/design/312-tool-prefix-corpus/README.md
Normal file
@@ -0,0 +1,207 @@
|
||||
# 설계서: Tool call 직전 prefix 토큰 corpus & 조건부 push (#312)
|
||||
|
||||
> **상태**: Draft
|
||||
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
|
||||
> **추적성** — Redmine: #312 · 관련 ADR: ADR-0006 (조건부, Developer 단계에서 corpus 결과 확정 후 작성)
|
||||
> · 구현 파일: `app/lib/state/chat_providers.dart:144-153` (수정 후보), `app/lib/ai/diagnostics/corpus_logger.dart` (신규) · 테스트: `app/test/state/chat_session_prefix_test.dart` (신규)
|
||||
|
||||
## 1. 목적 (Why)
|
||||
> Planner 인용: tool call 직전 Gemma 4 E2B 가 뱉는 prefix 자연어를 실측 corpus 로 측정하고, 30% 임계에 따라 partial push 구현 또는 의도적 폐기 결정 기록을 남긴다.
|
||||
|
||||
`chat_providers.dart:144-153` 의 `for await` 루프는 `LlmFunctionCall` 도착 시 `accumulated` 텍스트를 버리고 `break` 한다. Gemma 4 가 tool call 전에 "수면 카탈로그를 보여드릴게요" 같은 의미있는 한국어 prefix 를 자주 뱉는다면, 그 정보가 사용자 화면에서 사라지는 UX 손실이 발생한다. 본 이슈는 손실량을 실측한 뒤 push 구현 또는 의도적 폐기 둘 중 하나로 확정한다.
|
||||
|
||||
## 2. 범위 (Scope)
|
||||
- **포함**:
|
||||
- 디버그 빌드 전용 `CorpusLogger` 인터페이스 + `ChatSessionController` 에 optional inject.
|
||||
- corpus 수집 절차 문서 (`corpus-procedure.md`) 와 결과 표 (`docs/research/312-tool-prefix-corpus.md`).
|
||||
- 임계 (5건 이상 / 15) 충족 시 `userTurn` 의 break 직전 `accumulated.trim().isNotEmpty` → `ModelChatMessage` push.
|
||||
- 임계 미달 시 폐기 주석 + ADR 0006 결정 기록.
|
||||
- 어느 경로든 `ChatSessionController` 단위 테스트 1+ 건.
|
||||
- **제외 (out of scope)**:
|
||||
- `ParallelFunctionCallResponse` first-only 한계 (AC4 의 follow-up 이슈로 발행만).
|
||||
- Gemma 4 thinking/reasoning tag 처리 (`isThinking:false` 비활성 중).
|
||||
- 일반 streaming UX 폴리시 (cursor, 토큰 속도, scroll).
|
||||
- 프로덕션 빌드의 로깅 — `CorpusLogger` 는 `kDebugMode` 가드 + null default.
|
||||
|
||||
## 3. 인수조건 (Acceptance Criteria)
|
||||
- [ ] **AC1**: `docs/research/312-tool-prefix-corpus.md` 에 15 케이스 표 (사용자 입력 / tool name / `accumulated` raw / 의미있는 prefix 여부 (Y/N) / 사유). Planner 의 운영 정의를 본 설계서가 §6 에서 확정 (R2 해소).
|
||||
- [ ] **AC2 (조건부 구현)**: corpus 결과가 ≥5/15 이면 `userTurn` 의 break 직전 push (fn-userTurn_partial_push §5 경로 A). ≤4/15 이면 폐기 주석 + ADR 0006 + 경로 B (no-op 회귀 가드).
|
||||
- [ ] **AC3 (단위 테스트)**: `ChatSessionController` 단위 테스트 — fake `LlmService` 가 `text → text → function_call` 순으로 emit 했을 때 결과 `state.messages` 의 길이와 순서를 검증. 경로 A 면 [User, Model(prefix), ToolCall] 3 개. 경로 B 면 [User, ToolCall] 2 개 + prefix 누락이 의도적임을 주석으로 명시.
|
||||
- [ ] **AC4**: Parallel call 한계를 `userTurn` 코드 인근 주석 + 본 설계서 §11 에 명시 + Redmine 신규 이슈 발행 (예: "#312-followup ParallelFunctionCallResponse 다중 호출 처리").
|
||||
|
||||
## 4. 컨텍스트 & 제약
|
||||
- **의존성**:
|
||||
- `app/lib/data/ai/llm_service.dart` — `LlmChatEvent` sealed class (`LlmTextChunk` / `LlmFunctionCall` / `LlmDone`).
|
||||
- `app/lib/state/chat_providers.dart` — `ChatSessionController.userTurn` 의 multi-turn 루프.
|
||||
- 실 단말 — Gemma 4 E2B `.litertlm` 모델 (#218). corpus 는 실제 inference 결과여야 함 (mock 무효).
|
||||
- **제약**:
|
||||
- 사용자가 수동으로 APK 를 실행해 corpus 를 수집 — reproducibility 낮음. 본 설계는 logger 인터페이스로 수집 부담을 최소화하는 데 집중.
|
||||
- `kDebugMode` 가드로 production 영향 0. 빌드 사이즈 +수 KB 이내.
|
||||
- `LlmService` 인터페이스 변경 금지 — logger 는 `ChatSessionController` 의 ctor 파라미터로만 주입.
|
||||
- **가정**:
|
||||
- Gemma 4 E2B 의 함수 호출 메커니즘은 SDK 가 prompt 를 자동 렌더 (cf. `feedback_flutter_gemma_api_quirks`). 따라서 prefix 텍스트는 SDK 가 자체 emit 하는 자연어이지 사용자가 prompt 로 유도한 것이 아니다.
|
||||
- 동일 프롬프트라도 sampling 노이즈로 다른 결과가 나올 수 있음. 15 케이스 1 회 측정이 절대 진리는 아니지만 의사 결정에는 충분.
|
||||
|
||||
## 5. 아키텍처 개요
|
||||
- **모듈 / 파일**:
|
||||
- 신규: `app/lib/ai/diagnostics/corpus_logger.dart` — `abstract class CorpusLogger` + `DebugCorpusLogger` 구현 + factory.
|
||||
- 수정: `app/lib/state/chat_providers.dart` — `ChatSessionController` 에 `final CorpusLogger? logger` 필드 추가, ctor 에 optional 인자. `userTurn` 의 event 루프에서 hook 호출. corpus 결과 확정 후 §5 의 break 직전에 push 분기 (경로 A) 또는 폐기 주석 (경로 B).
|
||||
- 신규: `docs/research/312-tool-prefix-corpus.md` — corpus 표 (Developer 가 수집 후 채움).
|
||||
- 신규: `docs/design/312-tool-prefix-corpus/corpus-procedure.md` — 절차 매뉴얼.
|
||||
- **데이터 흐름**:
|
||||
```
|
||||
[User input] → ChatSessionController.userTurn
|
||||
↓
|
||||
LlmService.startChat → _session.sendUser(text)
|
||||
↓ (Stream<LlmChatEvent>)
|
||||
for await event:
|
||||
LlmTextChunk → accumulated += text
|
||||
→ logger?.onTextChunk(turn, text) ← 신규 hook
|
||||
→ state.streamingText = accumulated
|
||||
LlmFunctionCall → toolCall = event
|
||||
→ logger?.onFunctionCall(turn, ← 신규 hook
|
||||
accumulated, event.name, event.args)
|
||||
→ [경로 A] if accumulated.trim().isNotEmpty:
|
||||
state.messages.add(ModelChatMessage(accumulated))
|
||||
→ break
|
||||
↓
|
||||
state.messages.add(ToolCallChatMessage(...))
|
||||
```
|
||||
- **I/O ↔ 순수 경계**: `CorpusLogger` 의 구현체가 I/O (file or stdout). `userTurn` 의 push 분기 자체는 순수 (state mutation 뿐) — 테스트 용이.
|
||||
|
||||
```
|
||||
ChatSessionController ────── (optional) ──── CorpusLogger
|
||||
│ │
|
||||
│ userTurn () │ onTextChunk()
|
||||
│ │ onFunctionCall()
|
||||
│ ↓
|
||||
│ file / stdout
|
||||
↓
|
||||
state.messages
|
||||
↓
|
||||
ChatScreen (ListView)
|
||||
```
|
||||
|
||||
## 6. 데이터 모델
|
||||
|
||||
### 6.1 "의미있는 prefix" 운영 정의 (R2 확정)
|
||||
- **포함 (의미있음, Y)**:
|
||||
- 공백 제외 한국어 자연어 ≥10 자.
|
||||
- 정보 전달 의도 있음 (예: "수면 카탈로그에서 추천 항목을 보여드릴게요").
|
||||
- **제외 (의미없음, N)**:
|
||||
- 빈 문자열 또는 공백/줄바꿈만.
|
||||
- boilerplate: "search_catalog 를 호출합니다", "잠시만요", "조회 중...", "...", 영어 함수명 문구.
|
||||
- 단순 응대: "네", "알겠습니다", "확인했어요" 단독.
|
||||
- 사용자 입력 그대로 echo.
|
||||
|
||||
### 6.2 corpus 표 schema (`docs/research/312-tool-prefix-corpus.md`)
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| `#` | int | 1-15 |
|
||||
| `category` | enum | catalog / add_habit / log_tracker_entry / streak |
|
||||
| `user_input` | str | 그대로 |
|
||||
| `tool_name` | str | Gemma 가 호출한 도구 |
|
||||
| `accumulated_raw` | str (multiline) | tool call 도착 시점의 누적 텍스트 (no trim) |
|
||||
| `meaningful` | Y/N | §6.1 기준 |
|
||||
| `note` | str | Y/N 사유 1줄 |
|
||||
|
||||
### 6.3 임계 (R4 확정)
|
||||
- **5+ / 15 (≥33%) → 경로 A (push 구현)**.
|
||||
- **4 또는 그 이하 → 경로 B (폐기 + ADR 0006)**.
|
||||
- borderline (정확히 5) 도 경로 A 채택 (UX 손실 보수적 보호). 정확히 4 면 +5 케이스 추가 수집 후 재판정 (총 20 케이스, 임계 7).
|
||||
|
||||
## 7. 함수 명세 (Function Specs)
|
||||
|
||||
| 함수 | 책임 (1줄) | 시그니처 (잠정) | 입력 | 출력 | 에러 / 실패 | 복잡? |
|
||||
|------|-----------|----------------|------|------|-------------|-------|
|
||||
| `CorpusLogger.onTextChunk` | 텍스트 청크 도착 시점 기록 | `void onTextChunk(int turn, String text)` | turn idx, chunk | void | 구현체 I/O 실패는 swallow (debug only) | **복잡** ([fn](./fn-corpus_logger.md)) |
|
||||
| `CorpusLogger.onFunctionCall` | tool call 도착 시점의 누적 prefix + tool name 기록 | `void onFunctionCall(int turn, String accumulated, String toolName, Map<String,dynamic> args)` | turn idx, prefix, name, args | void | I/O swallow | **복잡** ([fn](./fn-corpus_logger.md)) |
|
||||
| `DebugCorpusLogger.maybeCreate` | factory — `kDebugMode` + dart-define 가드 | `static CorpusLogger? maybeCreate()` | none | nullable logger | exception swallow → null | 단순 (factory) |
|
||||
| `ChatSessionController.userTurn` (수정) | event 루프 + 조건부 push | (기존 시그니처) | (기존) | (기존) | (기존) | **복잡** ([fn](./fn-userTurn_partial_push.md)) |
|
||||
|
||||
> 복잡 기준: state mutation 분기 / 외부 I/O (logger) / corpus 결과에 따라 코드 경로 갈라짐.
|
||||
|
||||
## 8. 흐름 / 알고리즘
|
||||
|
||||
### Phase A — corpus 수집 (Developer 수동 작업)
|
||||
1. `flutter run --debug --dart-define=ENABLE_CORPUS_LOG=1` 으로 APK 빌드 & 단말 설치.
|
||||
2. `corpus-procedure.md` 의 15 프롬프트를 차례로 ChatScreen 에 입력.
|
||||
3. `flutter logs` 또는 logcat `--tag CorpusLogger` 로 raw event dump 수집.
|
||||
4. dump 를 `docs/research/312-tool-prefix-corpus.md` 표에 정리.
|
||||
5. §6.3 임계로 경로 A / B 결정.
|
||||
|
||||
### Phase B — 조건부 구현
|
||||
- 경로 A (push):
|
||||
```
|
||||
} else if (event is LlmFunctionCall) {
|
||||
toolCall = event;
|
||||
// #312 — corpus 측정 결과 X/15 가 의미있는 prefix → push 채택.
|
||||
if (accumulated.trim().isNotEmpty) {
|
||||
_appendPrefixMessage(accumulated);
|
||||
}
|
||||
break;
|
||||
}
|
||||
```
|
||||
- 경로 B (폐기 명시):
|
||||
```
|
||||
} else if (event is LlmFunctionCall) {
|
||||
toolCall = event;
|
||||
// #312 — corpus 측정 결과 X/15 만 의미있는 prefix → 의도적 폐기.
|
||||
// ADR-0006 참조. accumulated 는 버린다.
|
||||
break;
|
||||
}
|
||||
```
|
||||
|
||||
### Phase C — 테스트
|
||||
- fake `LlmService` 가 `[Text("수면 카탈로그를 보여드릴게요 "), FunctionCall(search_catalog, {...})]` 순으로 emit.
|
||||
- 경로 A: `state.messages == [User, Model("수면 카탈로그를 보여드릴게요"), ToolCall(...)]` (trim 적용).
|
||||
- 경로 B: `state.messages == [User, ToolCall(...)]` (prefix 누락 명시적 검증).
|
||||
- 보너스: 빈 prefix 케이스 — `[FunctionCall(...)]` 직접 emit → 경로 A 도 ModelChatMessage 추가 안 함 (trim guard).
|
||||
|
||||
## 9. 엣지케이스 & 에러 처리
|
||||
- **빈 prefix**: `accumulated.trim().isEmpty` → push 안 함 (경로 A 의 가드).
|
||||
- **whitespace only prefix** (Gemma 가 `"\n\n"` 같은 토큰 뱉음): trim 가드로 push 안 함.
|
||||
- **prefix 가 사용자 입력 echo**: §6.1 의 운영 정의로 corpus 수집 시 N 판정. 구현 단계에선 trim/length 가드만 — echo 감지는 false positive 위험 (정상 paraphrase 까지 잡힐 수 있음). corpus 결과로 임계 산정에 영향만.
|
||||
- **logger I/O 실패**: `DebugCorpusLogger` 내부 try/catch swallow. 절대 `userTurn` 흐름을 깨면 안 됨.
|
||||
- **production 빌드**: `DebugCorpusLogger.maybeCreate()` → null 반환. `logger?.onTextChunk` 의 null-aware 가 0 비용.
|
||||
- **dart-define 미설정 + debug**: factory 가 null 반환 (opt-in). 평소 debug run 도 영향 없음.
|
||||
|
||||
## 10. 테스트 계획
|
||||
|
||||
### 단위 테스트 (AC3)
|
||||
- [ ] `chat_session_prefix_test.dart` — 경로 A
|
||||
- Given: fake LlmService 가 `[Text("수면 카탈로그를 보여드릴게요 "), FunctionCall("search_catalog", {})]` emit.
|
||||
- When: `userTurn("수면 습관 추천")`.
|
||||
- Then: state.messages 의 마지막 3 개 = [User, Model("수면 카탈로그를 보여드릴게요"), ToolCall("search_catalog", {}, _)].
|
||||
- [ ] `chat_session_prefix_test.dart` — 경로 A trim guard
|
||||
- Given: fake 가 `[Text("\n\n"), FunctionCall(...)]` emit.
|
||||
- Then: state.messages 에 ModelChatMessage 추가 안 됨.
|
||||
- [ ] (경로 B 채택 시) `chat_session_prefix_test.dart` — 폐기 회귀 가드
|
||||
- Given: fake 가 `[Text("의미있는 prefix"), FunctionCall(...)]` emit.
|
||||
- Then: state.messages 에 ModelChatMessage("의미있는 prefix") 없음 — corpus 결과로 폐기가 정당화됨을 명시적으로 assert.
|
||||
|
||||
### logger 단위 테스트
|
||||
- [ ] `CorpusLogger.maybeCreate` — `kDebugMode=true && ENABLE_CORPUS_LOG=1` → non-null.
|
||||
- [ ] `maybeCreate` — release → null.
|
||||
- [ ] `DebugCorpusLogger.onFunctionCall` — 호출 시 stdout 에 `[CorpusLogger]` 라벨 + JSON 한 줄 emit.
|
||||
|
||||
### 통합 — 수동 corpus (AC1)
|
||||
- 절차서 `corpus-procedure.md` 따라 수행 + 결과 표.
|
||||
|
||||
## 11. 리스크 & 대안 검토
|
||||
- **선택**: optional logger inject + `kDebugMode` 가드. 프로덕션 0 영향, corpus 수집은 dart-define on/off.
|
||||
- **대안 1 (기각)**: ChatSessionController 에 직접 logging 코드 박기 — 프로덕션 영향, 테스트 mock 어려움.
|
||||
- **대안 2 (기각)**: Riverpod provider 로 logger 주입 — 단일 진실 (controller ctor) 보다 모호. 본 이슈는 수명 짧음 (corpus 끝나면 logger 제거 검토).
|
||||
- **트레이드오프**:
|
||||
- logger 가 ChatSessionController API 표면을 늘림 → 본 이슈 후 제거 가능 (코드 적음).
|
||||
- dart-define 가드는 IDE 자동완성에서 안 보임 → corpus-procedure.md 에 명시.
|
||||
- **ADR 0006**: 경로 B (폐기) 채택 시에만 작성. push 채택 시 본 설계서 자체가 결정 기록 — ADR 별도 발행 안 함.
|
||||
- **ParallelFunctionCallResponse (AC4)**: 본 이슈 OOS. follow-up 이슈 발행 권고 — "#312-followup ParallelFunctionCallResponse 다중 호출 처리: 현재 first call only yield 후 return. 다중 tool 시나리오가 Gemma 4 E2B 에서 발생하는지 corpus 결과로 함께 측정 가능 (보너스 컬럼)."
|
||||
|
||||
## 12. 미해결 질문 (Open Questions)
|
||||
없음. R1-R5 해소 완료:
|
||||
- R1 → 15 케이스 1 회 측정 (§4 가정).
|
||||
- R2 → §6.1 운영 정의 확정.
|
||||
- R3 → optional CorpusLogger (§5, §11).
|
||||
- R4 → 임계 5/15 확정 (§6.3).
|
||||
- R5 → state.messages 순서 보존 + trim guard (§9).
|
||||
89
docs/design/312-tool-prefix-corpus/corpus-procedure.md
Normal file
89
docs/design/312-tool-prefix-corpus/corpus-procedure.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# Corpus 수집 절차 (#312)
|
||||
|
||||
> **부모 설계서**: ./README.md · **목적**: Developer 가 corpus 를 재현 가능하게 수집할 수 있도록 절차 명문화.
|
||||
|
||||
## 1. 빌드
|
||||
|
||||
```bash
|
||||
cd app
|
||||
flutter build apk --debug --dart-define=ENABLE_CORPUS_LOG=true
|
||||
# 또는 단말 연결 후
|
||||
flutter run --debug --dart-define=ENABLE_CORPUS_LOG=true
|
||||
```
|
||||
|
||||
`ENABLE_CORPUS_LOG=true` 가 빠지면 `DebugCorpusLogger.maybeCreate()` 가 null 반환 → logging 없음.
|
||||
|
||||
## 2. 로그 캡처
|
||||
|
||||
```bash
|
||||
# adb 연결된 단말
|
||||
adb logcat | grep CorpusLogger > /tmp/corpus-raw.log
|
||||
|
||||
# 또는 flutter run 의 stdout
|
||||
flutter run --debug --dart-define=ENABLE_CORPUS_LOG=true 2>&1 \
|
||||
| grep CorpusLogger > /tmp/corpus-raw.log
|
||||
```
|
||||
|
||||
각 줄은 `[CorpusLogger] {"kind":"text_chunk"|"function_call",...}` 형태의 JSON.
|
||||
|
||||
## 3. 프롬프트 시퀀스 (15 케이스)
|
||||
|
||||
ChatScreen 진입 후 아래를 순서대로 입력. 각 프롬프트 사이에 ↻ 로 세션 초기화 (turn 격리).
|
||||
|
||||
### catalog 카테고리 (5)
|
||||
1. 수면 관련 습관 추천해줘
|
||||
2. 아침에 할 수 있는 습관 뭐 있어?
|
||||
3. 스트레스 관리 프로토콜 알려줘
|
||||
4. 운동 관련 카탈로그 보여줘
|
||||
5. 명상 어떤 게 있어?
|
||||
|
||||
### add_habit 카테고리 (5)
|
||||
6. 아침 햇빛 보기 습관 추가해줘
|
||||
7. 매일 물 2L 마시기 추가해줘
|
||||
8. 잠들기 전 스트레칭 등록할래
|
||||
9. 출근 전 명상 5분 추가해줘
|
||||
10. 점심 후 산책 습관 만들어줘
|
||||
|
||||
### log_tracker_entry 카테고리 (3)
|
||||
11. 오늘 햇빛 봤어 체크해줘
|
||||
12. 어제 운동한 거 기록해줘
|
||||
13. 오늘 명상 완료
|
||||
|
||||
### streak 카테고리 (2)
|
||||
14. 내 연속 기록 어때?
|
||||
15. 스트릭 보여줘
|
||||
|
||||
## 4. 결과 표 작성
|
||||
|
||||
`/tmp/corpus-raw.log` 의 각 `function_call` 이벤트에서 `accumulated_prefix` 를 추출해 `docs/research/312-tool-prefix-corpus.md` 표에 채운다.
|
||||
|
||||
표 schema (README §6.2 참조):
|
||||
|
||||
```markdown
|
||||
| # | category | user_input | tool_name | accumulated_raw | meaningful | note |
|
||||
|---|----------|-----------|-----------|-----------------|------------|------|
|
||||
| 1 | catalog | 수면 관련 습관 추천해줘 | search_catalog | "수면 카탈로그를 보여드릴게요" | Y | 정보 전달 의도 + 14자 |
|
||||
| 2 | catalog | 아침에 할 수 있는 습관 뭐 있어? | search_catalog | "" | N | 빈 prefix |
|
||||
| ... |
|
||||
```
|
||||
|
||||
판정 기준은 README §6.1 의 운영 정의:
|
||||
- **Y**: 공백 제외 한국어 자연어 ≥10 자 + 정보 전달 의도.
|
||||
- **N**: 빈/공백/boilerplate/단순 응대/echo.
|
||||
|
||||
## 5. 임계 판정
|
||||
|
||||
- Y 카운트 ≥5 → 경로 A (push 구현).
|
||||
- Y 카운트 =4 → +5 케이스 추가 수집 (총 20, 임계 7).
|
||||
- Y 카운트 ≤3 → 경로 B (폐기 + ADR-0006).
|
||||
|
||||
## 6. 후처리
|
||||
|
||||
- corpus 결과를 Redmine #312 의 `## [AI] Developer` 섹션 (또는 별도 댓글) 에 요약: Y/N count + 채택 경로.
|
||||
- 채택 경로에 따라 `fn-userTurn_partial_push.md` 의 경로 A 또는 B 를 구현.
|
||||
- 경로 B 채택 시 ADR-0006 작성 (`docs/adr/0006-tool-call-prefix-discard.md` 또는 적합한 제목).
|
||||
- corpus 수집 종료 후 `--dart-define=ENABLE_CORPUS_LOG=true` 사용 빈도 0 → 향후 CorpusLogger 제거 follow-up 이슈 발행 검토.
|
||||
|
||||
## 7. 보너스 — ParallelFunctionCallResponse 측정
|
||||
|
||||
같은 raw log 에서 한 turn 안에 `function_call` 이 2 회 이상 emit 되는지 확인. 발생 시 AC4 의 follow-up 이슈에 빈도 데이터 포함.
|
||||
134
docs/design/312-tool-prefix-corpus/fn-corpus_logger.md
Normal file
134
docs/design/312-tool-prefix-corpus/fn-corpus_logger.md
Normal file
@@ -0,0 +1,134 @@
|
||||
# 함수 설계서: `CorpusLogger` (#312)
|
||||
|
||||
> **부모 설계서**: ./README.md · **상태**: Draft
|
||||
> **작성**: [AI] Architect · **구현**: `app/lib/ai/diagnostics/corpus_logger.dart` (신규) · **테스트**: `app/test/ai/diagnostics/corpus_logger_test.dart` (신규)
|
||||
|
||||
## 1. 시그니처
|
||||
```dart
|
||||
abstract class CorpusLogger {
|
||||
void onTextChunk(int turn, String text);
|
||||
void onFunctionCall(
|
||||
int turn,
|
||||
String accumulatedPrefix,
|
||||
String toolName,
|
||||
Map<String, dynamic> args,
|
||||
);
|
||||
}
|
||||
|
||||
class DebugCorpusLogger implements CorpusLogger {
|
||||
/// kDebugMode + --dart-define=ENABLE_CORPUS_LOG=1 일 때만 non-null.
|
||||
/// production 빌드에서는 항상 null.
|
||||
static CorpusLogger? maybeCreate();
|
||||
|
||||
@override
|
||||
void onTextChunk(int turn, String text);
|
||||
|
||||
@override
|
||||
void onFunctionCall(...);
|
||||
}
|
||||
```
|
||||
|
||||
## 2. 책임 (단일 책임, 1줄)
|
||||
ChatSessionController 의 event 루프에서 발생한 텍스트 청크와 function call 의 raw payload 를 디버그 빌드에서 stdout 으로 dump 한다 — corpus 수집 부담을 줄이기 위한 한시적 진단 도구.
|
||||
|
||||
## 3. 입력
|
||||
|
||||
### `onTextChunk`
|
||||
| 파라미터 | 타입 | 제약/검증 | 설명 |
|
||||
|----------|------|-----------|------|
|
||||
| `turn` | int | ≥0 | userTurn 내 multi-turn 루프의 turn index (0=첫 LLM 응답). |
|
||||
| `text` | String | non-null | 도착한 텍스트 청크 (raw, 누적 X). |
|
||||
|
||||
### `onFunctionCall`
|
||||
| 파라미터 | 타입 | 제약/검증 | 설명 |
|
||||
|----------|------|-----------|------|
|
||||
| `turn` | int | ≥0 | 동일. |
|
||||
| `accumulatedPrefix` | String | non-null | tool call 도착 시점까지의 누적 텍스트 (chunks 의 concat). |
|
||||
| `toolName` | String | non-null, non-empty | Gemma 가 호출한 도구 이름. |
|
||||
| `args` | `Map<String, dynamic>` | non-null | tool 인자. JSON serializable 가정. |
|
||||
|
||||
### `maybeCreate`
|
||||
- 입력 없음.
|
||||
|
||||
## 4. 출력
|
||||
- `onTextChunk` / `onFunctionCall`: **반환 없음**. 부수효과 = stdout 한 줄 emit (디버그). I/O 실패 시 swallow.
|
||||
- `maybeCreate`: **반환** `CorpusLogger?` — `kDebugMode` 가 true 이고 `const bool.fromEnvironment('ENABLE_CORPUS_LOG')` 가 true 일 때 `DebugCorpusLogger()` 인스턴스, 그 외 null.
|
||||
|
||||
## 5. 동작 / 알고리즘
|
||||
|
||||
### `DebugCorpusLogger.onTextChunk`
|
||||
```
|
||||
1. _emit({
|
||||
'kind': 'text_chunk',
|
||||
'turn': turn,
|
||||
'text': text,
|
||||
});
|
||||
```
|
||||
|
||||
### `DebugCorpusLogger.onFunctionCall`
|
||||
```
|
||||
1. _emit({
|
||||
'kind': 'function_call',
|
||||
'turn': turn,
|
||||
'accumulated_prefix': accumulatedPrefix,
|
||||
'tool_name': toolName,
|
||||
'args': args,
|
||||
});
|
||||
```
|
||||
|
||||
### `_emit(Map<String, dynamic> payload)`
|
||||
```
|
||||
1. try:
|
||||
2. final line = '[CorpusLogger] ' + jsonEncode(payload);
|
||||
3. developer.log(line, name: 'CorpusLogger');
|
||||
4. catch (_):
|
||||
5. // swallow — diagnostic 이 user flow 를 깨지 않게.
|
||||
```
|
||||
|
||||
### `DebugCorpusLogger.maybeCreate`
|
||||
```
|
||||
1. if (!kDebugMode) return null;
|
||||
2. const enabled = bool.fromEnvironment('ENABLE_CORPUS_LOG', defaultValue: false);
|
||||
3. if (!enabled) return null;
|
||||
4. return DebugCorpusLogger._();
|
||||
```
|
||||
|
||||
## 6. 에러 & 실패 모드
|
||||
| 조건 | 처리 | 반환/예외 |
|
||||
|------|------|-----------|
|
||||
| `jsonEncode` 가 args 의 non-serializable 키로 throw | `_emit` 의 try/catch swallow | void (silent) |
|
||||
| `developer.log` I/O 실패 | swallow | void |
|
||||
| `maybeCreate` 가 production 호출 | `kDebugMode=false` 분기에서 null | null (정상) |
|
||||
| ctor 직접 호출 시도 | private ctor `_()` 로 차단 | 컴파일 에러 |
|
||||
|
||||
## 7. 엣지케이스
|
||||
- **매우 빈번한 호출**: 토큰 단위 stream 이라 `onTextChunk` 가 초당 수십 회. `developer.log` 가 stdout flush 부담 — 단, debug only + 한시적이라 수용.
|
||||
- **args 에 BigInt / DateTime**: `jsonEncode` 가 throw → swallow. corpus 결과 누락 시 코드 보강 (toString fallback) 가능하지만 본 설계는 swallow 만.
|
||||
- **multi-turn 루프**: 같은 userTurn 내에서 turn 0, 1, 2 ... 각각의 prefix 가 모두 캡처되어야 비교 가능 — caller (ChatSessionController) 가 정확한 turn idx 를 넘긴다.
|
||||
- **logger null 이지만 inject 됨**: callsite 가 `logger?.onTextChunk(...)` 패턴이므로 null-safe.
|
||||
|
||||
## 8. 복잡도 / 성능
|
||||
- 시간: 각 호출 O(payload 크기). `jsonEncode` 가 prefix 길이에 선형.
|
||||
- 공간: emit 마다 임시 string. 영구 보관 없음 (stdout sink).
|
||||
- 호출 빈도: 토큰당 1회 (text_chunk), tool call 당 1회 (function_call). 한 userTurn 에 수십-수백 호출 가능 — debug only 라 수용.
|
||||
|
||||
## 9. 의존성
|
||||
- `dart:convert` — `jsonEncode`.
|
||||
- `dart:developer` — `log`.
|
||||
- `flutter/foundation.dart` — `kDebugMode`.
|
||||
- 환경 변수: `ENABLE_CORPUS_LOG` (dart-define).
|
||||
|
||||
## 10. 테스트 케이스
|
||||
- [ ] **maybeCreate**: production 시뮬 (kDebugMode false fake) → null 반환.
|
||||
- [ ] **maybeCreate**: debug + ENABLE_CORPUS_LOG=false → null.
|
||||
- [ ] **maybeCreate**: debug + ENABLE_CORPUS_LOG=true → non-null DebugCorpusLogger.
|
||||
- [ ] **onFunctionCall happy**: 인자 정상 → stdout 에 `[CorpusLogger]` 라벨 + JSON 한 줄 emit (capture for verification).
|
||||
- [ ] **onFunctionCall non-serializable args**: `{'date': DateTime.now()}` → throw 안 함 (swallow), test 가 timeout 없이 종료.
|
||||
- [ ] **onTextChunk** 빈 텍스트 → swallow 없이 정상 emit (filter 안 함, 무엇이 들어왔는지 그대로 기록하는 게 corpus 의 정직성).
|
||||
|
||||
> ENABLE_CORPUS_LOG 의 dart-define 기반 테스트는 `--dart-define=ENABLE_CORPUS_LOG=true` 로 별도 `flutter test` invocation 또는 mockable wrapper 로 분리. Architect 권고 = wrapper (`bool _readEnableFlag()` 를 visibleForTesting 으로 expose) 로 테스트 간소화.
|
||||
|
||||
## 11. 추적성
|
||||
- 인수조건: AC1 (corpus 수집 인프라).
|
||||
- 관련 ADR: 없음 (한시적 진단 도구).
|
||||
- 본 이슈 종료 후 제거 여부 검토 — follow-up 이슈로 발행 권장 ("CorpusLogger 정리 — corpus 결과 반영 후 logger 제거 또는 영구화 결정").
|
||||
116
docs/design/312-tool-prefix-corpus/fn-userTurn_partial_push.md
Normal file
116
docs/design/312-tool-prefix-corpus/fn-userTurn_partial_push.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# 함수 설계서: `ChatSessionController.userTurn` partial push 분기 (#312)
|
||||
|
||||
> **부모 설계서**: ./README.md · **상태**: Draft
|
||||
> **작성**: [AI] Architect · **구현**: `app/lib/state/chat_providers.dart:144-153` 수정 · **테스트**: `app/test/state/chat_session_prefix_test.dart` (신규)
|
||||
|
||||
## 1. 시그니처
|
||||
변경 없음 (메서드 시그니처 유지):
|
||||
```dart
|
||||
Future<void> userTurn(String text, BuildContext context) async;
|
||||
```
|
||||
|
||||
본 설계서는 메서드 내부 event 루프의 `LlmFunctionCall` 분기만 다룬다.
|
||||
|
||||
## 2. 책임 (단일 책임, 1줄)
|
||||
Event 루프가 `LlmFunctionCall` 을 받았을 때, corpus 결과 (≥5/15) 가 prefix 보존을 정당화한 경우에만 `accumulated` 를 `ModelChatMessage` 로 push 한 뒤 tool 처리로 break.
|
||||
|
||||
## 3. 입력
|
||||
| 파라미터 | 타입 | 제약/검증 | 설명 |
|
||||
|----------|------|-----------|------|
|
||||
| (loop local) `accumulated` | String | non-null, 빈 가능 | LlmTextChunk 누적 결과. |
|
||||
| (loop local) `event` | LlmFunctionCall | non-null | Gemma 의 함수 호출 이벤트. |
|
||||
| (instance) `logger` | `CorpusLogger?` | nullable | optional 진단. corpus 단계에서만 활성. |
|
||||
| (instance) `state.messages` | `List<ChatMessage>` | non-null | 누적 메시지 history. |
|
||||
|
||||
## 4. 출력
|
||||
- **반환**: 없음 (loop 내부 분기).
|
||||
- **부수효과**:
|
||||
- `logger?.onFunctionCall(...)` (corpus 활성 시).
|
||||
- 경로 A: `state.messages` 에 `ModelChatMessage(accumulated)` append (단 trim 후 non-empty).
|
||||
- 양 경로 공통: `toolCall = event; break;`.
|
||||
|
||||
## 5. 동작 / 알고리즘
|
||||
|
||||
### 경로 A (corpus 결과 ≥5/15 → push 채택)
|
||||
```dart
|
||||
} else if (event is LlmFunctionCall) {
|
||||
toolCall = event;
|
||||
// #312 — corpus 결과 X/15 (≥5) 가 의미있는 prefix → push.
|
||||
logger?.onFunctionCall(turn, accumulated, event.name, event.args);
|
||||
final trimmed = accumulated.trim();
|
||||
if (trimmed.isNotEmpty) {
|
||||
state = state.copyWith(
|
||||
messages: [
|
||||
...state.messages,
|
||||
ModelChatMessage(trimmed),
|
||||
],
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
```
|
||||
|
||||
### 경로 B (corpus 결과 ≤4/15 → 폐기)
|
||||
```dart
|
||||
} else if (event is LlmFunctionCall) {
|
||||
toolCall = event;
|
||||
// #312 — corpus 측정 결과 X/15 만 의미있는 prefix → 의도적 폐기.
|
||||
// ADR-0006 (docs/adr/0006-tool-call-prefix-discard.md) 참조.
|
||||
// accumulated 는 버린다 — 회귀 가드는
|
||||
// app/test/state/chat_session_prefix_test.dart 의 "폐기 회귀" 테스트.
|
||||
logger?.onFunctionCall(turn, accumulated, event.name, event.args);
|
||||
break;
|
||||
}
|
||||
```
|
||||
|
||||
`logger?.onTextChunk(turn, event.text)` 는 `LlmTextChunk` 분기에 동일하게 추가 (양 경로 공통).
|
||||
|
||||
### 양 경로 공통 추가 사항
|
||||
- 컨트롤러 생성자에 optional `CorpusLogger? logger` 추가.
|
||||
- Riverpod provider 가 `DebugCorpusLogger.maybeCreate()` 를 호출해 inject (production 에서는 null).
|
||||
|
||||
## 6. 에러 & 실패 모드
|
||||
| 조건 | 처리 | 반환/예외 |
|
||||
|------|------|-----------|
|
||||
| `accumulated.trim()` 이 빈 문자열 | 경로 A 의 if 가드 → push 안 함 | 정상 break |
|
||||
| `state.copyWith` 가 빈 messages 로 호출 | 정상 (no-op equivalent) | 정상 |
|
||||
| logger 가 throw | logger 구현체 내부에서 swallow (fn-corpus_logger §6) | 정상 |
|
||||
| `event.args` 가 null | `LlmFunctionCall` 계약상 non-null — 발생 시 LlmService 버그. catch 없음 (fail-fast). | LlmService 단에서 처리 |
|
||||
|
||||
## 7. 엣지케이스
|
||||
- **빈 prefix 후 tool**: accumulated="" → 경로 A 의 trim guard 가 push 차단. ChatScreen 에 빈 버블 노출 안 됨.
|
||||
- **whitespace only prefix** (`"\n\n "`): trim 후 empty → push 안 함.
|
||||
- **prefix 가 multi-turn 루프의 turn 1+ 에서 발생**: 첫 turn 에서 tool 호출, 두 번째 turn 에서 LLM 이 또 prefix 후 tool 호출. 이때도 동일 로직 — accumulated 가 turn 별로 reset 되어 있음 (`var accumulated = '';` 가 for 루프 내부) 이므로 OK.
|
||||
- **마지막 turn 의 prefix + 자연어 종료**: tool call 이 안 들어오고 `toolCall == null` 분기로 빠지면 기존 코드가 `ModelChatMessage(accumulated)` push — 본 설계와 무관.
|
||||
- **prefix 가 그대로 사용자 입력 echo**: 운영 정의상 corpus 에서 N 으로 판정되나 구현은 echo 감지 안 함 (false positive 위험). 코드는 단순 trim/length 만.
|
||||
|
||||
## 8. 복잡도 / 성능
|
||||
- 시간: O(accumulated.length) for trim. 무시 가능.
|
||||
- 공간: `ModelChatMessage` 1개 (trim 된 prefix 길이).
|
||||
- 호출 빈도: tool call 당 1회. userTurn 당 최대 `kChatMaxTurns` (4) 회.
|
||||
|
||||
## 9. 의존성
|
||||
- 본 파일 (`chat_providers.dart`) 내 sealed `ChatMessage` (`UserChatMessage`/`ModelChatMessage`/`ToolCallChatMessage`).
|
||||
- `LlmChatEvent` sealed class (`LlmTextChunk` / `LlmFunctionCall`).
|
||||
- `CorpusLogger?` (fn-corpus_logger.md).
|
||||
|
||||
## 10. 테스트 케이스
|
||||
- [ ] **경로 A happy**:
|
||||
- Given: fake LlmService emit `[Text("수면 카탈로그를 보여드릴게요 "), FunctionCall("search_catalog", {"category":"sleep"})]`.
|
||||
- When: `userTurn("수면 습관 추천")`.
|
||||
- Then: `state.messages` 의 마지막 3 = `[UserChatMessage("수면 습관 추천"), ModelChatMessage("수면 카탈로그를 보여드릴게요"), ToolCallChatMessage("search_catalog", {category:"sleep"}, _)]`.
|
||||
- [ ] **경로 A trim guard**:
|
||||
- Given: fake emit `[Text("\n\n "), FunctionCall(...)]`.
|
||||
- Then: `state.messages` 에 ModelChatMessage 추가 안 됨. 마지막 2 = `[User, ToolCall]`.
|
||||
- [ ] **경로 A 빈 prefix**:
|
||||
- Given: fake emit `[FunctionCall(...)]` (text chunk 없음).
|
||||
- Then: state.messages 마지막 2 = `[User, ToolCall]`.
|
||||
- [ ] **경로 B 폐기 회귀** (경로 B 채택 시):
|
||||
- Given: fake emit `[Text("의미있는 한국어 prefix"), FunctionCall(...)]`.
|
||||
- Then: state.messages 에 ModelChatMessage 없음 — corpus 결과로 폐기가 정당화됨을 명시적으로 assert.
|
||||
|
||||
> 모든 케이스는 mocked `LlmService` + 실제 ChatSessionController. 실 Gemma 통합은 corpus 수집 (Phase A) 에서 검증.
|
||||
|
||||
## 11. 추적성
|
||||
- 인수조건: AC2 (조건부 구현), AC3 (단위 테스트).
|
||||
- 관련 ADR: ADR-0006 (조건부 — 경로 B 채택 시).
|
||||
116
docs/design/342-v042-hotfix/README.md
Normal file
116
docs/design/342-v042-hotfix/README.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# 설계서: v0.4.2 hotfix — ChatScreen SafeArea + LLM 진단 + UX round 1 (#342)
|
||||
|
||||
> **상태**: Approved
|
||||
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
|
||||
> **추적성** — Redmine: #342 · 관련 ADR: 없음
|
||||
> · 구현 파일: `app/lib/ui/screens/chat_screen.dart`, `app/lib/state/chat_providers.dart`, `app/lib/ui/labels.dart`, `app/lib/ui/screens/habit_list_screen.dart`, `app/lib/ui/screens/streak_screen.dart`, `app/lib/ui/screens/habit_create_screen.dart`
|
||||
> · 테스트: 기존 167 회귀 (신규 추가 없음 — string label / SafeArea wrap 라 단위 가치 낮음)
|
||||
|
||||
## 1. 목적 (Why)
|
||||
v0.4.1 실 단말 (Android, 사용자 본인) 첫 테스트에서 발견된 사용성·진단 격차 묶음. 모두 dev 단계 신속 hotfix.
|
||||
|
||||
1. **A — ChatScreen 입력창 가림**: edge-to-edge 모드의 시스템 nav bar 가 send 버튼/입력창을 덮어 사용 불가.
|
||||
2. **B — LLM 실패 원인 불명**: warm-up 은 Ready 까지 가지만 send 실패 시 빨간 배너에 `LLM 응답 실패: <Type>` 만 떠 원인 진단 불가.
|
||||
3. **C — UX round 1 (raw enum 노출)**: 습관 카드/스트릭/추가 화면이 Drift row 의 `'build'` / `RewardTier.dbValue('T0')` / `'Never miss twice'` 같은 식별자를 그대로 사용자에 노출.
|
||||
|
||||
## 2. 범위 (Scope)
|
||||
- **포함**:
|
||||
- A. `ChatScreen` Scaffold.body → `SafeArea(top: false, …)`.
|
||||
- B. `userTurn` catch 가 `e.toString() + stack` 전체를 error state 에 저장. ChatScreen 빨간 배너를 `SingleChildScrollView + SelectableText` (monospace, 12pt, 최대 1/3 높이) 로 교체.
|
||||
- C. `app/lib/ui/labels.dart` 신규 — `habitTypeLabel(HabitType)`, `habitTypeLabelFromDb(String)`, `rewardTierLabel(RewardTier)`. P0 3건 + P1 2건.
|
||||
- D. UX round 2 — chat 빈 상태 안내 (예시 prompt 4 tap-to-fill), check_in 한국식 날짜 (`6월 15일 (월)`), habit_create "프레임 레벨" → "표현 방식" + 예시 부연.
|
||||
- **제외 (out of scope)**:
|
||||
- LLM `BackendInitException: model may be invalid` 실 원인 (GPU KV cache RESOURCE_EXHAUSTED 후보) — 단말 빌드/설치 비용 때문에 별도 사이클로 분리. follow-up 이슈 발행 예정.
|
||||
- release 빌드에서 stack 숨김 (사용자 친화 메시지로 좁히기) — #342 종료 후 follow-up.
|
||||
|
||||
## 3. 인수조건 (Acceptance Criteria)
|
||||
- [x] **AC-A1** Pixel/Galaxy gesture nav 단말에서 입력창 + send 버튼이 nav bar 와 겹치지 않음.
|
||||
- [x] **AC-B1** send 실패 시 빨간 배너에 `LLM 응답 실패: <Type>\n<message>\n--- STACK ---\n<stack>` 표시. SelectableText 라 복사 가능.
|
||||
- [x] **AC-B2** 빨간 배너 높이는 화면의 1/3 을 넘지 않고, 내부 스크롤로 전체 노출.
|
||||
- [x] **AC-C1** 습관 카드 부제가 `build · L3 · …` → `만들기 · …` (frameLevel 식별자 제거).
|
||||
- [x] **AC-C2** 스트릭 화면 현재 티어가 `T0` / `T1` (raw) → `🌱 새싹` / `🥉 3회 도전` ….
|
||||
- [x] **AC-C3** 스트릭 화면 강등 경고가 `Never miss twice 발동 — 티어 강등` → `이틀 연속 빠졌어요. 한 단계 강등됐습니다.` (영문 잠언 제거).
|
||||
- [x] **AC-C4** 습관 추가 드롭다운이 `만들기 (build)` → `만들기` (식별자 병기 제거).
|
||||
- [x] **AC-C5** 스트릭 화면의 현재 스트릭이 `displayLarge` hero + 티어 라벨로 시각 위계 강조.
|
||||
- [x] **AC-D1** ChatScreen 첫 진입 시 빈 메시지 리스트 대신 안내 (아이콘 + 한 줄 설명 + 예시 prompt 4개). tap → 입력창 자동 채움 (자동 send X — 사용자 수정 여지).
|
||||
- [x] **AC-D2** CheckIn 화면 날짜 `2026-06-15` raw → `6월 15일 (월)` 한국식. DB 저장은 `_ymd` 유지.
|
||||
- [x] **AC-D3** HabitCreate 의 `프레임 레벨` → `표현 방식` (+ helperText `행동 위주 vs 정체성 위주`). 아이템 라벨 `L2 · 조건부 긍정` → `조건부 행동 (예: 아침에 햇빛 받기)` 식 예시 포함.
|
||||
- [x] **AC-D** 167 기존 테스트 회귀 없음, `flutter analyze` clean.
|
||||
|
||||
## 4. 컨텍스트 & 제약
|
||||
- **의존성**: flutter_gemma 0.16.5 (B 변경 안 함), Riverpod 2.x, Drift row 의 raw String enum.
|
||||
- **제약**:
|
||||
- dev 단계 hotfix — release 노출 가능한 stack 도 허용 (사용자 본인 단말 진단 우선).
|
||||
- C 의 라벨 매핑은 UI 레이어 단일 지점 (`ui/labels.dart`) — domain enum 에 `koreanLabel` 두지 않음 (관심사 분리).
|
||||
- **가정**:
|
||||
- `h.type` 은 Drift row 의 String — `HabitTypeX.dbValue` 와 동일한 wire 값 (`'build'` / `'break'`).
|
||||
- `RewardTier` 의 사용자 명칭은 메모리상 5-Tier 정의 — 🌱 새싹 / 🥉 3회 / 🥈 7일 / 🥇 30일 / 🏆 6주 완주.
|
||||
|
||||
## 5. 아키텍처 개요
|
||||
순수 string 매핑 + Widget tree 재구성. 신규 모듈 없음.
|
||||
|
||||
```
|
||||
ChatScreen
|
||||
├─ Scaffold.body — SafeArea(top: false) ← AC-A1
|
||||
│ └─ Column
|
||||
│ ├─ _WarmupErrorBanner (변경 없음)
|
||||
│ ├─ Container(error) ← AC-B1/B2
|
||||
│ │ constraints: maxHeight: screen/3
|
||||
│ │ child: SingleChildScrollView(SelectableText, monospace 12pt)
|
||||
│ └─ ListView (변경 없음)
|
||||
|
||||
ChatSessionController.userTurn ← AC-B1
|
||||
└─ catch (e, st) → state.error = "LLM 응답 실패: ${e.runtimeType}\n$e\n\n--- STACK ---\n$st"
|
||||
|
||||
ui/labels.dart ← AC-C1~C4
|
||||
├─ habitTypeLabel(HabitType) → '만들기' / '없애기'
|
||||
├─ habitTypeLabelFromDb(String) → ↑ (Drift raw 분기, 기본 fallback = dbValue)
|
||||
└─ rewardTierLabel(RewardTier) → '🌱 새싹' / '🥉 3회 도전' / … / '🏆 6주 완주'
|
||||
|
||||
habit_list_screen / streak_screen / habit_create_screen
|
||||
└─ raw enum 노출 지점 모두 labels.dart 의 함수로 교체
|
||||
```
|
||||
|
||||
## 6. 데이터 모델
|
||||
신규 모델 없음. 매핑 도메인은 기존 enum (`HabitType`, `FrameLevel`, `RewardTier`) 의 표현 레이어만 분리.
|
||||
|
||||
| Enum | Raw (DB/wire) | UI 라벨 |
|
||||
|---|---|---|
|
||||
| `HabitType.build` | `'build'` | `만들기` |
|
||||
| `HabitType.breakHabit` | `'break'` | `없애기` |
|
||||
| `RewardTier.t0` | `'T0'` | `🌱 새싹` |
|
||||
| `RewardTier.t1` | `'T1'` | `🥉 3회 도전` |
|
||||
| `RewardTier.t2` | `'T2'` | `🥈 7일 형성` |
|
||||
| `RewardTier.t3` | `'T3'` | `🥇 30일 정착` |
|
||||
| `RewardTier.t4` | `'T4'` | `🏆 6주 완주` |
|
||||
|
||||
`FrameLevel` 은 본 hotfix 에서 UI 노출을 **제거** — 사용자에 의미 모호 (L2/L3 차이가 즉시 보이지 않음). 라벨 매핑 미작성.
|
||||
|
||||
## 7. 함수 명세
|
||||
|
||||
| 함수 | 책임 | 시그니처 | 복잡? |
|
||||
|------|------|----------|-------|
|
||||
| `habitTypeLabel` | enum → 한국어 라벨 | `String habitTypeLabel(HabitType)` | 단순 (switch) |
|
||||
| `habitTypeLabelFromDb` | Drift raw String → 한국어 (fallback = raw) | `String habitTypeLabelFromDb(String)` | 단순 (switch + default) |
|
||||
| `rewardTierLabel` | enum → 이모지+한국어 | `String rewardTierLabel(RewardTier)` | 단순 (switch) |
|
||||
|
||||
모두 단순 string switch 라 `fn-*.md` 분리 불필요.
|
||||
|
||||
## 8. 흐름 / 알고리즘
|
||||
- A: `Scaffold.body` 가 `SafeArea` 로 감싸지면서 system bottom inset 만큼 padding 자동 적용. `top: false` 인 이유는 AppBar 가 이미 top inset 처리 (이중 padding 방지).
|
||||
- B: `Future.try-catch (e, st)` 에서 stack trace 까지 함께 string concat → state → 빨간 컨테이너의 `SelectableText` 로 노출. 사용자가 텍스트 선택 → 복사해 외부에 공유 가능.
|
||||
- C: 라벨 매핑은 분기/상태/I/O 없음. switch one-liner.
|
||||
|
||||
## 9. 테스트 전략
|
||||
- 신규 unit 추가 없음 — 라벨 매핑은 상수 매핑이라 unit 가치 낮음.
|
||||
- SafeArea + 빨간 배너는 widget 레이어 변경이지만 LLM 단말 시도 자체가 차단 상태 (#312 corpus collection blocker) — manual 검증으로 대체.
|
||||
- 167 기존 테스트 회귀 없음으로 단위/통합/도메인 보호.
|
||||
|
||||
## 10. 후속 (v0.4.3 또는 별개 이슈)
|
||||
- `BackendInitException: model may be invalid` 진단/수정 — `maxTokens=2048` 의 GPU buffer 후보. 단말 빌드 비용 때문에 분리.
|
||||
- release 빌드에서 stack 숨김 (사용자 친화 메시지로).
|
||||
|
||||
## 11. 추적성
|
||||
- **Redmine**: #342 (07-Release, dev hotfix bundle).
|
||||
- **선행**: #311 (v0.4.1 warm-up — 빨간 배너 자체는 v0.4.1 에서 도입, 본 hotfix 가 진단성 강화).
|
||||
- **관련**: #312 (corpus collection — LLM 동작 의존, B 진단 완료까지 블로커).
|
||||
70
docs/guides/ai-chat-using.md
Normal file
70
docs/guides/ai-chat-using.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# AI 코치와 대화하기 (사용자 가이드)
|
||||
|
||||
> 적용 버전: **v0.4.0 이상** (warm-up 은 **v0.4.1+**) · Redmine #260 / #311 · 관련 레퍼런스: [도구 호출](../reference/260-in-app-tool-calling.md) · [Warm-up](../reference/311-llm-warmup.md)
|
||||
>
|
||||
> 본 가이드는 v0.3.0 의 [AI 도움 켜기·끄기](ai-help-onboarding.md) 가 끝났다는 전제에서 시작합니다. 모델이 단말에 준비되어 있어야 합니다.
|
||||
|
||||
v0.4.0 부터는 자유 문장으로 AI 코치에게 카탈로그를 묻거나, 습관을 추가하거나, 오늘의 체크인을 기록할 수 있습니다. 모든 처리는 단말에서 일어나며 입력 텍스트는 외부로 나가지 않습니다.
|
||||
|
||||
## 진입
|
||||
|
||||
1. **습관 목록** 화면 진입.
|
||||
2. AppBar 의 🤖 (smart toy) 아이콘 탭.
|
||||
- AI 도움이 꺼져 있으면 아이콘이 보이지 않습니다 — [AI 도움 켜기](ai-help-onboarding.md) 먼저 진행해주세요.
|
||||
|
||||
## 가능한 대화 예시
|
||||
|
||||
- "아침 햇빛 프로토콜이 뭐야?" → AI 가 카탈로그에서 찾아 한국어로 요약.
|
||||
- "수면 관련 습관 추천해줘" → 카테고리 검색 결과를 보여주고 어떤 것부터 시작할지 제안.
|
||||
- "아침 햇빛 보기 습관 추가해줘" → **확인 다이얼로그가 떠야** 추가됩니다 (아래 §확인 게이트).
|
||||
- "오늘 햇빛 체크해줘" → 같은 식의 확인 다이얼로그.
|
||||
- "내 스트릭 어때?" → 현재 연속일수 + 5-tier 보상 등급 안내.
|
||||
|
||||
## 확인 게이트 (destructive actions)
|
||||
|
||||
다음 작업은 사용자의 명시적 확인 없이는 절대 수행되지 않습니다:
|
||||
|
||||
- **습관 추가** (`add_habit`)
|
||||
- **체크 기록** (`log_tracker_entry`)
|
||||
|
||||
확인 다이얼로그가 뜨면:
|
||||
- **수행** — 작업 진행
|
||||
- **취소** — 작업 중단 (대화에는 `🛠 습관 추가 → 취소됨` 라벨이 남습니다)
|
||||
- 바깥 영역 탭 = 취소
|
||||
|
||||
## 안전장치
|
||||
|
||||
- 한 대화당 최대 4번의 도구 호출 — 무한 루프 방지.
|
||||
- 8 turn 도달 시 "대화가 길어졌어요" 안내 — 오른쪽 위 ↻ 로 새 대화 시작 권장 (이전 기록은 비워짐).
|
||||
- AI 가 잘못된 인자로 도구를 호출하면 검증 오류 메시지를 모델에게 돌려주고, AI 가 다시 시도합니다. 앱이 죽지 않습니다.
|
||||
- "술 끊기" 같은 회피 표현은 R7 규칙으로 자동 거부 + 긍정 프레임 제안 ("맑은 정신을 즐긴다" 등).
|
||||
- 활성 build 습관 3개 / break 습관 1개 초과 시 R3 규칙으로 거부 — 기존 습관을 정리해야 추가 가능.
|
||||
- 같은 날짜의 같은 습관에 두 번 체크하면 중복 안내 — 덮어쓰려면 기존 항목 삭제 후 다시 시도.
|
||||
|
||||
## 자주 묻는 질문
|
||||
|
||||
**Q. 대화 기록은 어디에 저장되나요?**
|
||||
A. 메모리만 — 화면을 떠나거나 ↻ 를 누르면 사라집니다. 단말에 저장되지 않습니다.
|
||||
|
||||
**Q. AI 가 같은 도구를 계속 호출하면?**
|
||||
A. 4 회를 넘기면 자동으로 멈추고 "도구 호출 루프가 너무 길어 중단했습니다" 안내가 뜹니다.
|
||||
|
||||
**Q. 응답이 너무 느린 것 같아요.**
|
||||
A. 단말 모델이라 1턴 평균 2~5초가 정상입니다. AI 가 도구를 호출하면 round trip 이 한 번 더 들어가 6~10초가 될 수 있어요.
|
||||
|
||||
**Q. ChatScreen 진입 시 입력창에 "AI 준비 중…" 이 떠요.**
|
||||
A. v0.4.1 부터 모델을 백그라운드로 미리 시동합니다 (warm-up). 첫 진입 시 한 번만 보이고 평균 2~8초 안에 사라집니다. 그동안 메시지를 미리 타이핑해도 되고, send 버튼만 비활성 상태로 기다립니다. 다시 진입하면 즉시 사용 가능합니다.
|
||||
|
||||
**Q. "AI 모델 파일을 찾을 수 없어요." / "AI 를 시작하지 못했어요." 가 떠요.**
|
||||
A. 파일을 못 찾는 경우는 [설정으로 가기] 로 이동해 재다운로드, 일시적 시작 실패는 [다시 시도] 로 회복합니다. 설정에서 다운로드를 완료하고 돌아오면 자동으로 다시 시도합니다.
|
||||
|
||||
**Q. 도구 결과가 잘렸어요.**
|
||||
A. 모델 컨텍스트 보호를 위해 도구 결과는 2KB 로 제한됩니다. 잘림이 발생하면 AI 가 자동으로 단건 조회 (`프로토콜 상세`) 도구를 다시 부르도록 안내됩니다.
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [AI 도움 켜기·끄기](ai-help-onboarding.md)
|
||||
- 설계서: [docs/design/260-gemma-tool-calling/](../design/260-gemma-tool-calling/)
|
||||
- 결정 기록: [docs/adr/0005-in-app-tool-calling-architecture.md](../adr/0005-in-app-tool-calling-architecture.md)
|
||||
- API 레퍼런스: [docs/reference/260-in-app-tool-calling.md](../reference/260-in-app-tool-calling.md)
|
||||
- 변경 이력: [CHANGELOG.md](../../CHANGELOG.md)
|
||||
98
docs/guides/ai-help-onboarding.md
Normal file
98
docs/guides/ai-help-onboarding.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# AI 도움 켜기·끄기 (사용자 가이드)
|
||||
|
||||
> 적용 버전: **v0.3.0 이상** (v0.2.0 은 placeholder URL 로 다운로드 graceful 실패) · Redmine #215 / #218 · 관련 레퍼런스: [docs/reference/215-ai-frame-suggest.md](../reference/215-ai-frame-suggest.md)
|
||||
>
|
||||
> v0.3.0 부터 **단말 RAM ≥ 4GB** 가 필수입니다. 4GB 미만 단말은 설정 화면의 토글이 비활성으로 표시됩니다.
|
||||
|
||||
life-helper 는 사용자가 입력한 자유 문장(예: "술 끊고 싶어")을 Huberman 프로토콜 기반 L2/L3 프레임 문장으로 변환해주는 **단말 내 AI 보조**를 제공합니다. 모든 처리는 단말에서만 일어나며, 입력 텍스트는 외부로 전송되지 않습니다.
|
||||
|
||||
## 누구를 위한 가이드인가
|
||||
|
||||
- 새 습관을 추가할 때 "어떻게 표현하면 좋을지" 막막한 사용자.
|
||||
- AI 기능을 켜기 전에 데이터/저장공간/배터리 영향을 미리 확인하고 싶은 사용자.
|
||||
|
||||
## 핵심 원칙
|
||||
|
||||
1. **기본 OFF.** AI 기능은 사용자가 명시적으로 켜야 동작합니다.
|
||||
2. **단말 처리.** 입력 텍스트는 단말 밖으로 나가지 않습니다.
|
||||
3. **수동 입력 100% 유지.** AI 가 꺼져 있거나 모델 다운로드가 안 되어 있어도, "프레임 문구" 입력란에 직접 작성하는 경로는 항상 살아있습니다.
|
||||
|
||||
## AI 도움 켜기
|
||||
|
||||
1. 하단 탭에서 **설정** 진입.
|
||||
2. "AI 도움 켜기" 토글 탭.
|
||||
3. 동의 다이얼로그가 뜹니다:
|
||||
- **파일 크기 ≈ 2.4GB** (Gemma 4 E2B Q4_0 모델 — 단말에 한 번만 다운로드)
|
||||
- **WiFi 연결 권장** (셀룰러 대역폭 절약)
|
||||
- 모든 처리는 단말 — 입력 텍스트 외부 송출 없음
|
||||
4. **"동의하고 다운로드"** 탭 → 백그라운드 다운로드 시작.
|
||||
|
||||
### 다운로드 진행 화면
|
||||
|
||||
설정 화면 "AI 도움" 섹션 아래에 진행 상태가 표시됩니다:
|
||||
|
||||
| 상태 | 표시 | 가능한 조작 |
|
||||
|---|---|---|
|
||||
| 다운로드 중 | 진행률 % + 받은 용량 | **일시정지** 버튼 |
|
||||
| 일시정지됨 | 마지막 진행률 | **재개** 버튼 (이어받기) |
|
||||
| 실패 | 한국어 안내 메시지 (네트워크/서버/손상별) | **다시 시도** 버튼 |
|
||||
| 준비 완료 | "준비 완료" 라벨 + 받은 용량 | (조작 없음 — 사용 가능) |
|
||||
|
||||
다운로드 도중 앱을 강제 종료해도 다음 실행 시 같은 자리에서 이어받습니다 (HTTP Range 기반).
|
||||
|
||||
### 다운로드 실패 시 한국어 안내
|
||||
|
||||
| 화면 메시지 | 의미 | 권장 조치 |
|
||||
|---|---|---|
|
||||
| "네트워크 연결을 확인하고 다시 시도해주세요." | 일시적 끊김 | WiFi 확인 후 [다시 시도] |
|
||||
| "서버 응답이 올바르지 않습니다. 잠시 후 다시 시도해주세요." | 서버 측 문제 | 시간 두고 [다시 시도] |
|
||||
| "다운로드가 중단되었어요. 다시 시도하면 이어받습니다." | 스트림 중단 | [다시 시도] — 받은 데이터는 보존 |
|
||||
| "파일이 손상되었어요. 다시 시도하면 처음부터 받습니다." | 무결성 검증 실패 | [다시 시도] — 자동으로 처음부터 |
|
||||
|
||||
## AI 사용하기
|
||||
|
||||
1. **새 습관** 화면 진입.
|
||||
2. "제목" 에 자유 문장 입력 (예: "술 끊고 싶어").
|
||||
3. "프레임 문구" 입력란 아래 **✨ AI 제안** 버튼 탭.
|
||||
- AI 도움이 꺼져 있으면 버튼이 보이지 않습니다.
|
||||
- AI 도움은 켜졌지만 모델 다운로드가 아직 완료되지 않았다면 버튼은 비활성 상태로 보이고 "AI 도움을 먼저 켜주세요" 툴팁이 표시됩니다.
|
||||
4. 다이얼로그에 후보가 **최대 3개** 표시됩니다 (L2 조건부 긍정 2개 + L3 정체성 1개 권장).
|
||||
5. 마음에 드는 후보 카드 탭 → "프레임 문구" 입력란이 자동으로 채워지고 프레임 레벨이 자동 선택됩니다.
|
||||
6. 저장.
|
||||
|
||||
### 후보가 없거나 마음에 안 들 때
|
||||
|
||||
- "더 구체적으로 입력해주시면 더 좋은 제안을 드릴 수 있어요" 메시지가 보이면 제목을 더 명확히 작성한 뒤 [다시 시도].
|
||||
- 제안을 받지 못해도 **프레임 문구를 직접 입력하셔도 괜찮습니다**.
|
||||
|
||||
## AI 도움 끄기
|
||||
|
||||
1. **설정** → "AI 도움 켜기" 토글 OFF.
|
||||
2. 확인 다이얼로그:
|
||||
- 모델 파일이 단말에서 **즉시 삭제** 됩니다.
|
||||
- 약 2.4GB 의 저장공간이 확보됩니다.
|
||||
- 다시 켜면 다시 다운로드해야 합니다.
|
||||
3. **"끄고 삭제"** 탭 → "공간 확보됨 2469 MB" 토스트.
|
||||
|
||||
진행 중인 다운로드가 있어도 깔끔히 중단되고, `.tmp` 임시 파일까지 함께 삭제됩니다.
|
||||
|
||||
## 자주 묻는 질문
|
||||
|
||||
**Q. 입력 텍스트가 외부로 나가나요?**
|
||||
A. 아니요. 단말 내 추론만 사용합니다. 다운로드는 모델 파일을 받을 때 한 번만 발생합니다.
|
||||
|
||||
**Q. AI 가 만들어준 문장이 마음에 안 들면?**
|
||||
A. 직접 입력란을 고쳐 쓰면 됩니다. AI 제안은 채우기 도우미일 뿐, 저장 시점 검증(L0/L1 금지 등)은 변하지 않습니다.
|
||||
|
||||
**Q. 모델 파일이 너무 큽니다.**
|
||||
A. 언제든 끌 수 있고, 끄면 즉시 삭제됩니다. 다시 켜면 다시 받아야 한다는 점만 유의하세요.
|
||||
|
||||
**Q. v0.2.0 에서 다운로드가 항상 실패합니다.**
|
||||
A. v0.2.0 은 모델 URL 이 미확정 (OQ-1) 인 상태로 출시되어, 실제 다운로드는 의도된 graceful 실패 경로로 안내됩니다. **v0.3.0 부터 실 Gemma 4 E2B 모델 (HuggingFace) 다운로드가 활성화되었습니다.** 그동안 수동 입력 경로는 정상 동작합니다.
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- 설계서: [docs/design/215-gemma-frame-suggest/](../design/215-gemma-frame-suggest/)
|
||||
- 결정 기록: [docs/adr/0003-on-device-llm-gemma.md](../adr/0003-on-device-llm-gemma.md)
|
||||
- API 레퍼런스: [docs/reference/215-ai-frame-suggest.md](../reference/215-ai-frame-suggest.md)
|
||||
- 변경 이력: [CHANGELOG.md](../../CHANGELOG.md)
|
||||
193
docs/reference/215-ai-frame-suggest.md
Normal file
193
docs/reference/215-ai-frame-suggest.md
Normal file
@@ -0,0 +1,193 @@
|
||||
# Reference: AI 프레임 제안 (#215 + #218, v0.3.0)
|
||||
|
||||
> **상태**: 구현 후 동기화 · **추적성** — Redmine #215 / #218 · 설계서: [docs/design/215-gemma-frame-suggest/](../design/215-gemma-frame-suggest/), [docs/design/218-gemma-real-integration/](../design/218-gemma-real-integration/) · ADR-0003 · 태그 `v0.2.0` (placeholder) → `v0.3.0` (real Gemma 4, commit da60dd1)
|
||||
>
|
||||
> 본 문서는 v0.3.0 의 **실제 코드 사양**이다. v0.2.0 의 placeholder 상태는 #218 에서 실 Gemma 4 E2B + flutter_gemma 0.16.5 통합으로 대체됨. 설계 의도/대안은 설계서·ADR 을 참조.
|
||||
|
||||
## 1. 모듈 지도
|
||||
|
||||
```
|
||||
lib/
|
||||
data/ai/
|
||||
llm_service.dart — LlmService 추상 + MockLlmService
|
||||
gemma_llm_service.dart — GemmaLlmService (flutter_gemma 0.16.5 + Gemma 4 E2B 실 구현)
|
||||
model_lifecycle.dart — 다운로드/검증/purge + ModelLifecycle + StorageAdapter
|
||||
domain/ai/
|
||||
frame_candidate.dart — FrameCandidate, FrameLevel (enum)
|
||||
suggest_frame.dart — suggestFrame() 메인 함수 + L2:2+L3:1 분포
|
||||
few_shot_builder.dart — buildFewShotPrompt()
|
||||
parse_response.dart — parseFrameCandidates()
|
||||
state/
|
||||
ai_providers.dart — Riverpod providers + ModelDownloadController
|
||||
ui/
|
||||
screens/settings_screen.dart — AI 도움 토글 + 다운로드 진행률
|
||||
widgets/frame_suggestion_dialog.dart — 후보 카드 선택
|
||||
screens/habit_create_screen.dart — _AiSuggestButton (3분기)
|
||||
```
|
||||
|
||||
## 2. 도메인 모델
|
||||
|
||||
### `FrameCandidate` (`lib/domain/ai/frame_candidate.dart`)
|
||||
|
||||
| 필드 | 타입 | 의미 |
|
||||
|---|---|---|
|
||||
| `level` | `FrameLevel` | `l0` / `l1` / `l2` / `l3` (출력에는 L2/L3 만 살아남음) |
|
||||
| `framedText` | `String` | 모델이 생성한 한국어 문장 (≤120자) |
|
||||
| `confidence` | `double` | 0.0~1.0 (모델이 반환한 score, 없으면 0.5) — UI 표시 X |
|
||||
| `sourcePatternId` | `String?` | few-shot 매칭에 쓰인 `FramePattern.id` |
|
||||
|
||||
### Function-calling 스키마 (`kFrameCandidatesSchema`)
|
||||
|
||||
`suggest_frame.dart` 상단의 `const Map<String, dynamic>`. `emit_frame_candidates` 함수의 parameters. `minItems:1 / maxItems:3`, 각 `item.required = ['level','framed_text']`.
|
||||
|
||||
## 3. 핵심 함수
|
||||
|
||||
### `suggestFrame(input, {llm, framePatterns, timeout=10s}) → List<FrameCandidate>`
|
||||
|
||||
순수에 가까움 (`llm` + `framePatterns` 만 의존). **절대 throw 하지 않음**. 모든 실패 → `const []`.
|
||||
|
||||
흐름:
|
||||
1. `input.rawText.trim()` 길이 검사 (1~200자). 벗어나면 빈 리스트.
|
||||
2. `buildFewShotPrompt(input, framePatterns)` 로 prompt 조립.
|
||||
3. `llm.generateStructured(prompt, schema).timeout(10s)` 호출. 어떤 예외든 catch → 빈 리스트.
|
||||
4. `parseFrameCandidates(json)` 으로 디코드. `FormatException` catch → 빈 리스트.
|
||||
5. 각 후보에 `validateFrameLevel` 적용. `reject` 인 후보만 드랍.
|
||||
6. `_shapeDistribution(validated, l2Quota:2, l3Quota:1)` — L2 먼저 최대 2개 + L3 최대 1개. **부족 시 패딩 X** (graceful — 적은 카드).
|
||||
|
||||
### `buildFewShotPrompt(input, framePatterns, {maxFewShot=5}) → String`
|
||||
|
||||
순수. `_tokenize` (whitespace + 한국어 punctuation 분리) → `_scorePattern` (avoidanceKeyword 3점 + domain 1점) → 상위 N개. 매칭 0 이면 시드 앞에서 N개. patterns 비어있으면 시스템 prompt 만 emit.
|
||||
|
||||
마지막에 명시 지시: `"L2 2개 + L3 1개, 총 3개 후보로 변환. 순서는 L2 → L2 → L3. emit_frame_candidates 함수로 호출."`
|
||||
|
||||
### `parseFrameCandidates(json) → List<FrameCandidate>`
|
||||
|
||||
- 최상위 `candidates` 없거나 `List` 아니면 `throw FormatException`.
|
||||
- 개별 item 결손 (level/framed_text 미존재, 빈 문자열, >120자) → 조용히 skip.
|
||||
- `level` 은 대소문자 무시 매칭.
|
||||
- `confidence` 결손 시 0.5 기본값, 범위 밖이면 `clamp(0, 1)`.
|
||||
|
||||
## 4. 데이터 계층
|
||||
|
||||
### `LlmService` (abstract)
|
||||
|
||||
```dart
|
||||
abstract class LlmService {
|
||||
bool get isLoaded;
|
||||
Future<void> load();
|
||||
Future<void> unload(); // idempotent
|
||||
Future<Map<String, dynamic>> generateStructured(String prompt, Map schema);
|
||||
}
|
||||
```
|
||||
|
||||
계약:
|
||||
- `load` 후 `isLoaded == true`.
|
||||
- 미로드 상태에서 `generateStructured` 호출 → `StateError`.
|
||||
- 스키마/응답 깨짐 → `FormatException`.
|
||||
- timeout 은 **호출자 책임** (`suggestFrame` 가 10s 적용).
|
||||
|
||||
구현 2개:
|
||||
- `MockLlmService` — `enqueueResponse(Map)` / `enqueueError(Object)`. `callCount`, `lastPrompt`, `lastSchema`, `responseDelay` 노출. **v1 런타임 기본 주입**.
|
||||
- `GemmaLlmService` — flutter_gemma 0.16.5 + Gemma 4 E2B 실 구현 (#218). `load` 는 `FlutterGemma.initialize` → `installModel(modelType: gemma4, fileType: litertlm).fromFile(modelPath).install()` → `getActiveModel(maxTokens: 2048)`. `generateStructured` 는 `createChat(modelType: gemma4, supportsFunctionCalls: true, toolChoice: required, tools: [Tool(...)])` + `collectFunctionCall(stream, fnName)` 로 SDK 의 native function calling 사용. `_lazyLlmService` (main.dart) 가 ModelLifecycle 의 availability 에 따라 Gemma vs Mock 자동 분기.
|
||||
|
||||
### `ModelLifecycle` (`lib/data/ai/model_lifecycle.dart`)
|
||||
|
||||
생성자 의존성: `MetaDao meta`, `ModelConfig config`, `StorageAdapter? storage`, `http.Client? httpClient`.
|
||||
|
||||
| 메서드 | 시그니처 | 비고 |
|
||||
|---|---|---|
|
||||
| `checkAvailability` | `Future<ModelAvailability>` | `ready` / `missing` / `corrupt` / `downloading`. 옵트인 OFF → 항상 `missing`. SHA mismatch → `corrupt`. 어떤 I/O 예외든 catch → `corrupt`. |
|
||||
| `download` | `Stream<DownloadProgress>` | Range 기반 resume. SHA-256 검증 후 `.tmp → final.rename()`. 모든 실패는 `state: failed` 로 emit (throw X). |
|
||||
| `purge` | `Future<int>` | 해제된 byte 수. 파일 + `.tmp` + meta_kv 4키 (`ai_opt_in` 제외) 삭제. F2 hardening (#218): `File.delete()` / `temp.delete()` / `meta.remove()` 각각 try/catch 로 감쌈 → 단일 OS 플레이크가 opt-out 을 막지 않음. freed-bytes 는 성공한 삭제만 합산. |
|
||||
|
||||
`StorageAdapter` 는 `supportDir()` + `rangeGet(Uri, int)` 2개 메서드만 — 테스트가 `_FakeStorage` 로 주입.
|
||||
|
||||
### meta_kv 키 5개 (`AiMetaKeys`)
|
||||
|
||||
| 키 | 값 | 의미 |
|
||||
|---|---|---|
|
||||
| `ai_opt_in` | `'true'` / `'false'` | 사용자 옵트인 |
|
||||
| `ai_model_path` | 절대경로 | 다운로드 완료 시 |
|
||||
| `ai_model_sha256` | hex string | 검증 통과 시 |
|
||||
| `ai_download_state` | `'idle'` / `'downloading'` / `'paused'` / `'completed'` / `'failed'` | 진행 상태 |
|
||||
| `ai_download_bytes` | int as string | 재시작 시 resume 좌표 |
|
||||
|
||||
→ Drift schema 변경 0. `meta_kv` 테이블은 #204 에서 이미 존재.
|
||||
|
||||
## 5. 상태 계층 (Riverpod, `lib/state/ai_providers.dart`)
|
||||
|
||||
| Provider | 타입 | 책임 |
|
||||
|---|---|---|
|
||||
| `modelLifecycleProvider` | `Provider<ModelLifecycle>` | 실 URL: `https://huggingface.co/litert-community/gemma-4-E2B-it-litert-lm/resolve/main/gemma-4-E2B-it.litertlm`, SHA-256 `181938105e...39a63c`, 2.41GB (#218) |
|
||||
| `aiSettingsProvider` | `FutureProvider<bool>` | meta_kv 읽어서 옵트인 상태 |
|
||||
| `aiSettingsControllerProvider` | `Provider<AiSettingsController>` | `setOptIn(bool) → int(freed)` |
|
||||
| `modelDownloadControllerProvider` | `StateNotifierProvider<ModelDownloadController, DownloadProgress?>` | start / pause / resume / cancel |
|
||||
| `modelAvailabilityProvider` | `FutureProvider<ModelAvailability>` | `lifecycle.checkAvailability()` |
|
||||
| `framePatternsProvider` | `FutureProvider<List<FramePatternModel>>` | Drift → 도메인 |
|
||||
| `llmServiceProvider` | `Provider<LlmService>` | **반드시 override** — `main.dart` 가 `MockLlmService` 주입 |
|
||||
| `frameSuggestionsProvider` | `FutureProvider.autoDispose.family<List<FrameCandidate>, SuggestFrameInput>` | `llm.load` (실패 시 빈 리스트) → `suggestFrame` |
|
||||
|
||||
### `AiSettingsController.setOptIn(value)`
|
||||
|
||||
- `value=true`: `meta_kv['ai_opt_in']='true'` → invalidate(settings, availability) → `ModelDownloadController.start()` 호출 (AC2 — 다운로드 스트림 시작).
|
||||
- `value=false`: `ModelDownloadController.cancel()` → `ModelLifecycle.purge()` → `meta_kv['ai_opt_in']='false'` → invalidate. 반환: 해제된 byte 수.
|
||||
|
||||
### `ModelDownloadController`
|
||||
|
||||
- `start()`: 기존 subscription cancel 후 `lifecycle.download().listen(...)`. 완료 시 `modelAvailabilityProvider` invalidate.
|
||||
- `pause()`: subscription cancel + state 를 `paused` 로. `.tmp` 파일 + meta_kv 보존 → 다음 `start()` 가 Range 로 resume.
|
||||
- `resume()` = `start()` alias.
|
||||
- `cancel()`: subscription cancel + state = `null` (idle).
|
||||
|
||||
## 6. UI 계층
|
||||
|
||||
### `SettingsScreen` (`lib/ui/screens/settings_screen.dart`)
|
||||
|
||||
- `SwitchListTile` — 토글 시 옵트인은 동의 다이얼로그 (저장공간/WiFi/단말 처리 3개 bullet) → `setOptIn(true)`. 옵트아웃은 확인 다이얼로그 → `setOptIn(false)` → "공간 확보됨 X.X MB" 토스트.
|
||||
- `_DownloadProgressTile` — 옵트인 ON + 진행 중일 때만 표시. 상태별 컬러 라벨 + 둥근 `LinearProgressIndicator(minHeight:6)` + `FilledButton.tonalIcon` 재개/재시도. `_friendlyError()` 가 내부 코드를 한국어로 매핑:
|
||||
- `network:*` → "네트워크 연결을 확인하고 다시 시도해주세요."
|
||||
- `http *` → "서버 응답이 올바르지 않습니다."
|
||||
- `stream:*` → "다운로드가 중단되었어요. 다시 시도하면 이어받습니다."
|
||||
- `sha mismatch` → "파일이 손상되었어요. 다시 시도하면 처음부터 받습니다."
|
||||
|
||||
### `_AiSuggestButton` (3분기, AC6)
|
||||
|
||||
| optIn | availability | 렌더 |
|
||||
|---|---|---|
|
||||
| false | * | `SizedBox.shrink()` (숨김) |
|
||||
| true | `!= ready` | `TextButton` (disabled) + `Tooltip("AI 도움을 먼저 켜주세요")` |
|
||||
| true | `ready` | `TextButton` (enabled, tap → `FrameSuggestionDialog.show`) |
|
||||
|
||||
### `FrameSuggestionDialog`
|
||||
|
||||
`AlertDialog` 안에 `frameSuggestionsProvider(input).when(loading/error/data)`. data 가 empty 면 "더 구체적으로 입력해주시면 더 좋은 제안을 드릴 수 있어요" + "다시 시도" 버튼. data 가 있으면 `_CandidateCard` 리스트 — L3 는 `scheme.primary` 배지, L2 는 `scheme.secondary` 배지. 탭 시 `Navigator.pop(c)` 로 `FrameCandidate` 반환.
|
||||
|
||||
## 7. 테스트 매핑
|
||||
|
||||
| AC | 테스트 파일 | 케이스 수 |
|
||||
|---|---|---|
|
||||
| AC1 | `flutter analyze` + `flutter build apk --debug/release` | CI |
|
||||
| AC2 | `test/state/model_download_controller_test.dart` | 3 |
|
||||
| AC3, AC8 | `test/data/ai/model_lifecycle_test.dart` | 7 |
|
||||
| AC4 | `test/domain/ai/suggest_frame_test.dart` (분포 3) | 3 |
|
||||
| AC5 | `test/domain/ai/suggest_frame_test.dart` (FrameLevel 사용) | 1 |
|
||||
| AC6 | `test/ui/ai_suggest_button_visibility_test.dart` | 4 |
|
||||
| AC7 | `test/domain/ai/parse_response_test.dart` | 8 |
|
||||
| AC9 | `test/domain/ai/suggest_frame_test.dart` (graceful) | 다수 |
|
||||
| AC10 | (별도 이슈 #221 — corpus 품질 평가) | — |
|
||||
| #218 AC8 | `test/data/ai/gemma_llm_service_test.dart` (`collectFunctionCall` 8 + ParallelFCR 2) | 10 |
|
||||
|
||||
신규 합계 41 (parse 8 + few_shot 7 + suggest 9+3 + lifecycle 7 + AC6 widget 4 + AC2 state 3 + gemma_llm 10). v0.3.0-dev 시점 전체 81 통과 / analyze 0.
|
||||
|
||||
## 8. 알려진 제약
|
||||
|
||||
- **#218 AC-6 (디바이스 게이트)**: 저사양 단말 (RAM < 4GB) 에서는 SettingsScreen 의 AI 토글 disabled + "이 단말에서는 AI 도움을 사용할 수 없어요 (RAM 4GB 이상 필요)" 안내. RAM 조회 = MethodChannel `life_helper/device_caps` → `MainActivity.kt` 에서 `ActivityManager.MemoryInfo.totalMem`. `device_info_plus` 도 deps 에 있지만 RAM 임계 (4GB) 측정엔 미사용 (해당 패키지는 `isLowRamDevice` ≈ 1GB 만 제공). iOS 는 #218 범위 밖.
|
||||
- **#218 AC-7 (실 단말 E2E)**: cold-start ≤ 8s / opt-in→ready→suggestFrame 시나리오는 실 Android 8GB+ 단말 필요. Host CI 는 검증 불가 → 별도 디바이스 랩 단계.
|
||||
- **F1**: 설계서 §8.8 / §9 의 "60초 idle 시 `unload()`" 미구현. #219 별도 이슈 — 단발 호출 후 즉시 unload 가 안전한 기본값.
|
||||
- **#221 AC10 corpus 품질 평가**: 30+ 한국어 라이브 입력 corpus 로 L2 ≥ 70% / L3 ≥ 50% 측정. 실 모델 통합 후 별도 이슈.
|
||||
|
||||
## 9. 다음 단계 / 확장 포인트
|
||||
|
||||
- **#215 follow-up 4 이슈** (#218 다음): **#219** 60s idle auto-unload (F1), **#220** purge hardening (F2), **#221** AC10 한국어 corpus 품질 평가 (≥70%), **#222** production keystore / Play Store 준비.
|
||||
- 시나리오 #2~#6 (앵커 추출 / dose variants / if-then / lapse 구조화 / 주간 요약): 모두 `LlmService.generateStructured` 에 새 schema 추가하는 형태로 확장. 도메인 함수는 `lib/domain/ai/` 에 신규 파일.
|
||||
- 멀티 모델 슬롯 (E2B + E4B): ADR-0004 후보.
|
||||
191
docs/reference/260-in-app-tool-calling.md
Normal file
191
docs/reference/260-in-app-tool-calling.md
Normal file
@@ -0,0 +1,191 @@
|
||||
# Reference: In-app tool calling (#260, v0.4.0)
|
||||
|
||||
> **상태**: 구현 후 동기화 · **추적성** — Redmine #260 · 설계서: [docs/design/260-gemma-tool-calling/](../design/260-gemma-tool-calling/) · ADR-0005 · 태그 `v0.4.0` (commit `7037b9e`)
|
||||
>
|
||||
> 본 문서는 v0.4.0 의 **실제 코드 사양**이다. 설계 의도/대안은 설계서·ADR 을 참조. 본 기능은 v0.3.0 의 AI 프레임 제안 ([215-ai-frame-suggest.md](215-ai-frame-suggest.md)) 위에 multi-turn tool calling 을 얹은 것이다.
|
||||
|
||||
## 1. 모듈 지도
|
||||
|
||||
```
|
||||
lib/
|
||||
ai/tools/
|
||||
tool_definition.dart — ToolDefinition / ToolHandler / ToolDeps
|
||||
tool_envelope.dart — ToolResult sealed (Ok/Err/Cancelled) + encodeToolResult (2KB cap)
|
||||
tool_registry.dart — ToolRegistry.defaults() + 6 tool 모음
|
||||
tool_dispatcher.dart — 라우팅 + JSON-schema 검증 + ConfirmGate 통합
|
||||
confirm_gate.dart — destructive 호출 시 AlertDialog
|
||||
catalog_tools.dart — search_catalog, query_protocol (read-only)
|
||||
habit_tools.dart — add_habit (destructive), list_active_habits
|
||||
tracker_tools.dart — log_tracker_entry (destructive), get_streak
|
||||
data/ai/
|
||||
llm_service.dart — LlmChatSession 추상 + LlmChatEvent sealed + MockLlmChatSession
|
||||
gemma_llm_service.dart — _GemmaChatSession (flutter_gemma 0.16.5 + ToolChoice.auto)
|
||||
state/
|
||||
chat_providers.dart — ChatSessionController + kChatMaxTurns / kChatSoftHistoryLimit
|
||||
ui/screens/
|
||||
chat_screen.dart — AI 코치 화면 + _kToolKoreanLabels
|
||||
```
|
||||
|
||||
## 2. 도메인 모델
|
||||
|
||||
### `ToolDefinition` (`lib/ai/tools/tool_definition.dart`)
|
||||
```dart
|
||||
class ToolDefinition {
|
||||
final String name; // 'search_catalog'
|
||||
final String description; // 모델이 보는 한국어 설명
|
||||
final Map<String, dynamic> parametersSchema; // draft-07 JSON Schema Map 리터럴
|
||||
final bool isDestructive; // true → ConfirmGate 의무
|
||||
final ToolHandler handler; // Future<ToolResult> Function(args, deps)
|
||||
final String Function(Map<String, dynamic>)? summarize; // confirm 모달 본문
|
||||
}
|
||||
```
|
||||
|
||||
### `ToolResult` (sealed)
|
||||
```dart
|
||||
sealed class ToolResult { Map<String, dynamic> toJson(); }
|
||||
final class ToolOk extends ToolResult { final Map<String, dynamic> data; }
|
||||
final class ToolErr extends ToolResult { final String code; final String reason; }
|
||||
final class ToolCancelled extends ToolResult {}
|
||||
```
|
||||
|
||||
`toJson()` 디스크리미네이터:
|
||||
- ok → `{'status': 'ok', 'data': {...}}`
|
||||
- error → `{'status': 'error', 'code': '...', 'reason': '...'}`
|
||||
- cancel → `{'status': 'cancelled', 'reason': 'user did not confirm'}`
|
||||
|
||||
### `LlmChatEvent` (sealed)
|
||||
```dart
|
||||
sealed class LlmChatEvent {}
|
||||
final class LlmTextChunk extends LlmChatEvent { final String text; }
|
||||
final class LlmFunctionCall extends LlmChatEvent {
|
||||
final String name;
|
||||
final Map<String, dynamic> args;
|
||||
}
|
||||
```
|
||||
|
||||
## 3. 등록된 도구 6개
|
||||
|
||||
| 이름 | 종류 | parametersSchema (required) | 반환 (ok) |
|
||||
|---|---|---|---|
|
||||
| `search_catalog` | read-only | `category?`, `keyword?`, `limit?` (1~10) | `{count, items:[{id,title,category,summary}]}` |
|
||||
| `query_protocol` | read-only | `id` | kind 별 필드 (`protocol` / `break` / `diet`) |
|
||||
| `list_active_habits` | read-only | — | `{count, build_count, break_count, build_quota_remaining, break_quota_remaining, items[]}` |
|
||||
| `get_streak` | read-only | `habit_id` | `{current_streak, longest_streak, done_count_30d, done_count_phase42, tier, never_miss_twice_broken}` |
|
||||
| `add_habit` | **destructive** | `protocol_id`, `frame_level`, `framed_text`, `anchor_when?`, `anchor_after_what?`, `dose_text?` | `{habit_id, title, type, frame_level}` |
|
||||
| `log_tracker_entry` | **destructive** | `habit_id`, `value`, `date?`, `note?` | `{entry_id, habit_id, date, value}` |
|
||||
|
||||
### R 규칙 enforce (핸들러 책임, ADR-0005 §D-2)
|
||||
|
||||
| R 규칙 | 위치 | ToolErr code |
|
||||
|---|---|---|
|
||||
| R3 (build ≤3 / break ≤1) | `_addHabitHandler` → `judgeActiveHabitQuota` | `r3_quota` |
|
||||
| R5 (체크 (habit,date) 중복) | `_logTrackerEntryHandler` 핸들러 레벨 dedup | `duplicate` |
|
||||
| R7 (회피 키워드) | `_addHabitHandler` → `detectAvoidanceKeywords(framePatterns)` | `r7_avoidance` |
|
||||
| R8 (build XOR break protocol_id) | `HabitDao.insertWithVariants` assert → catch | `r8_xor` |
|
||||
|
||||
R1/R2/R4/R6/R9/R10 는 현재 본 surface 의 직접 enforce 범위 아님. R9/R10 (minimum variant) 는 UI 흐름에서 별도 관리 — 후속 이슈.
|
||||
|
||||
## 4. 멀티턴 루프
|
||||
|
||||
`ChatSessionController.userTurn(text, context)` — `lib/state/chat_providers.dart`.
|
||||
|
||||
```
|
||||
1. 사용자 메시지 append + isStreaming=true
|
||||
2. (lazy) llm.load() + _session = llm.startChat(tools)
|
||||
3. nextStream = sendUser(trimmed)
|
||||
4. for turn in 0..kChatMaxTurns:
|
||||
events = nextStream()
|
||||
if events.last is LlmTextChunk only:
|
||||
모델 자연어 응답 append → return
|
||||
if events has LlmFunctionCall(toolCall):
|
||||
result = dispatcher.dispatch(toolCall.name, toolCall.args, context, deps)
|
||||
messages append ToolCallChatMessage(name, args, result)
|
||||
capped = jsonDecode(encodeToolResult(result)) ← AC-9 2KB cap
|
||||
nextStream = sendToolResult(name, capped)
|
||||
5. else: error('도구 호출 루프가 너무 길어 중단했습니다.')
|
||||
```
|
||||
|
||||
- `kChatMaxTurns = 4` (안전 cap, ADR-0005 §C).
|
||||
- `kChatSoftHistoryLimit = 8` — 사용자 turn 누적 8 회 도달 시 SystemChatMessage 한 줄 추가 ("대화가 길어졌어요").
|
||||
- `LlmChatSession.sendToolResult` 의 `result` 는 항상 capped Map (`encodeToolResult` round-trip 결과).
|
||||
|
||||
## 5. ToolDispatcher 게이트
|
||||
|
||||
`lib/ai/tools/tool_dispatcher.dart` — `dispatch` 는 절대 throw 하지 않는다.
|
||||
|
||||
```
|
||||
1. registry.byName → null 이면 ToolErr('unknown_tool')
|
||||
2. _validateArgs(schema, rawArgs) → 실패 시 ToolErr('validation', ...)
|
||||
3. tool.isDestructive 라면:
|
||||
confirmContext==null → ToolCancelled
|
||||
ConfirmGate.show(ctx, tool, rawArgs) == false → ToolCancelled
|
||||
4. try handler(rawArgs, deps) — 예외 → ToolErr('handler_error', ...)
|
||||
```
|
||||
|
||||
`_validateArgs` 의 지원 타입: `string` / `integer` / `number` / `boolean` / `object` / `array`. 미선언 키는 허용 (모델 환각 허용 — 핸들러가 무시).
|
||||
|
||||
## 6. ConfirmGate UI
|
||||
|
||||
`lib/ai/tools/confirm_gate.dart` — `showDialog<bool>` 기반 `AlertDialog`.
|
||||
|
||||
- 제목: `이 작업을 수행할까요?`
|
||||
- 본문: `tool.description` + summary box (`tool.summarize?.call(args)` 또는 JSON fallback)
|
||||
- 액션: `취소` (TextButton, false) / `수행` (FilledButton autofocus, true)
|
||||
- `barrierDismissible: true` — 바깥 탭 = 취소
|
||||
- 좁은 화면 대응: content 를 `SingleChildScrollView` 로 감쌈, summary box `width: double.infinity`
|
||||
|
||||
## 7. 2KB result cap (ADR-0005 §OQ-2)
|
||||
|
||||
`encodeToolResult(ToolResult, {int maxBytes = 2048})` — `lib/ai/tools/tool_envelope.dart`.
|
||||
|
||||
- `jsonEncode(result.toJson())` 후 길이가 `maxBytes` 이하면 그대로 반환.
|
||||
- 초과 + `ToolOk` 이면 payload 를 `{'_truncated': true, '_hint': '... query_protocol 같은 단건 조회 도구를 사용하세요.'}` 로 대체.
|
||||
- 초과 + 그 외 (방어용) → `substring(0, maxBytes)` hard cut.
|
||||
- 호출 위치: `chat_providers.dart` `userTurn` 안에서 `jsonDecode(encodeToolResult(result))` 로 round-trip → Map 형태로 `sendToolResult` 전달.
|
||||
|
||||
> 주의: `.length` 는 UTF-16 code units. 한글 멀티바이트 utf-8 환산 시 더 작은 바이트 → 2048 cap 은 보수적 (안전 방향).
|
||||
|
||||
## 8. LlmChatSession 두 구현
|
||||
|
||||
| 구현 | 위치 | 비고 |
|
||||
|---|---|---|
|
||||
| `_GemmaChatSession` | `gemma_llm_service.dart` | flutter_gemma 0.16.5 `_chat.addQueryChunk` + `generateChatResponseAsync`. ParallelFunctionCallResponse 는 first 만 yield 후 return. ThinkingResponse skip. |
|
||||
| `MockLlmChatSession` | `llm_service.dart` | 테스트용. `chatScript` (FIFO `List<List<LlmChatEvent>>`) + `userInputs` + `toolResults` 기록. `lastChat` 으로 마지막 세션 접근. |
|
||||
|
||||
`Tool` (flutter_gemma) ↔ `ToolDefinition` 매핑은 `startChat` 안에서 수행 (`name`, `description`, `parameters`).
|
||||
|
||||
## 9. UI 진입점
|
||||
|
||||
- `HabitListScreen` AppBar 의 🤖 IconButton (Icons.smart_toy_outlined) — `aiSettingsProvider == true` 일 때만 노출.
|
||||
- 탭 → `ChatScreen` push.
|
||||
- 새 대화: AppBar refresh 아이콘 (tooltip: `새 대화 (이전 기록 비우기)`) → `controller.clear()`.
|
||||
- ToolCallChatMessage 라벨은 `_kToolKoreanLabels` 맵 기반 (`add_habit → 습관 추가` 등 6종, 미매핑은 raw name fallback).
|
||||
|
||||
## 10. 테스트
|
||||
|
||||
| 파일 | 케이스 수 |
|
||||
|---|---|
|
||||
| `test/ai/tools/tool_envelope_test.dart` | 6 |
|
||||
| `test/ai/tools/catalog_tools_test.dart` | 7 |
|
||||
| `test/ai/tools/habit_tools_test.dart` | 8 |
|
||||
| `test/ai/tools/tracker_tools_test.dart` | 7 |
|
||||
| `test/ai/tools/tool_dispatcher_test.dart` | 6 |
|
||||
| `test/state/chat_session_controller_test.dart` | 8 (huge_dump cap 회귀 포함) |
|
||||
| `test/ui/chat_screen_test.dart` | 2 (E2E 수행/취소) |
|
||||
|
||||
전체 v0.4.0 기준 **154 passed (1 skip)**.
|
||||
|
||||
## 11. Known limitations / 후속 이슈 권장
|
||||
|
||||
- **`ToolDefinition.koreanLabel` 필드 도입** — 현재 `chat_screen.dart` 의 `_kToolKoreanLabels` hardcoded const. 후속 tool 추가 시 두 위치 동기화 필요.
|
||||
- **`log_tracker_entry` blank 시 confirm skip** — 현재 `isDestructive: true` 가 done/blank 무차별. R5 의도 "blank = 의도적 공란" 과 차이.
|
||||
- **`search_catalog` category case-insensitive** — 현재 `DisplayCategory.name` 정확 일치. 모델 환각 대비.
|
||||
- **R9/R10 minimum variant chat 노출** — Phase 1 dose variants UI 가 chat 진입점에 노출 안 됨. 핸들러는 `isMinimum:false` 고정.
|
||||
- **KGP deprecation 경고** (빌드 시) — `device_info_plus`, `flutter_gemma`, `large_file_handler`. Future Flutter 빌드 실패 가능.
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- 설계서: [docs/design/260-gemma-tool-calling/](../design/260-gemma-tool-calling/)
|
||||
- 결정 기록: [docs/adr/0005-in-app-tool-calling-architecture.md](../adr/0005-in-app-tool-calling-architecture.md)
|
||||
- 선행 레퍼런스: [docs/reference/215-ai-frame-suggest.md](215-ai-frame-suggest.md)
|
||||
- 변경 이력: [CHANGELOG.md](../../CHANGELOG.md)
|
||||
103
docs/reference/311-llm-warmup.md
Normal file
103
docs/reference/311-llm-warmup.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# LLM warm-up (#311)
|
||||
|
||||
> 적용 버전: **v0.4.1 이상** · Redmine #311 · 설계서: [docs/design/311-llm-warmup/](../design/311-llm-warmup/)
|
||||
|
||||
`ChatScreen` 진입 시 LLM 모델을 백그라운드로 warm-up 하여 첫 send 의 cold native-init 비용 (수 초) 을 제거한다.
|
||||
|
||||
## 상태 머신
|
||||
|
||||
`app/lib/state/chat_warmup_provider.dart` — `sealed class ChatWarmupState`.
|
||||
|
||||
| 상태 | 진입 조건 | UI 동작 |
|
||||
|------|-----------|---------|
|
||||
| `Idle` | 초기값 | 평상 |
|
||||
| `Loading` | `quickCheck = ready` + `llm.isLoaded = false` → `llm.load()` in-flight | hintText 교체, send 자리 spinner |
|
||||
| `Ready` | `load()` 성공 또는 `llm.isLoaded = true` fast path | 평상, send 즉시 활성 |
|
||||
| `Failed(kind)` | `load()` throw | `_WarmupErrorBanner` 표시 |
|
||||
| `Unavailable` | `quickCheck != ready` (opt-out / downloading / missing) | 평상 (warmup 라벨 X, 기존 lazy 경로 fallback) |
|
||||
|
||||
`ChatWarmupFailureKind`:
|
||||
- `fileMissing` — `FileSystemException`. 회복 = `SettingsScreen` push (재다운로드).
|
||||
- `runtime` — 기타. 회복 = `retry()`.
|
||||
|
||||
## API
|
||||
|
||||
### `ChatWarmupController`
|
||||
|
||||
```dart
|
||||
final chatWarmupProvider =
|
||||
StateNotifierProvider.autoDispose<ChatWarmupController, ChatWarmupState>(...);
|
||||
```
|
||||
|
||||
- `Future<void> start()` — quickCheck → load → state 전이. 재진입 가드 (Loading 중 호출은 no-op). `ChatScreen.initState` 의 `postFrameCallback` 에서 호출.
|
||||
- `Future<void> retry()` — Idle reset 후 start() 재호출.
|
||||
- dispose 시 `_disposed = true` → `_safeSet` 가 후속 state 변경 무시.
|
||||
|
||||
### `ModelLifecycle.quickCheck()`
|
||||
|
||||
```dart
|
||||
Future<ModelAvailability> quickCheck();
|
||||
```
|
||||
|
||||
`checkAvailability()` 의 lightweight 버전. SHA-256 재해싱 (~2.4GB) 을 건너뛰고 meta_kv + 파일 존재만으로 ready 추정. tamper 감지는 `checkAvailability()` 의 cold path (SettingsScreen) 에 위임.
|
||||
|
||||
| meta_kv 상태 | quickCheck 반환 |
|
||||
|---|---|
|
||||
| `ai_opt_in != 'true'` | `missing` |
|
||||
| `ai_download_state in (downloading, paused)` | `downloading` |
|
||||
| `ai_model_path` null | `missing` |
|
||||
| `ai_model_sha256` null | `corrupt` |
|
||||
| 파일 부재 | `missing` |
|
||||
| 그 외 | `ready` |
|
||||
| 내부 throw | `corrupt` (보수적 fallback) |
|
||||
|
||||
### Concurrent load guard
|
||||
|
||||
`GemmaLlmService.load()` + `MockLlmService.load()` 가 `_loadingFuture` 가드 공유:
|
||||
|
||||
```dart
|
||||
Future<void> load() {
|
||||
if (_loaded) return Future.value();
|
||||
final existing = _loadingFuture;
|
||||
if (existing != null) return existing;
|
||||
final future = _doLoad();
|
||||
_loadingFuture = future;
|
||||
return future.whenComplete(() { _loadingFuture = null; });
|
||||
}
|
||||
```
|
||||
|
||||
ChatScreen warm-up + `userTurn` lazy load 가 race 해도 native init 1회만 실행. `_doLoad` throw 시 `whenComplete` 가 `_loadingFuture = null` 처리 → 다음 caller 가 새 시도.
|
||||
|
||||
## UI binding (`chat_screen.dart`)
|
||||
|
||||
- `TextField.enabled = !state.isStreaming` — warmup state 와 무관 (타이핑 미리 작성 가능, UX R1+R2).
|
||||
- `hintText` 분기 — warmup loading 시 `AI 준비 중… 첫 시작은 몇 초 걸려요`, 그 외 평상.
|
||||
- send 영역 — `state.isStreaming || isWarming` 이면 `CircularProgressIndicator(strokeWidth: 2)`, 그 외 `IconButton.filled`.
|
||||
- `_WarmupErrorBanner` (`Failed` 일 때만 표시):
|
||||
- 메시지 본문 = 상태 기술만 (명령형 X, AC12).
|
||||
- 우측 정렬 `OutlinedButton`:
|
||||
- `fileMissing` → `[설정으로 가기]` + `Navigator.push(SettingsScreen).then((_) => retry())` (pop 후 자동 retry).
|
||||
- `runtime` → `[다시 시도]` + `retry()` (즉시).
|
||||
|
||||
## 마이크로카피
|
||||
|
||||
| 상황 | 한국어 |
|
||||
|------|--------|
|
||||
| warmup 중 hintText | `AI 준비 중… 첫 시작은 몇 초 걸려요` |
|
||||
| 평상 hintText | `습관 추가, 기록, 카탈로그 질문…` |
|
||||
| Failed(fileMissing) | `AI 모델 파일을 찾을 수 없어요.` |
|
||||
| Failed(runtime) | `AI 를 시작하지 못했어요.` |
|
||||
|
||||
명령형 ("다시 시도해주세요" 등) 은 본문 금지 — 행동은 버튼이 담당.
|
||||
|
||||
## 테스트
|
||||
|
||||
- `app/test/state/chat_warmup_test.dart` — 8 unit (AC1-2, 5-7, 11-12 + retry + 재진입 가드).
|
||||
- `app/test/data/ai/model_lifecycle_test.dart` — quickCheck 4 신규.
|
||||
- `app/test/ui/chat_screen_test.dart` — widget E2E 보류 (NOTE comment 사유). `CircularProgressIndicator` 무한 ticker ↔ `pumpAndSettle` race 가 framework-level 한계.
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- 설계서: [docs/design/311-llm-warmup/](../design/311-llm-warmup/)
|
||||
- 사용자 가이드: [AI 코치와 대화하기](../guides/ai-chat-using.md)
|
||||
- 변경 이력: [CHANGELOG.md](../../CHANGELOG.md) [0.4.1]
|
||||
Reference in New Issue
Block a user