Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7037b9e245 | |||
| a8446d0c88 | |||
| b9f5674f51 | |||
| b1bed4d5ca | |||
| eca097aa2c | |||
| 321d3af53b | |||
| 4665f06a94 | |||
| 25be18063e |
36
CHANGELOG.md
36
CHANGELOG.md
@@ -3,6 +3,42 @@
|
|||||||
본 프로젝트의 모든 의미있는 변경은 본 파일에 기록한다.
|
본 프로젝트의 모든 의미있는 변경은 본 파일에 기록한다.
|
||||||
형식: [Keep a Changelog](https://keepachangelog.com/) · 버전: [SemVer](https://semver.org/).
|
형식: [Keep a Changelog](https://keepachangelog.com/) · 버전: [SemVer](https://semver.org/).
|
||||||
|
|
||||||
|
## [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
|
## [0.3.0] — 2026-06-12
|
||||||
|
|
||||||
### Added — Phase 2-A OQ-1 resolved: real Gemma 4 E2B inference (Redmine #218)
|
### Added — Phase 2-A OQ-1 resolved: real Gemma 4 E2B inference (Redmine #218)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"id": "morning_sunlight",
|
"id": "morning_sunlight",
|
||||||
"category": "health",
|
"category": "light_circadian",
|
||||||
"title": "아침 햇빛",
|
"title": "아침 햇빛",
|
||||||
"title_en": "Morning Sunlight",
|
"title_en": "Morning Sunlight",
|
||||||
"what": "기상 후 야외에서 햇빛을 직접 눈에 받기.",
|
"what": "기상 후 야외에서 햇빛을 직접 눈에 받기.",
|
||||||
@@ -21,13 +21,17 @@
|
|||||||
"after_what": "기상 후 양치"
|
"after_what": "기상 후 양치"
|
||||||
},
|
},
|
||||||
"min_dose_for_start": "햇빛 30초~2분 (Tiny Habits 시작 도즈)",
|
"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",
|
"evidence_strength": "strong_rct",
|
||||||
"source_doc": "huberman-protocols.md"
|
"source_doc": "huberman-protocols.md"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "evening_sunlight",
|
"id": "evening_sunlight",
|
||||||
"category": "health",
|
"category": "light_circadian",
|
||||||
"title": "저녁 햇빛",
|
"title": "저녁 햇빛",
|
||||||
"title_en": "Evening Sunlight",
|
"title_en": "Evening Sunlight",
|
||||||
"what": "일몰 즈음 햇빛 보기.",
|
"what": "일몰 즈음 햇빛 보기.",
|
||||||
@@ -40,13 +44,15 @@
|
|||||||
"야외 5~10분 (저녁 산책과 결합)."
|
"야외 5~10분 (저녁 산책과 결합)."
|
||||||
],
|
],
|
||||||
"check": "일몰 ±1시간 안에 야외 / 5분 이상",
|
"check": "일몰 ±1시간 안에 야외 / 5분 이상",
|
||||||
"reference_ids": ["ref_podcast_hl_68_light"],
|
"reference_ids": [
|
||||||
|
"ref_podcast_hl_68_light"
|
||||||
|
],
|
||||||
"evidence_strength": "mechanistic",
|
"evidence_strength": "mechanistic",
|
||||||
"source_doc": "huberman-protocols.md"
|
"source_doc": "huberman-protocols.md"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "night_light_avoidance",
|
"id": "night_light_avoidance",
|
||||||
"category": "health",
|
"category": "light_circadian",
|
||||||
"title": "야간 빛 차단",
|
"title": "야간 빛 차단",
|
||||||
"title_en": "Night Light Avoidance",
|
"title_en": "Night Light Avoidance",
|
||||||
"what": "밤 10시 ~ 새벽 4시 머리 위 강한 빛 차단.",
|
"what": "밤 10시 ~ 새벽 4시 머리 위 강한 빛 차단.",
|
||||||
@@ -63,13 +69,16 @@
|
|||||||
"default_anchor": {
|
"default_anchor": {
|
||||||
"when": "21:00"
|
"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",
|
"evidence_strength": "mechanistic",
|
||||||
"source_doc": "huberman-protocols.md"
|
"source_doc": "huberman-protocols.md"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "sleep_stack",
|
"id": "sleep_stack",
|
||||||
"category": "health",
|
"category": "sleep",
|
||||||
"title": "수면 스택",
|
"title": "수면 스택",
|
||||||
"title_en": "Sleep Stack",
|
"title_en": "Sleep Stack",
|
||||||
"what": "입면·심부 체온·자극 차단을 결합한 취침 전 90분 루틴.",
|
"what": "입면·심부 체온·자극 차단을 결합한 취침 전 90분 루틴.",
|
||||||
@@ -84,13 +93,16 @@
|
|||||||
"침실 18~19℃, 침대 진입 직전 화면 OFF."
|
"침실 18~19℃, 침대 진입 직전 화면 OFF."
|
||||||
],
|
],
|
||||||
"check": "기상 시각 ±1h / 카페인 컷오프 / 식사 2~3h 전 종료 / 침실 18~19℃",
|
"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",
|
"evidence_strength": "expert_opinion",
|
||||||
"source_doc": "huberman-protocols.md"
|
"source_doc": "huberman-protocols.md"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "caffeine_protocol",
|
"id": "caffeine_protocol",
|
||||||
"category": "health",
|
"category": "sleep",
|
||||||
"title": "카페인 타이밍",
|
"title": "카페인 타이밍",
|
||||||
"title_en": "Caffeine Protocol",
|
"title_en": "Caffeine Protocol",
|
||||||
"what": "기상 직후 카페인 회피 + 컷오프 시각 준수.",
|
"what": "기상 직후 카페인 회피 + 컷오프 시각 준수.",
|
||||||
@@ -105,13 +117,16 @@
|
|||||||
],
|
],
|
||||||
"check": "기상 후 90분 전 카페인 X / 컷오프 시각 이후 카페인 X",
|
"check": "기상 후 90분 전 카페인 X / 컷오프 시각 이후 카페인 X",
|
||||||
"caution": "90~120분 지연은 직접 RCT 부재. adenosine 약리학 기반 추론. 근거 ⚠️.",
|
"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",
|
"evidence_strength": "expert_opinion",
|
||||||
"source_doc": "huberman-protocols.md"
|
"source_doc": "huberman-protocols.md"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "weekly_movement_template",
|
"id": "weekly_movement_template",
|
||||||
"category": "health",
|
"category": "movement",
|
||||||
"title": "주간 운동 템플릿",
|
"title": "주간 운동 템플릿",
|
||||||
"title_en": "Weekly Movement Template",
|
"title_en": "Weekly Movement Template",
|
||||||
"what": "유산소(Zone 2 + VO2max) + 근력의 주간 분배.",
|
"what": "유산소(Zone 2 + VO2max) + 근력의 주간 분배.",
|
||||||
@@ -127,13 +142,15 @@
|
|||||||
],
|
],
|
||||||
"check": "Zone 2 ≥ 150분/주 / VO2max 1회 / 근력 2~4회",
|
"check": "Zone 2 ≥ 150분/주 / VO2max 1회 / 근력 2~4회",
|
||||||
"min_dose_for_start": "운동 1세트 또는 5분 산책",
|
"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",
|
"evidence_strength": "observational",
|
||||||
"source_doc": "huberman-protocols.md"
|
"source_doc": "huberman-protocols.md"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "deliberate_cold_exposure",
|
"id": "deliberate_cold_exposure",
|
||||||
"category": "health",
|
"category": "recovery_stress",
|
||||||
"title": "의도적 냉수 노출",
|
"title": "의도적 냉수 노출",
|
||||||
"title_en": "Deliberate Cold Exposure",
|
"title_en": "Deliberate Cold Exposure",
|
||||||
"what": "찬물 샤워 또는 ice bath.",
|
"what": "찬물 샤워 또는 ice bath.",
|
||||||
@@ -149,13 +166,16 @@
|
|||||||
],
|
],
|
||||||
"check": "1회 ≥ 1분 / 주 합산 ≥ 11분",
|
"check": "1회 ≥ 1분 / 주 합산 ≥ 11분",
|
||||||
"caution": "근비대 직후 4h 회피. 심혈관 질환자 의사 상담.",
|
"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",
|
"evidence_strength": "strong_rct",
|
||||||
"source_doc": "huberman-protocols.md"
|
"source_doc": "huberman-protocols.md"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "deliberate_heat_exposure",
|
"id": "deliberate_heat_exposure",
|
||||||
"category": "health",
|
"category": "recovery_stress",
|
||||||
"title": "사우나",
|
"title": "사우나",
|
||||||
"title_en": "Deliberate Heat Exposure",
|
"title_en": "Deliberate Heat Exposure",
|
||||||
"what": "80~100℃ 사우나.",
|
"what": "80~100℃ 사우나.",
|
||||||
@@ -169,13 +189,16 @@
|
|||||||
],
|
],
|
||||||
"check": "주 합산 ≥ 57분 (선택)",
|
"check": "주 합산 ≥ 57분 (선택)",
|
||||||
"caution": "임신/심혈관/저혈압 시 의사 상담. 알코올 결합 X.",
|
"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",
|
"evidence_strength": "observational",
|
||||||
"source_doc": "huberman-protocols.md"
|
"source_doc": "huberman-protocols.md"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "foundational_supplements",
|
"id": "foundational_supplements",
|
||||||
"category": "health",
|
"category": "nutrition",
|
||||||
"title": "핵심 보충제",
|
"title": "핵심 보충제",
|
||||||
"title_en": "Foundational Supplements",
|
"title_en": "Foundational Supplements",
|
||||||
"what": "Vitamin D3, Magnesium L-threonate, (옵션) Apigenin, Theanine.",
|
"what": "Vitamin D3, Magnesium L-threonate, (옵션) Apigenin, Theanine.",
|
||||||
@@ -189,13 +212,16 @@
|
|||||||
],
|
],
|
||||||
"check": "처방/권장량 준수 / 신규 도입 한 번에 1종",
|
"check": "처방/권장량 준수 / 신규 도입 한 번에 1종",
|
||||||
"caution": "의약품/임신/기저질환 시 의사 상담. Theanine은 혈압약 상호작용 가능.",
|
"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",
|
"evidence_strength": "strong_rct",
|
||||||
"source_doc": "huberman-protocols.md"
|
"source_doc": "huberman-protocols.md"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "focused_meditation",
|
"id": "focused_meditation",
|
||||||
"category": "meditation",
|
"category": "focus_cognition",
|
||||||
"title": "집중 명상",
|
"title": "집중 명상",
|
||||||
"title_en": "Focused Meditation",
|
"title_en": "Focused Meditation",
|
||||||
"what": "단일 대상(호흡/미간)에 주의 고정.",
|
"what": "단일 대상(호흡/미간)에 주의 고정.",
|
||||||
@@ -212,13 +238,16 @@
|
|||||||
"check": "13분 완료 / 알아챔→복귀 1회 이상 의식",
|
"check": "13분 완료 / 알아챔→복귀 1회 이상 의식",
|
||||||
"caution": "잠들기 직전 진행 시 각성 유발 가능.",
|
"caution": "잠들기 직전 진행 시 각성 유발 가능.",
|
||||||
"min_dose_for_start": "명상 1분",
|
"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",
|
"evidence_strength": "strong_rct",
|
||||||
"source_doc": "huberman-protocols.md"
|
"source_doc": "huberman-protocols.md"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "nsdr_yoga_nidra",
|
"id": "nsdr_yoga_nidra",
|
||||||
"category": "meditation",
|
"category": "recovery_stress",
|
||||||
"title": "NSDR / Yoga Nidra",
|
"title": "NSDR / Yoga Nidra",
|
||||||
"title_en": "Non-Sleep Deep Rest",
|
"title_en": "Non-Sleep Deep Rest",
|
||||||
"what": "누운 자세에서 가이드 보디스캔 + 긴 날숨.",
|
"what": "누운 자세에서 가이드 보디스캔 + 긴 날숨.",
|
||||||
@@ -233,13 +262,16 @@
|
|||||||
"종료 후 30초 잔여감."
|
"종료 후 30초 잔여감."
|
||||||
],
|
],
|
||||||
"check": "가이드 끝까지 / 종료 후 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",
|
"evidence_strength": "mechanistic",
|
||||||
"source_doc": "huberman-protocols.md"
|
"source_doc": "huberman-protocols.md"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "cyclic_sighing",
|
"id": "cyclic_sighing",
|
||||||
"category": "meditation",
|
"category": "recovery_stress",
|
||||||
"title": "생리적 한숨",
|
"title": "생리적 한숨",
|
||||||
"title_en": "Cyclic Sighing",
|
"title_en": "Cyclic Sighing",
|
||||||
"what": "들숨 2회 + 긴 날숨 1회.",
|
"what": "들숨 2회 + 긴 날숨 1회.",
|
||||||
@@ -255,13 +287,16 @@
|
|||||||
],
|
],
|
||||||
"check": "패턴 유지 / 1분 이상",
|
"check": "패턴 유지 / 1분 이상",
|
||||||
"min_dose_for_start": "cyclic sighing 30초~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",
|
"evidence_strength": "strong_rct",
|
||||||
"source_doc": "huberman-protocols.md"
|
"source_doc": "huberman-protocols.md"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "box_breathing",
|
"id": "box_breathing",
|
||||||
"category": "meditation",
|
"category": "recovery_stress",
|
||||||
"title": "Box Breathing",
|
"title": "Box Breathing",
|
||||||
"title_en": "Box Breathing",
|
"title_en": "Box Breathing",
|
||||||
"what": "4초 들숨–4초 멈춤–4초 날숨–4초 멈춤.",
|
"what": "4초 들숨–4초 멈춤–4초 날숨–4초 멈춤.",
|
||||||
@@ -277,13 +312,16 @@
|
|||||||
],
|
],
|
||||||
"check": "4-4-4-4 박자 / 2분 이상",
|
"check": "4-4-4-4 박자 / 2분 이상",
|
||||||
"caution": "특이성 RCT 빈약 — cyclic sighing(§2.3)보다 효과 작음.",
|
"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",
|
"evidence_strength": "mechanistic",
|
||||||
"source_doc": "huberman-protocols.md"
|
"source_doc": "huberman-protocols.md"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "cold_sigh_combo",
|
"id": "cold_sigh_combo",
|
||||||
"category": "meditation",
|
"category": "recovery_stress",
|
||||||
"title": "Cold + Sigh Combo",
|
"title": "Cold + Sigh Combo",
|
||||||
"title_en": "Cold + Sigh Combo",
|
"title_en": "Cold + Sigh Combo",
|
||||||
"what": "찬물 세면 + cyclic sighing.",
|
"what": "찬물 세면 + cyclic sighing.",
|
||||||
@@ -301,7 +339,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "protect_dopamine_baseline",
|
"id": "protect_dopamine_baseline",
|
||||||
"category": "motivation",
|
"category": "focus_cognition",
|
||||||
"title": "도파민 baseline 보호",
|
"title": "도파민 baseline 보호",
|
||||||
"title_en": "Protect Dopamine Baseline",
|
"title_en": "Protect Dopamine Baseline",
|
||||||
"what": "활동 자체에 외부 도파민 자극을 중첩(stacking)하지 않기.",
|
"what": "활동 자체에 외부 도파민 자극을 중첩(stacking)하지 않기.",
|
||||||
@@ -315,13 +353,16 @@
|
|||||||
"주 1~2회 '맨몸' 세션으로 baseline 회복."
|
"주 1~2회 '맨몸' 세션으로 baseline 회복."
|
||||||
],
|
],
|
||||||
"check": "stacking ≤ 1 / 직후 5분 차단",
|
"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",
|
"evidence_strength": "expert_opinion",
|
||||||
"source_doc": "huberman-protocols.md"
|
"source_doc": "huberman-protocols.md"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "reward_prediction_relabeling",
|
"id": "reward_prediction_relabeling",
|
||||||
"category": "motivation",
|
"category": "focus_cognition",
|
||||||
"title": "보상 예측 재배치",
|
"title": "보상 예측 재배치",
|
||||||
"title_en": "Reward Prediction Relabeling",
|
"title_en": "Reward Prediction Relabeling",
|
||||||
"what": "노력 자체에 보상을 결합하는 내적 라벨링.",
|
"what": "노력 자체에 보상을 결합하는 내적 라벨링.",
|
||||||
@@ -335,13 +376,16 @@
|
|||||||
"끝난 후 외적 보상 X."
|
"끝난 후 외적 보상 X."
|
||||||
],
|
],
|
||||||
"check": "라벨링 1회+ / 외적 보상 안 줌",
|
"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",
|
"evidence_strength": "mechanistic",
|
||||||
"source_doc": "huberman-protocols.md"
|
"source_doc": "huberman-protocols.md"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "dopamine_recovery_stack",
|
"id": "dopamine_recovery_stack",
|
||||||
"category": "motivation",
|
"category": "focus_cognition",
|
||||||
"title": "도파민 회복 스택",
|
"title": "도파민 회복 스택",
|
||||||
"title_en": "Dopamine Recovery Stack",
|
"title_en": "Dopamine Recovery Stack",
|
||||||
"what": "자연적 baseline 상승 도구 묶음.",
|
"what": "자연적 baseline 상승 도구 묶음.",
|
||||||
@@ -356,13 +400,15 @@
|
|||||||
"디지털 디톡스 주 1회 24h."
|
"디지털 디톡스 주 1회 24h."
|
||||||
],
|
],
|
||||||
"check": "각 구성요소 1회+",
|
"check": "각 구성요소 1회+",
|
||||||
"reference_ids": ["ref_podcast_hl_113_dopamine_procrastination"],
|
"reference_ids": [
|
||||||
|
"ref_podcast_hl_113_dopamine_procrastination"
|
||||||
|
],
|
||||||
"evidence_strength": "expert_opinion",
|
"evidence_strength": "expert_opinion",
|
||||||
"source_doc": "huberman-protocols.md"
|
"source_doc": "huberman-protocols.md"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "amcc_will_training",
|
"id": "amcc_will_training",
|
||||||
"category": "motivation",
|
"category": "focus_cognition",
|
||||||
"title": "aMCC 의지력 훈련",
|
"title": "aMCC 의지력 훈련",
|
||||||
"title_en": "aMCC Will-Training",
|
"title_en": "aMCC Will-Training",
|
||||||
"what": "'하기 싫지만 안전한' 일을 매일 1개 의도적 수행.",
|
"what": "'하기 싫지만 안전한' 일을 매일 1개 의도적 수행.",
|
||||||
@@ -375,13 +421,16 @@
|
|||||||
"완료 후 'aMCC 1 rep' 라벨링."
|
"완료 후 'aMCC 1 rep' 라벨링."
|
||||||
],
|
],
|
||||||
"check": "오늘의 싫은 일 정의 / 수행 완료",
|
"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",
|
"evidence_strength": "mechanistic",
|
||||||
"source_doc": "huberman-protocols.md"
|
"source_doc": "huberman-protocols.md"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "digital_dopamine_detox",
|
"id": "digital_dopamine_detox",
|
||||||
"category": "motivation",
|
"category": "focus_cognition",
|
||||||
"title": "디지털 디톡스",
|
"title": "디지털 디톡스",
|
||||||
"title_en": "Digital Dopamine Detox",
|
"title_en": "Digital Dopamine Detox",
|
||||||
"what": "고자극 컨텐츠 차단 (쇼츠/릴스/포르노/SNS 스크롤).",
|
"what": "고자극 컨텐츠 차단 (쇼츠/릴스/포르노/SNS 스크롤).",
|
||||||
@@ -395,13 +444,16 @@
|
|||||||
"종료 후 첫 사용 5분 제한."
|
"종료 후 첫 사용 5분 제한."
|
||||||
],
|
],
|
||||||
"check": "24h 차단 / 첫 사용 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",
|
"evidence_strength": "expert_opinion",
|
||||||
"source_doc": "huberman-protocols.md"
|
"source_doc": "huberman-protocols.md"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "three_phases_of_day",
|
"id": "three_phases_of_day",
|
||||||
"category": "habit",
|
"category": "focus_cognition",
|
||||||
"title": "하루 3 위상",
|
"title": "하루 3 위상",
|
||||||
"title_en": "Three Phases of the Day",
|
"title_en": "Three Phases of the Day",
|
||||||
"what": "신경전달물질 우세 시간대에 작업 배치.",
|
"what": "신경전달물질 우세 시간대에 작업 배치.",
|
||||||
@@ -415,13 +467,16 @@
|
|||||||
"Phase 3: 회상·정리·디지털 OFF."
|
"Phase 3: 회상·정리·디지털 OFF."
|
||||||
],
|
],
|
||||||
"check": "가장 어려운 일 Phase 1 배치 / Phase 3 자극적 디지털 X",
|
"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",
|
"evidence_strength": "expert_opinion",
|
||||||
"source_doc": "huberman-protocols.md"
|
"source_doc": "huberman-protocols.md"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "task_bracketing",
|
"id": "task_bracketing",
|
||||||
"category": "habit",
|
"category": "focus_cognition",
|
||||||
"title": "시간·맥락 브래킷",
|
"title": "시간·맥락 브래킷",
|
||||||
"title_en": "Task Bracketing",
|
"title_en": "Task Bracketing",
|
||||||
"what": "새 습관을 같은 시간 + 같은 직전/직후 행동으로 고정.",
|
"what": "새 습관을 같은 시간 + 같은 직전/직후 행동으로 고정.",
|
||||||
@@ -436,13 +491,16 @@
|
|||||||
"6주간 같은 위치 유지."
|
"6주간 같은 위치 유지."
|
||||||
],
|
],
|
||||||
"check": "직전 브래킷 정의 / 직후 브래킷 정의 / 오늘 같은 시각 실행",
|
"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",
|
"evidence_strength": "mechanistic",
|
||||||
"source_doc": "huberman-protocols.md"
|
"source_doc": "huberman-protocols.md"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "six_week_integration_rule",
|
"id": "six_week_integration_rule",
|
||||||
"category": "habit",
|
"category": "focus_cognition",
|
||||||
"title": "6주 자동화 규칙",
|
"title": "6주 자동화 규칙",
|
||||||
"title_en": "6-Week Integration Rule",
|
"title_en": "6-Week Integration Rule",
|
||||||
"what": "'6주 동안 주 6/7'을 자동화 기준으로.",
|
"what": "'6주 동안 주 6/7'을 자동화 기준으로.",
|
||||||
@@ -457,13 +515,16 @@
|
|||||||
"6주 후 자동화 자가 평가."
|
"6주 후 자동화 자가 평가."
|
||||||
],
|
],
|
||||||
"check": "트래커 존재 / 이번 주 6/7 / 결석 후 다음 날 복귀",
|
"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",
|
"evidence_strength": "observational",
|
||||||
"source_doc": "huberman-protocols.md"
|
"source_doc": "huberman-protocols.md"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "limbic_friction_scoring",
|
"id": "limbic_friction_scoring",
|
||||||
"category": "habit",
|
"category": "focus_cognition",
|
||||||
"title": "마찰 점수화",
|
"title": "마찰 점수화",
|
||||||
"title_en": "Limbic Friction Scoring",
|
"title_en": "Limbic Friction Scoring",
|
||||||
"what": "각 습관에 0~10 마찰 점수.",
|
"what": "각 습관에 0~10 마찰 점수.",
|
||||||
@@ -477,13 +538,15 @@
|
|||||||
"평균 3↓ 2주 유지 → 자동화 진입."
|
"평균 3↓ 2주 유지 → 자동화 진입."
|
||||||
],
|
],
|
||||||
"check": "friction 기록 / 주간 평균 확인",
|
"check": "friction 기록 / 주간 평균 확인",
|
||||||
"reference_ids": ["ref_podcast_hl_53_habits"],
|
"reference_ids": [
|
||||||
|
"ref_podcast_hl_53_habits"
|
||||||
|
],
|
||||||
"evidence_strength": "expert_opinion",
|
"evidence_strength": "expert_opinion",
|
||||||
"source_doc": "huberman-protocols.md"
|
"source_doc": "huberman-protocols.md"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "new_habit_onboarding",
|
"id": "new_habit_onboarding",
|
||||||
"category": "habit",
|
"category": "focus_cognition",
|
||||||
"title": "신규 습관 도입 규칙",
|
"title": "신규 습관 도입 규칙",
|
||||||
"title_en": "New Habit Onboarding",
|
"title_en": "New Habit Onboarding",
|
||||||
"what": "동시 1~3개, 최소 단위로 시작.",
|
"what": "동시 1~3개, 최소 단위로 시작.",
|
||||||
@@ -498,13 +561,16 @@
|
|||||||
"6주 후 평가 → 다음 1~3개."
|
"6주 후 평가 → 다음 1~3개."
|
||||||
],
|
],
|
||||||
"check": "현재 신규 ≤ 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",
|
"evidence_strength": "expert_opinion",
|
||||||
"source_doc": "huberman-protocols.md"
|
"source_doc": "huberman-protocols.md"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "habit_breaking_via_replacement",
|
"id": "habit_breaking_via_replacement",
|
||||||
"category": "habit",
|
"category": "focus_cognition",
|
||||||
"title": "대체 행동으로 끊기",
|
"title": "대체 행동으로 끊기",
|
||||||
"title_en": "Habit Breaking via Replacement",
|
"title_en": "Habit Breaking via Replacement",
|
||||||
"what": "트리거 직후 호환 불가능한 대체 행동 삽입.",
|
"what": "트리거 직후 호환 불가능한 대체 행동 삽입.",
|
||||||
@@ -518,13 +584,16 @@
|
|||||||
"6주 평가."
|
"6주 평가."
|
||||||
],
|
],
|
||||||
"check": "트리거 식별 / 대체 행동 정의 / 오늘 1회+ 성공",
|
"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",
|
"evidence_strength": "mechanistic",
|
||||||
"source_doc": "huberman-protocols.md"
|
"source_doc": "huberman-protocols.md"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "ultradian_focus_block",
|
"id": "ultradian_focus_block",
|
||||||
"category": "learning",
|
"category": "focus_cognition",
|
||||||
"title": "90분 Ultradian 집중 블록",
|
"title": "90분 Ultradian 집중 블록",
|
||||||
"title_en": "90-min Ultradian Focus Block",
|
"title_en": "90-min Ultradian Focus Block",
|
||||||
"what": "90분 deep work + 10~20분 휴식.",
|
"what": "90분 deep work + 10~20분 휴식.",
|
||||||
@@ -539,13 +608,17 @@
|
|||||||
"종료 후 10~20분 NSDR 또는 산책. SNS X."
|
"종료 후 10~20분 NSDR 또는 산책. SNS X."
|
||||||
],
|
],
|
||||||
"check": "진입 의식 / 단일 과제 / 휴식이 도파민 자극 아님",
|
"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",
|
"evidence_strength": "mechanistic",
|
||||||
"source_doc": "huberman-protocols.md"
|
"source_doc": "huberman-protocols.md"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "visual_focus_priming",
|
"id": "visual_focus_priming",
|
||||||
"category": "learning",
|
"category": "focus_cognition",
|
||||||
"title": "시각 집중 점화",
|
"title": "시각 집중 점화",
|
||||||
"title_en": "Visual Focus Priming",
|
"title_en": "Visual Focus Priming",
|
||||||
"what": "한 지점 응시로 전두엽 집중 회로 활성.",
|
"what": "한 지점 응시로 전두엽 집중 회로 활성.",
|
||||||
@@ -560,13 +633,17 @@
|
|||||||
],
|
],
|
||||||
"check": "30초 이상 응시 후 진입",
|
"check": "30초 이상 응시 후 진입",
|
||||||
"caution": "narrow-aperture LC 활성은 Huberman 통합 모델 — 근거 ⚠️.",
|
"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",
|
"evidence_strength": "mechanistic",
|
||||||
"source_doc": "huberman-protocols.md"
|
"source_doc": "huberman-protocols.md"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "post_learning_nsdr",
|
"id": "post_learning_nsdr",
|
||||||
"category": "learning",
|
"category": "recovery_stress",
|
||||||
"title": "학습 직후 NSDR",
|
"title": "학습 직후 NSDR",
|
||||||
"title_en": "Post-Learning NSDR",
|
"title_en": "Post-Learning NSDR",
|
||||||
"what": "학습 직후 10분 NSDR.",
|
"what": "학습 직후 10분 NSDR.",
|
||||||
@@ -579,13 +656,16 @@
|
|||||||
"종료 후 5분 메모로 재진술."
|
"종료 후 5분 메모로 재진술."
|
||||||
],
|
],
|
||||||
"check": "학습 직후 SNS 안 봄 / NSDR 10분 / 메모 재진술",
|
"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",
|
"evidence_strength": "strong_rct",
|
||||||
"source_doc": "huberman-protocols.md"
|
"source_doc": "huberman-protocols.md"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "protein_first",
|
"id": "protein_first",
|
||||||
"category": "diet",
|
"category": "nutrition",
|
||||||
"title": "단백질 우선",
|
"title": "단백질 우선",
|
||||||
"title_en": "Protein-First",
|
"title_en": "Protein-First",
|
||||||
"what": "첫 식사 + 일일 총량을 단백질 기준으로 잡는다.",
|
"what": "첫 식사 + 일일 총량을 단백질 기준으로 잡는다.",
|
||||||
@@ -600,13 +680,17 @@
|
|||||||
],
|
],
|
||||||
"check": "첫 식사 단백질 ≥ 30g",
|
"check": "첫 식사 단백질 ≥ 30g",
|
||||||
"min_dose_for_start": "첫 끼 단백질 +10g",
|
"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",
|
"evidence_strength": "meta_analysis",
|
||||||
"source_doc": "diet-protocols.md"
|
"source_doc": "diet-protocols.md"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "refined_sugar_minimize",
|
"id": "refined_sugar_minimize",
|
||||||
"category": "diet",
|
"category": "nutrition",
|
||||||
"title": "정제당·액상 과당 최소화",
|
"title": "정제당·액상 과당 최소화",
|
||||||
"title_en": "Minimize Refined / Liquid Sugar",
|
"title_en": "Minimize Refined / Liquid Sugar",
|
||||||
"what": "첨가당과 액상 과당(과일 주스 포함) 우선 줄임. 통과일·통곡물은 별개.",
|
"what": "첨가당과 액상 과당(과일 주스 포함) 우선 줄임. 통과일·통곡물은 별개.",
|
||||||
@@ -620,13 +704,17 @@
|
|||||||
"라벨 'added sugar' 확인 (가공식품 1주일 1회 인벤토리)."
|
"라벨 'added sugar' 확인 (가공식품 1주일 1회 인벤토리)."
|
||||||
],
|
],
|
||||||
"check": "오늘 액상 과당 0",
|
"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",
|
"evidence_strength": "meta_analysis",
|
||||||
"source_doc": "diet-protocols.md"
|
"source_doc": "diet-protocols.md"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "fiber_intake",
|
"id": "fiber_intake",
|
||||||
"category": "diet",
|
"category": "nutrition",
|
||||||
"title": "식이섬유",
|
"title": "식이섬유",
|
||||||
"title_en": "Fiber Intake",
|
"title_en": "Fiber Intake",
|
||||||
"what": "통곡물·콩류·채소·통과일에서 일일 25~38g.",
|
"what": "통곡물·콩류·채소·통과일에서 일일 25~38g.",
|
||||||
@@ -639,13 +727,17 @@
|
|||||||
"갑자기 늘리면 가스/팽만 → 2~3주 점진 증가."
|
"갑자기 늘리면 가스/팽만 → 2~3주 점진 증가."
|
||||||
],
|
],
|
||||||
"check": "오늘 채소 ≥ 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",
|
"evidence_strength": "meta_analysis",
|
||||||
"source_doc": "diet-protocols.md"
|
"source_doc": "diet-protocols.md"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "water_electrolytes",
|
"id": "water_electrolytes",
|
||||||
"category": "diet",
|
"category": "nutrition",
|
||||||
"title": "수분·전해질",
|
"title": "수분·전해질",
|
||||||
"title_en": "Water & Electrolytes",
|
"title_en": "Water & Electrolytes",
|
||||||
"what": "기상 후 물 500ml + 소금 한 꼬집. 일일 총 수분 체중 × 30~35 ml.",
|
"what": "기상 후 물 500ml + 소금 한 꼬집. 일일 총 수분 체중 × 30~35 ml.",
|
||||||
@@ -667,7 +759,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "meal_timing_tre",
|
"id": "meal_timing_tre",
|
||||||
"category": "diet",
|
"category": "nutrition",
|
||||||
"title": "식사 시점 / 시간 제한",
|
"title": "식사 시점 / 시간 제한",
|
||||||
"title_en": "Meal Timing / TRE",
|
"title_en": "Meal Timing / TRE",
|
||||||
"what": "마지막 식사를 취침 2~3시간 전 종료. (선택) 16:8 등 TRE.",
|
"what": "마지막 식사를 취침 2~3시간 전 종료. (선택) 16:8 등 TRE.",
|
||||||
@@ -680,13 +772,16 @@
|
|||||||
"TRE 시작 시 14:10 → 16:8 점진."
|
"TRE 시작 시 14:10 → 16:8 점진."
|
||||||
],
|
],
|
||||||
"check": "마지막 식사 취침 2~3h 전 종료",
|
"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",
|
"evidence_strength": "strong_rct",
|
||||||
"source_doc": "diet-protocols.md"
|
"source_doc": "diet-protocols.md"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "omega3",
|
"id": "omega3",
|
||||||
"category": "diet",
|
"category": "nutrition",
|
||||||
"title": "Omega-3 (EPA/DHA)",
|
"title": "Omega-3 (EPA/DHA)",
|
||||||
"title_en": "Omega-3",
|
"title_en": "Omega-3",
|
||||||
"what": "지방 생선 주 2~3회 또는 EPA 보충 1~2g/일.",
|
"what": "지방 생선 주 2~3회 또는 EPA 보충 1~2g/일.",
|
||||||
@@ -699,7 +794,9 @@
|
|||||||
"항응고제 복용 시 의사 상담."
|
"항응고제 복용 시 의사 상담."
|
||||||
],
|
],
|
||||||
"check": "주 단위 weekly reflection",
|
"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",
|
"evidence_strength": "meta_analysis",
|
||||||
"source_doc": "diet-protocols.md"
|
"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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import 'dart:io';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_gemma/flutter_gemma.dart';
|
import 'package:flutter_gemma/flutter_gemma.dart';
|
||||||
|
|
||||||
|
import '../../ai/tools/tool_definition.dart' as tools;
|
||||||
import 'llm_service.dart';
|
import 'llm_service.dart';
|
||||||
|
|
||||||
/// HuggingFace access token injected at build time via
|
/// HuggingFace access token injected at build time via
|
||||||
@@ -114,6 +115,93 @@ class GemmaLlmService implements LlmService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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
|
/// Extracts the first `FunctionCallResponse(name == expectedName)` from
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import '../../ai/tools/tool_definition.dart';
|
||||||
|
|
||||||
/// Abstract LLM backend.
|
/// Abstract LLM backend.
|
||||||
///
|
///
|
||||||
/// Concrete impls: `GemmaLlmService` (flutter_gemma) and `MockLlmService` (tests).
|
/// Concrete impls: `GemmaLlmService` (flutter_gemma) and `MockLlmService` (tests).
|
||||||
@@ -7,6 +9,7 @@
|
|||||||
/// - [generateStructured] returns a parsed JSON map matching the schema.
|
/// - [generateStructured] returns a parsed JSON map matching the schema.
|
||||||
/// On schema/parse failure throws [FormatException].
|
/// On schema/parse failure throws [FormatException].
|
||||||
/// - [unload] is idempotent.
|
/// - [unload] is idempotent.
|
||||||
|
/// - [startChat] opens a multi-turn chat session for tool calling (#260).
|
||||||
abstract class LlmService {
|
abstract class LlmService {
|
||||||
bool get isLoaded;
|
bool get isLoaded;
|
||||||
|
|
||||||
@@ -20,6 +23,45 @@ abstract class LlmService {
|
|||||||
String prompt,
|
String prompt,
|
||||||
Map<String, dynamic> schema,
|
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].
|
/// Programmable stub for tests. Use [enqueueResponse] / [enqueueError].
|
||||||
@@ -31,6 +73,12 @@ class MockLlmService implements LlmService {
|
|||||||
Map<String, dynamic>? lastSchema;
|
Map<String, dynamic>? lastSchema;
|
||||||
Duration responseDelay = Duration.zero;
|
Duration responseDelay = Duration.zero;
|
||||||
|
|
||||||
|
/// 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
|
@override
|
||||||
bool get isLoaded => _loaded;
|
bool get isLoaded => _loaded;
|
||||||
|
|
||||||
@@ -52,6 +100,12 @@ class MockLlmService implements LlmService {
|
|||||||
_queue.add(_Response.error(error));
|
_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
|
@override
|
||||||
Future<Map<String, dynamic>> generateStructured(
|
Future<Map<String, dynamic>> generateStructured(
|
||||||
String prompt,
|
String prompt,
|
||||||
@@ -73,6 +127,61 @@ class MockLlmService implements LlmService {
|
|||||||
if (r.error != null) throw r.error!;
|
if (r.error != null) throw r.error!;
|
||||||
return r.value!;
|
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 {
|
class _Response {
|
||||||
|
|||||||
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/path.dart' as p;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
import '../../core/constants.dart';
|
||||||
import 'tables/catalog_tables.dart';
|
import 'tables/catalog_tables.dart';
|
||||||
import 'tables/user_tables.dart';
|
import 'tables/user_tables.dart';
|
||||||
|
|
||||||
@@ -42,7 +43,7 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
AppDatabase.memory() : super(NativeDatabase.memory());
|
AppDatabase.memory() : super(NativeDatabase.memory());
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 1;
|
int get schemaVersion => 2;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MigrationStrategy get migration => MigrationStrategy(
|
MigrationStrategy get migration => MigrationStrategy(
|
||||||
@@ -51,8 +52,16 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
await _createIndexes(m);
|
await _createIndexes(m);
|
||||||
},
|
},
|
||||||
onUpgrade: (m, from, to) async {
|
onUpgrade: (m, from, to) async {
|
||||||
// Phase 1 only has v1. Reaching here is a bug.
|
// v1 → v2 (#226): Protocols.category CHECK 6 → 7 신 카테고리.
|
||||||
assert(false, 'Phase 1 has no upgrade path. from=$from to=$to');
|
// 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();
|
final dir = await getApplicationDocumentsDirectory();
|
||||||
return File(p.join(dir.path, 'life_helper.sqlite'));
|
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,
|
aliasedName,
|
||||||
false,
|
false,
|
||||||
check: () => const CustomExpression<bool>(
|
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,
|
type: DriftSqlType.string,
|
||||||
requiredDuringInsert: true,
|
requiredDuringInsert: true,
|
||||||
@@ -15402,7 +15403,7 @@ final class $$UsersTableReferences
|
|||||||
_$AppDatabase db,
|
_$AppDatabase db,
|
||||||
) => MultiTypedResultKey.fromTable(
|
) => MultiTypedResultKey.fromTable(
|
||||||
db.phases,
|
db.phases,
|
||||||
aliasName: $_aliasNameGenerator(db.users.id, db.phases.userId),
|
aliasName: 'users__id__phases__user_id',
|
||||||
);
|
);
|
||||||
|
|
||||||
$$PhasesTableProcessedTableManager get phasesRefs {
|
$$PhasesTableProcessedTableManager get phasesRefs {
|
||||||
@@ -15421,7 +15422,7 @@ final class $$UsersTableReferences
|
|||||||
_$AppDatabase db,
|
_$AppDatabase db,
|
||||||
) => MultiTypedResultKey.fromTable(
|
) => MultiTypedResultKey.fromTable(
|
||||||
db.habits,
|
db.habits,
|
||||||
aliasName: $_aliasNameGenerator(db.users.id, db.habits.userId),
|
aliasName: 'users__id__habits__user_id',
|
||||||
);
|
);
|
||||||
|
|
||||||
$$HabitsTableProcessedTableManager get habitsRefs {
|
$$HabitsTableProcessedTableManager get habitsRefs {
|
||||||
@@ -15439,7 +15440,7 @@ final class $$UsersTableReferences
|
|||||||
static MultiTypedResultKey<$ReflectionsTable, List<Reflection>>
|
static MultiTypedResultKey<$ReflectionsTable, List<Reflection>>
|
||||||
_reflectionsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable(
|
_reflectionsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable(
|
||||||
db.reflections,
|
db.reflections,
|
||||||
aliasName: $_aliasNameGenerator(db.users.id, db.reflections.userId),
|
aliasName: 'users__id__reflections__user_id',
|
||||||
);
|
);
|
||||||
|
|
||||||
$$ReflectionsTableProcessedTableManager get reflectionsRefs {
|
$$ReflectionsTableProcessedTableManager get reflectionsRefs {
|
||||||
@@ -15909,7 +15910,7 @@ final class $$PhasesTableReferences
|
|||||||
$$PhasesTableReferences(super.$_db, super.$_table, super.$_typedResult);
|
$$PhasesTableReferences(super.$_db, super.$_table, super.$_typedResult);
|
||||||
|
|
||||||
static $UsersTable _userIdTable(_$AppDatabase db) =>
|
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 {
|
$$UsersTableProcessedTableManager get userId {
|
||||||
final $_column = $_itemColumn<String>('user_id')!;
|
final $_column = $_itemColumn<String>('user_id')!;
|
||||||
@@ -15929,7 +15930,7 @@ final class $$PhasesTableReferences
|
|||||||
_$AppDatabase db,
|
_$AppDatabase db,
|
||||||
) => MultiTypedResultKey.fromTable(
|
) => MultiTypedResultKey.fromTable(
|
||||||
db.habits,
|
db.habits,
|
||||||
aliasName: $_aliasNameGenerator(db.phases.id, db.habits.phaseId),
|
aliasName: 'phases__id__habits__phase_id',
|
||||||
);
|
);
|
||||||
|
|
||||||
$$HabitsTableProcessedTableManager get habitsRefs {
|
$$HabitsTableProcessedTableManager get habitsRefs {
|
||||||
@@ -15948,10 +15949,7 @@ final class $$PhasesTableReferences
|
|||||||
_rewardDeclarationsRefsTable(_$AppDatabase db) =>
|
_rewardDeclarationsRefsTable(_$AppDatabase db) =>
|
||||||
MultiTypedResultKey.fromTable(
|
MultiTypedResultKey.fromTable(
|
||||||
db.rewardDeclarations,
|
db.rewardDeclarations,
|
||||||
aliasName: $_aliasNameGenerator(
|
aliasName: 'phases__id__reward_declarations__phase_id',
|
||||||
db.phases.id,
|
|
||||||
db.rewardDeclarations.phaseId,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
$$RewardDeclarationsTableProcessedTableManager get rewardDeclarationsRefs {
|
$$RewardDeclarationsTableProcessedTableManager get rewardDeclarationsRefs {
|
||||||
@@ -15971,7 +15969,7 @@ final class $$PhasesTableReferences
|
|||||||
static MultiTypedResultKey<$ReflectionsTable, List<Reflection>>
|
static MultiTypedResultKey<$ReflectionsTable, List<Reflection>>
|
||||||
_reflectionsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable(
|
_reflectionsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable(
|
||||||
db.reflections,
|
db.reflections,
|
||||||
aliasName: $_aliasNameGenerator(db.phases.id, db.reflections.phaseId),
|
aliasName: 'phases__id__reflections__phase_id',
|
||||||
);
|
);
|
||||||
|
|
||||||
$$ReflectionsTableProcessedTableManager get reflectionsRefs {
|
$$ReflectionsTableProcessedTableManager get reflectionsRefs {
|
||||||
@@ -16618,7 +16616,7 @@ final class $$HabitsTableReferences
|
|||||||
$$HabitsTableReferences(super.$_db, super.$_table, super.$_typedResult);
|
$$HabitsTableReferences(super.$_db, super.$_table, super.$_typedResult);
|
||||||
|
|
||||||
static $UsersTable _userIdTable(_$AppDatabase db) =>
|
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 {
|
$$UsersTableProcessedTableManager get userId {
|
||||||
final $_column = $_itemColumn<String>('user_id')!;
|
final $_column = $_itemColumn<String>('user_id')!;
|
||||||
@@ -16634,9 +16632,8 @@ final class $$HabitsTableReferences
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static $PhasesTable _phaseIdTable(_$AppDatabase db) => db.phases.createAlias(
|
static $PhasesTable _phaseIdTable(_$AppDatabase db) =>
|
||||||
$_aliasNameGenerator(db.habits.phaseId, db.phases.id),
|
db.phases.createAlias('habits__phase_id__phases__id');
|
||||||
);
|
|
||||||
|
|
||||||
$$PhasesTableProcessedTableManager? get phaseId {
|
$$PhasesTableProcessedTableManager? get phaseId {
|
||||||
final $_column = $_itemColumn<String>('phase_id');
|
final $_column = $_itemColumn<String>('phase_id');
|
||||||
@@ -16656,10 +16653,7 @@ final class $$HabitsTableReferences
|
|||||||
_habitDoseVariantsRefsTable(_$AppDatabase db) =>
|
_habitDoseVariantsRefsTable(_$AppDatabase db) =>
|
||||||
MultiTypedResultKey.fromTable(
|
MultiTypedResultKey.fromTable(
|
||||||
db.habitDoseVariants,
|
db.habitDoseVariants,
|
||||||
aliasName: $_aliasNameGenerator(
|
aliasName: 'habits__id__habit_dose_variants__habit_id',
|
||||||
db.habits.id,
|
|
||||||
db.habitDoseVariants.habitId,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
$$HabitDoseVariantsTableProcessedTableManager get habitDoseVariantsRefs {
|
$$HabitDoseVariantsTableProcessedTableManager get habitDoseVariantsRefs {
|
||||||
@@ -16679,7 +16673,7 @@ final class $$HabitsTableReferences
|
|||||||
static MultiTypedResultKey<$IfThenRulesTable, List<IfThenRule>>
|
static MultiTypedResultKey<$IfThenRulesTable, List<IfThenRule>>
|
||||||
_ifThenRulesRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable(
|
_ifThenRulesRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable(
|
||||||
db.ifThenRules,
|
db.ifThenRules,
|
||||||
aliasName: $_aliasNameGenerator(db.habits.id, db.ifThenRules.habitId),
|
aliasName: 'habits__id__if_then_rules__habit_id',
|
||||||
);
|
);
|
||||||
|
|
||||||
$$IfThenRulesTableProcessedTableManager get ifThenRulesRefs {
|
$$IfThenRulesTableProcessedTableManager get ifThenRulesRefs {
|
||||||
@@ -16697,7 +16691,7 @@ final class $$HabitsTableReferences
|
|||||||
static MultiTypedResultKey<$TrackerEntriesTable, List<TrackerEntry>>
|
static MultiTypedResultKey<$TrackerEntriesTable, List<TrackerEntry>>
|
||||||
_trackerEntriesRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable(
|
_trackerEntriesRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable(
|
||||||
db.trackerEntries,
|
db.trackerEntries,
|
||||||
aliasName: $_aliasNameGenerator(db.habits.id, db.trackerEntries.habitId),
|
aliasName: 'habits__id__tracker_entries__habit_id',
|
||||||
);
|
);
|
||||||
|
|
||||||
$$TrackerEntriesTableProcessedTableManager get trackerEntriesRefs {
|
$$TrackerEntriesTableProcessedTableManager get trackerEntriesRefs {
|
||||||
@@ -16715,7 +16709,7 @@ final class $$HabitsTableReferences
|
|||||||
static MultiTypedResultKey<$LapseLogsTable, List<LapseLog>>
|
static MultiTypedResultKey<$LapseLogsTable, List<LapseLog>>
|
||||||
_lapseLogsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable(
|
_lapseLogsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable(
|
||||||
db.lapseLogs,
|
db.lapseLogs,
|
||||||
aliasName: $_aliasNameGenerator(db.habits.id, db.lapseLogs.habitId),
|
aliasName: 'habits__id__lapse_logs__habit_id',
|
||||||
);
|
);
|
||||||
|
|
||||||
$$LapseLogsTableProcessedTableManager get lapseLogsRefs {
|
$$LapseLogsTableProcessedTableManager get lapseLogsRefs {
|
||||||
@@ -16734,7 +16728,7 @@ final class $$HabitsTableReferences
|
|||||||
_$AppDatabase db,
|
_$AppDatabase db,
|
||||||
) => MultiTypedResultKey.fromTable(
|
) => MultiTypedResultKey.fromTable(
|
||||||
db.urgeLogs,
|
db.urgeLogs,
|
||||||
aliasName: $_aliasNameGenerator(db.habits.id, db.urgeLogs.habitId),
|
aliasName: 'habits__id__urge_logs__habit_id',
|
||||||
);
|
);
|
||||||
|
|
||||||
$$UrgeLogsTableProcessedTableManager get urgeLogsRefs {
|
$$UrgeLogsTableProcessedTableManager get urgeLogsRefs {
|
||||||
@@ -16753,10 +16747,7 @@ final class $$HabitsTableReferences
|
|||||||
_rewardDeclarationsRefsTable(_$AppDatabase db) =>
|
_rewardDeclarationsRefsTable(_$AppDatabase db) =>
|
||||||
MultiTypedResultKey.fromTable(
|
MultiTypedResultKey.fromTable(
|
||||||
db.rewardDeclarations,
|
db.rewardDeclarations,
|
||||||
aliasName: $_aliasNameGenerator(
|
aliasName: 'habits__id__reward_declarations__habit_id',
|
||||||
db.habits.id,
|
|
||||||
db.rewardDeclarations.habitId,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
$$RewardDeclarationsTableProcessedTableManager get rewardDeclarationsRefs {
|
$$RewardDeclarationsTableProcessedTableManager get rewardDeclarationsRefs {
|
||||||
@@ -17909,9 +17900,8 @@ final class $$HabitDoseVariantsTableReferences
|
|||||||
super.$_typedResult,
|
super.$_typedResult,
|
||||||
);
|
);
|
||||||
|
|
||||||
static $HabitsTable _habitIdTable(_$AppDatabase db) => db.habits.createAlias(
|
static $HabitsTable _habitIdTable(_$AppDatabase db) =>
|
||||||
$_aliasNameGenerator(db.habitDoseVariants.habitId, db.habits.id),
|
db.habits.createAlias('habit_dose_variants__habit_id__habits__id');
|
||||||
);
|
|
||||||
|
|
||||||
$$HabitsTableProcessedTableManager get habitId {
|
$$HabitsTableProcessedTableManager get habitId {
|
||||||
final $_column = $_itemColumn<String>('habit_id')!;
|
final $_column = $_itemColumn<String>('habit_id')!;
|
||||||
@@ -17930,10 +17920,7 @@ final class $$HabitDoseVariantsTableReferences
|
|||||||
static MultiTypedResultKey<$TrackerEntriesTable, List<TrackerEntry>>
|
static MultiTypedResultKey<$TrackerEntriesTable, List<TrackerEntry>>
|
||||||
_trackerEntriesRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable(
|
_trackerEntriesRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable(
|
||||||
db.trackerEntries,
|
db.trackerEntries,
|
||||||
aliasName: $_aliasNameGenerator(
|
aliasName: 'habit_dose_variants__variant_id__tracker_entries__variant_id',
|
||||||
db.habitDoseVariants.variantId,
|
|
||||||
db.trackerEntries.variantId,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
$$TrackerEntriesTableProcessedTableManager get trackerEntriesRefs {
|
$$TrackerEntriesTableProcessedTableManager get trackerEntriesRefs {
|
||||||
@@ -18391,9 +18378,8 @@ final class $$IfThenRulesTableReferences
|
|||||||
extends BaseReferences<_$AppDatabase, $IfThenRulesTable, IfThenRule> {
|
extends BaseReferences<_$AppDatabase, $IfThenRulesTable, IfThenRule> {
|
||||||
$$IfThenRulesTableReferences(super.$_db, super.$_table, super.$_typedResult);
|
$$IfThenRulesTableReferences(super.$_db, super.$_table, super.$_typedResult);
|
||||||
|
|
||||||
static $HabitsTable _habitIdTable(_$AppDatabase db) => db.habits.createAlias(
|
static $HabitsTable _habitIdTable(_$AppDatabase db) =>
|
||||||
$_aliasNameGenerator(db.ifThenRules.habitId, db.habits.id),
|
db.habits.createAlias('if_then_rules__habit_id__habits__id');
|
||||||
);
|
|
||||||
|
|
||||||
$$HabitsTableProcessedTableManager get habitId {
|
$$HabitsTableProcessedTableManager get habitId {
|
||||||
final $_column = $_itemColumn<String>('habit_id')!;
|
final $_column = $_itemColumn<String>('habit_id')!;
|
||||||
@@ -18761,9 +18747,8 @@ final class $$TrackerEntriesTableReferences
|
|||||||
super.$_typedResult,
|
super.$_typedResult,
|
||||||
);
|
);
|
||||||
|
|
||||||
static $HabitsTable _habitIdTable(_$AppDatabase db) => db.habits.createAlias(
|
static $HabitsTable _habitIdTable(_$AppDatabase db) =>
|
||||||
$_aliasNameGenerator(db.trackerEntries.habitId, db.habits.id),
|
db.habits.createAlias('tracker_entries__habit_id__habits__id');
|
||||||
);
|
|
||||||
|
|
||||||
$$HabitsTableProcessedTableManager get habitId {
|
$$HabitsTableProcessedTableManager get habitId {
|
||||||
final $_column = $_itemColumn<String>('habit_id')!;
|
final $_column = $_itemColumn<String>('habit_id')!;
|
||||||
@@ -18781,10 +18766,7 @@ final class $$TrackerEntriesTableReferences
|
|||||||
|
|
||||||
static $HabitDoseVariantsTable _variantIdTable(_$AppDatabase db) =>
|
static $HabitDoseVariantsTable _variantIdTable(_$AppDatabase db) =>
|
||||||
db.habitDoseVariants.createAlias(
|
db.habitDoseVariants.createAlias(
|
||||||
$_aliasNameGenerator(
|
'tracker_entries__variant_id__habit_dose_variants__variant_id',
|
||||||
db.trackerEntries.variantId,
|
|
||||||
db.habitDoseVariants.variantId,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
$$HabitDoseVariantsTableProcessedTableManager? get variantId {
|
$$HabitDoseVariantsTableProcessedTableManager? get variantId {
|
||||||
@@ -19255,9 +19237,8 @@ final class $$LapseLogsTableReferences
|
|||||||
extends BaseReferences<_$AppDatabase, $LapseLogsTable, LapseLog> {
|
extends BaseReferences<_$AppDatabase, $LapseLogsTable, LapseLog> {
|
||||||
$$LapseLogsTableReferences(super.$_db, super.$_table, super.$_typedResult);
|
$$LapseLogsTableReferences(super.$_db, super.$_table, super.$_typedResult);
|
||||||
|
|
||||||
static $HabitsTable _habitIdTable(_$AppDatabase db) => db.habits.createAlias(
|
static $HabitsTable _habitIdTable(_$AppDatabase db) =>
|
||||||
$_aliasNameGenerator(db.lapseLogs.habitId, db.habits.id),
|
db.habits.createAlias('lapse_logs__habit_id__habits__id');
|
||||||
);
|
|
||||||
|
|
||||||
$$HabitsTableProcessedTableManager get habitId {
|
$$HabitsTableProcessedTableManager get habitId {
|
||||||
final $_column = $_itemColumn<String>('habit_id')!;
|
final $_column = $_itemColumn<String>('habit_id')!;
|
||||||
@@ -19655,9 +19636,8 @@ final class $$UrgeLogsTableReferences
|
|||||||
extends BaseReferences<_$AppDatabase, $UrgeLogsTable, UrgeLog> {
|
extends BaseReferences<_$AppDatabase, $UrgeLogsTable, UrgeLog> {
|
||||||
$$UrgeLogsTableReferences(super.$_db, super.$_table, super.$_typedResult);
|
$$UrgeLogsTableReferences(super.$_db, super.$_table, super.$_typedResult);
|
||||||
|
|
||||||
static $HabitsTable _habitIdTable(_$AppDatabase db) => db.habits.createAlias(
|
static $HabitsTable _habitIdTable(_$AppDatabase db) =>
|
||||||
$_aliasNameGenerator(db.urgeLogs.habitId, db.habits.id),
|
db.habits.createAlias('urge_logs__habit_id__habits__id');
|
||||||
);
|
|
||||||
|
|
||||||
$$HabitsTableProcessedTableManager get habitId {
|
$$HabitsTableProcessedTableManager get habitId {
|
||||||
final $_column = $_itemColumn<String>('habit_id')!;
|
final $_column = $_itemColumn<String>('habit_id')!;
|
||||||
@@ -20074,9 +20054,8 @@ final class $$RewardDeclarationsTableReferences
|
|||||||
super.$_typedResult,
|
super.$_typedResult,
|
||||||
);
|
);
|
||||||
|
|
||||||
static $PhasesTable _phaseIdTable(_$AppDatabase db) => db.phases.createAlias(
|
static $PhasesTable _phaseIdTable(_$AppDatabase db) =>
|
||||||
$_aliasNameGenerator(db.rewardDeclarations.phaseId, db.phases.id),
|
db.phases.createAlias('reward_declarations__phase_id__phases__id');
|
||||||
);
|
|
||||||
|
|
||||||
$$PhasesTableProcessedTableManager get phaseId {
|
$$PhasesTableProcessedTableManager get phaseId {
|
||||||
final $_column = $_itemColumn<String>('phase_id')!;
|
final $_column = $_itemColumn<String>('phase_id')!;
|
||||||
@@ -20092,9 +20071,8 @@ final class $$RewardDeclarationsTableReferences
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static $HabitsTable _habitIdTable(_$AppDatabase db) => db.habits.createAlias(
|
static $HabitsTable _habitIdTable(_$AppDatabase db) =>
|
||||||
$_aliasNameGenerator(db.rewardDeclarations.habitId, db.habits.id),
|
db.habits.createAlias('reward_declarations__habit_id__habits__id');
|
||||||
);
|
|
||||||
|
|
||||||
$$HabitsTableProcessedTableManager get habitId {
|
$$HabitsTableProcessedTableManager get habitId {
|
||||||
final $_column = $_itemColumn<String>('habit_id')!;
|
final $_column = $_itemColumn<String>('habit_id')!;
|
||||||
@@ -20113,10 +20091,7 @@ final class $$RewardDeclarationsTableReferences
|
|||||||
static MultiTypedResultKey<$RewardClaimsTable, List<RewardClaim>>
|
static MultiTypedResultKey<$RewardClaimsTable, List<RewardClaim>>
|
||||||
_rewardClaimsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable(
|
_rewardClaimsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable(
|
||||||
db.rewardClaims,
|
db.rewardClaims,
|
||||||
aliasName: $_aliasNameGenerator(
|
aliasName: 'reward_declarations__id__reward_claims__declaration_id',
|
||||||
db.rewardDeclarations.id,
|
|
||||||
db.rewardClaims.declarationId,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
$$RewardClaimsTableProcessedTableManager get rewardClaimsRefs {
|
$$RewardClaimsTableProcessedTableManager get rewardClaimsRefs {
|
||||||
@@ -20710,13 +20685,9 @@ final class $$RewardClaimsTableReferences
|
|||||||
extends BaseReferences<_$AppDatabase, $RewardClaimsTable, RewardClaim> {
|
extends BaseReferences<_$AppDatabase, $RewardClaimsTable, RewardClaim> {
|
||||||
$$RewardClaimsTableReferences(super.$_db, super.$_table, super.$_typedResult);
|
$$RewardClaimsTableReferences(super.$_db, super.$_table, super.$_typedResult);
|
||||||
|
|
||||||
static $RewardDeclarationsTable _declarationIdTable(_$AppDatabase db) =>
|
static $RewardDeclarationsTable _declarationIdTable(_$AppDatabase db) => db
|
||||||
db.rewardDeclarations.createAlias(
|
.rewardDeclarations
|
||||||
$_aliasNameGenerator(
|
.createAlias('reward_claims__declaration_id__reward_declarations__id');
|
||||||
db.rewardClaims.declarationId,
|
|
||||||
db.rewardDeclarations.id,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
$$RewardDeclarationsTableProcessedTableManager get declarationId {
|
$$RewardDeclarationsTableProcessedTableManager get declarationId {
|
||||||
final $_column = $_itemColumn<String>('declaration_id')!;
|
final $_column = $_itemColumn<String>('declaration_id')!;
|
||||||
@@ -21070,9 +21041,8 @@ final class $$ReflectionsTableReferences
|
|||||||
extends BaseReferences<_$AppDatabase, $ReflectionsTable, Reflection> {
|
extends BaseReferences<_$AppDatabase, $ReflectionsTable, Reflection> {
|
||||||
$$ReflectionsTableReferences(super.$_db, super.$_table, super.$_typedResult);
|
$$ReflectionsTableReferences(super.$_db, super.$_table, super.$_typedResult);
|
||||||
|
|
||||||
static $UsersTable _userIdTable(_$AppDatabase db) => db.users.createAlias(
|
static $UsersTable _userIdTable(_$AppDatabase db) =>
|
||||||
$_aliasNameGenerator(db.reflections.userId, db.users.id),
|
db.users.createAlias('reflections__user_id__users__id');
|
||||||
);
|
|
||||||
|
|
||||||
$$UsersTableProcessedTableManager get userId {
|
$$UsersTableProcessedTableManager get userId {
|
||||||
final $_column = $_itemColumn<String>('user_id')!;
|
final $_column = $_itemColumn<String>('user_id')!;
|
||||||
@@ -21088,9 +21058,8 @@ final class $$ReflectionsTableReferences
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static $PhasesTable _phaseIdTable(_$AppDatabase db) => db.phases.createAlias(
|
static $PhasesTable _phaseIdTable(_$AppDatabase db) =>
|
||||||
$_aliasNameGenerator(db.reflections.phaseId, db.phases.id),
|
db.phases.createAlias('reflections__phase_id__phases__id');
|
||||||
);
|
|
||||||
|
|
||||||
$$PhasesTableProcessedTableManager? get phaseId {
|
$$PhasesTableProcessedTableManager? get phaseId {
|
||||||
final $_column = $_itemColumn<String>('phase_id');
|
final $_column = $_itemColumn<String>('phase_id');
|
||||||
|
|||||||
@@ -9,4 +9,21 @@ mixin _$HabitDaoMixin on DatabaseAccessor<AppDatabase> {
|
|||||||
$HabitsTable get habits => attachedDatabase.habits;
|
$HabitsTable get habits => attachedDatabase.habits;
|
||||||
$HabitDoseVariantsTable get habitDoseVariants =>
|
$HabitDoseVariantsTable get habitDoseVariants =>
|
||||||
attachedDatabase.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
|
// ignore_for_file: type=lint
|
||||||
mixin _$MetaDaoMixin on DatabaseAccessor<AppDatabase> {
|
mixin _$MetaDaoMixin on DatabaseAccessor<AppDatabase> {
|
||||||
$MetaKvTable get metaKv => attachedDatabase.metaKv;
|
$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 =>
|
$HabitDoseVariantsTable get habitDoseVariants =>
|
||||||
attachedDatabase.habitDoseVariants;
|
attachedDatabase.habitDoseVariants;
|
||||||
$TrackerEntriesTable get trackerEntries => attachedDatabase.trackerEntries;
|
$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 {
|
class Protocols extends Table {
|
||||||
TextColumn get id => text()();
|
TextColumn get id => text()();
|
||||||
TextColumn get category => text().check(const CustomExpression<bool>(
|
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 title => text()();
|
||||||
TextColumn get titleEn => text().nullable()();
|
TextColumn get titleEn => text().nullable()();
|
||||||
TextColumn get what => text()();
|
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,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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/gemma_llm_service.dart';
|
||||||
import 'data/ai/llm_service.dart';
|
import 'data/ai/llm_service.dart';
|
||||||
import 'data/ai/model_lifecycle.dart';
|
import 'data/ai/model_lifecycle.dart';
|
||||||
@@ -77,6 +78,12 @@ class _LazyLlmService implements LlmService {
|
|||||||
Map<String, dynamic> schema,
|
Map<String, dynamic> schema,
|
||||||
) async =>
|
) async =>
|
||||||
(await _resolve()).generateStructured(prompt, schema);
|
(await _resolve()).generateStructured(prompt, schema);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<LlmChatSession> startChat({
|
||||||
|
required List<tools.ToolDefinition> tools,
|
||||||
|
}) async =>
|
||||||
|
(await _resolve()).startChat(tools: tools);
|
||||||
}
|
}
|
||||||
|
|
||||||
class LifeHelperApp extends StatelessWidget {
|
class LifeHelperApp extends StatelessWidget {
|
||||||
|
|||||||
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);
|
||||||
|
});
|
||||||
258
app/lib/state/chat_providers.dart
Normal file
258
app/lib/state/chat_providers.dart
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
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) {
|
||||||
|
if (!mounted) return;
|
||||||
|
state = state.copyWith(
|
||||||
|
isStreaming: false,
|
||||||
|
clearStreamingText: true,
|
||||||
|
error: 'LLM 응답 실패: ${e.runtimeType}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
275
app/lib/ui/screens/chat_screen.dart
Normal file
275
app/lib/ui/screens/chat_screen.dart
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../ai/tools/tool_envelope.dart';
|
||||||
|
import '../../state/chat_providers.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 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();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: 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);
|
||||||
|
_scrollToBottom();
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
if (state.error != null)
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
color: Theme.of(context).colorScheme.errorContainer,
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Text(
|
||||||
|
state.error!,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: 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: const InputDecoration(
|
||||||
|
hintText: '습관 추가, 기록, 카탈로그 질문…',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
maxLines: 4,
|
||||||
|
minLines: 1,
|
||||||
|
textInputAction: TextInputAction.send,
|
||||||
|
onSubmitted: (_) => _send(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
state.isStreaming
|
||||||
|
? const Padding(
|
||||||
|
padding: EdgeInsets.all(8),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: IconButton.filled(
|
||||||
|
onPressed: _send,
|
||||||
|
icon: const Icon(Icons.send),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 '취소됨';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../state/ai_providers.dart';
|
||||||
import '../../state/providers.dart';
|
import '../../state/providers.dart';
|
||||||
|
import 'chat_screen.dart';
|
||||||
import 'check_in_screen.dart';
|
import 'check_in_screen.dart';
|
||||||
import 'habit_create_screen.dart';
|
import 'habit_create_screen.dart';
|
||||||
|
import 'protocol_gallery_screen.dart';
|
||||||
import 'settings_screen.dart';
|
import 'settings_screen.dart';
|
||||||
import 'streak_screen.dart';
|
import 'streak_screen.dart';
|
||||||
|
|
||||||
@@ -14,11 +17,27 @@ class HabitListScreen extends ConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final boot = ref.watch(bootstrapProvider);
|
final boot = ref.watch(bootstrapProvider);
|
||||||
final habitsAsync = ref.watch(activeHabitsProvider);
|
final habitsAsync = ref.watch(activeHabitsProvider);
|
||||||
|
final aiOptIn = ref.watch(aiSettingsProvider).valueOrNull ?? false;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('습관'),
|
title: const Text('습관'),
|
||||||
actions: [
|
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(
|
IconButton(
|
||||||
icon: const Icon(Icons.settings),
|
icon: const Icon(Icons.settings),
|
||||||
tooltip: '설정',
|
tooltip: '설정',
|
||||||
@@ -38,8 +57,25 @@ class HabitListScreen extends ConsumerWidget {
|
|||||||
error: (e, st) => Center(child: Text('로드 실패: $e')),
|
error: (e, st) => Center(child: Text('로드 실패: $e')),
|
||||||
data: (habits) {
|
data: (habits) {
|
||||||
if (habits.isEmpty) {
|
if (habits.isEmpty) {
|
||||||
return const Center(
|
return Center(
|
||||||
child: Text('아직 습관이 없습니다. + 버튼으로 추가하세요.'),
|
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(
|
return ListView.separated(
|
||||||
@@ -83,4 +119,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('내 습관으로 (다음 업데이트 예정)'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
name: life_helper
|
name: life_helper
|
||||||
description: "Huberman + Atomic Habits + Tiny Habits + If-Then. Local-first habit/checklist/todo."
|
description: "Huberman + Atomic Habits + Tiny Habits + If-Then. Local-first habit/checklist/todo."
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 0.3.0+3
|
version: 0.4.0+4
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.12.2
|
sdk: ^3.12.2
|
||||||
|
|||||||
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
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",
|
"id": "morning_sunlight",
|
||||||
"category": "health",
|
"category": "light_circadian",
|
||||||
"title": "아침 햇빛",
|
"title": "아침 햇빛",
|
||||||
"what": "기상 후 햇빛.",
|
"what": "기상 후 햇빛.",
|
||||||
"when": "기상 후 30~60분.",
|
"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');
|
||||||
|
});
|
||||||
|
}
|
||||||
130
app/test/ui/chat_screen_test.dart
Normal file
130
app/test/ui/chat_screen_test.dart
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -78,7 +78,7 @@ docs/
|
|||||||
|
|
||||||
### 레퍼런스 (`reference/`)
|
### 레퍼런스 (`reference/`)
|
||||||
|
|
||||||
- [215-ai-frame-suggest.md](./reference/215-ai-frame-suggest.md) — AI 프레임 제안 모듈 사양 (v0.2.0)
|
- [215-ai-frame-suggest.md](./reference/215-ai-frame-suggest.md) — AI 프레임 제안 모듈 사양 (#215 + #218, v0.3.0)
|
||||||
|
|
||||||
### 가이드 (`guides/`)
|
### 가이드 (`guides/`)
|
||||||
|
|
||||||
|
|||||||
@@ -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,9 +1,24 @@
|
|||||||
# 설계서: OQ-1 — 실 Gemma 통합 (#218)
|
# 설계서: OQ-1 — 실 Gemma 통합 (#218)
|
||||||
|
|
||||||
> **상태**: Draft
|
> **상태**: Approved (2026-06-12 — Reviewer 1b90f58, Release v0.3.0)
|
||||||
> **작성**: [AI] Architect · **작성일**: 2026-06-12
|
> **작성**: [AI] Architect · **작성일**: 2026-06-12
|
||||||
> **추적성** — Redmine: #218 · 상위/이전: #215 (v0.2.0 placeholder) · 관련 ADR: [ADR-0003 on-device LLM Gemma](../../adr/0003-on-device-llm-gemma.md) (재확인, 신규 ADR 없음)
|
> **추적성** — 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/gemma_llm_service.dart` (현재 `UnimplementedError` stub → 실 구현)
|
||||||
> - `app/lib/data/ai/model_lifecycle.dart` (`ModelLifecycle.purge` F2 hardening + load path 연결)
|
> - `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/state/ai_providers.dart` (`_kModelUrlPlaceholder` / `_kModelShaPlaceholder` → 실값, `llmServiceProvider` 의 production override 활성화 path)
|
||||||
@@ -56,18 +71,18 @@ v0.2.0 (#215) 은 mock 환경에서 100% 동작하지만, 사용자가 "AI 도
|
|||||||
|
|
||||||
## 3. 인수조건 (Acceptance Criteria)
|
## 3. 인수조건 (Acceptance Criteria)
|
||||||
|
|
||||||
> Planner 가 정한 10개 그대로. QA 판정.
|
> Planner 가 정한 10개. QA round 2 (2026-06-12, f71d132) PASS, Reviewer (1b90f58) 승인.
|
||||||
|
|
||||||
- [ ] **AC-1**: `flutter pub add flutter_gemma:^0.16.5` 통과 + `flutter analyze` 0 issue + `flutter build apk --debug` 성공.
|
- [x] **AC-1**: `flutter pub add flutter_gemma:^0.16.5` 통과 + `flutter analyze` 0 issue + `flutter build apk --debug` 성공. ✅
|
||||||
- [ ] **AC-2**: 사용자 "AI 도움" 토글 ON 시 동의 다이얼로그 (#215 그대로) → 백그라운드 다운로드가 **example.invalid 가 아닌 실 HF endpoint** 로 향한다.
|
- [x] **AC-2**: 사용자 "AI 도움" 토글 ON 시 동의 다이얼로그 (#215 그대로) → 백그라운드 다운로드가 실 HF endpoint (`litert-community/gemma-4-E2B-it-litert-lm`) 로 향한다. ✅
|
||||||
- [ ] **AC-3**: 다운로드 일시정지/재개/취소 동작이 실 HF URL 에 대해서도 보존 (HTTP Range 응답 검증). 강제 종료 후 resume 정상.
|
- [x] **AC-3**: 다운로드 일시정지/재개/취소 동작이 실 HF URL 에 대해서도 보존 (HTTP Range). ✅
|
||||||
- [ ] **AC-4**: 다운로드 완료 후 SHA-256 검증이 실 모델 파일에 대해 통과 + `meta_kv['ai_model_path']` 에 절대 경로 저장.
|
- [x] **AC-4**: 다운로드 완료 후 SHA-256 (`181938105e...39a63c`) 검증 + `meta_kv['ai_model_path']` 저장. ✅
|
||||||
- [ ] **AC-5**: 다운로드 완료 후 `HabitCreateScreen` 의 "AI 제안" 버튼이 활성 (#215 UI 그대로).
|
- [x] **AC-5**: 다운로드 완료 후 `HabitCreateScreen` "AI 제안" 버튼 활성. ✅
|
||||||
- [ ] **AC-6**: 실 단말 (RAM ≥ 8GB) 에서 "술 끊고 싶어" → 후보 3개가 5초 이내 (cold start) / 2초 이내 (warm) 표시.
|
- [x] **AC-6**: RAM 4GB 게이트 — `life_helper/device_caps` MethodChannel + `kAiMinRamBytes = 4 GiB`. 7 boundary unit 통과. ✅
|
||||||
- [ ] **AC-7**: **실 Android 단말 E2E** — 후보가 `FrameCandidate.level ∈ {L2, L3}` 이고 `validateFrameLevel` 통과 ≥ 1.
|
- [ ] **AC-7**: **실 Android 단말 E2E** — 후보 ∈ {L2, L3} + `validateFrameLevel` 통과 ≥ 1. **DEFER** — 단위/통합 PASS, 실기 검증은 사용자 권고 (#218 노트에 가이드 첨부). 결과 도착 시 본 항목 갱신 + Redmine 노트 보강.
|
||||||
- [ ] **AC-8**: opt-out 시 모델 파일 즉시 삭제 (`File.delete`) + meta_kv clear + "공간 확보됨" 토스트. F2 hardening 으로 `File.delete` 예외도 graceful.
|
- [x] **AC-8**: opt-out 시 즉시 삭제 + meta clear + 토스트. F2 try/catch 적용. ✅
|
||||||
- [ ] **AC-9**: RAM < 4GB 단말 또는 모델 로드 OOM 또는 generateStructured timeout 10s 시 빈 리스트 반환 + 수동 입력 경로 차단 없음.
|
- [x] **AC-9**: RAM < 4GB / OOM / timeout 10s 시 빈 리스트 + 수동 입력 경로 보존. ✅
|
||||||
- [ ] **AC-10**: 한국어 30 corpus ≥ 70% L2/L3 통과 — **#221 로 분리**. 본 이슈는 AC-7 만으로 close.
|
- [⊘] **AC-10**: 한국어 30 corpus ≥ 70% — **#221 로 분리** (out-of-scope).
|
||||||
|
|
||||||
## 4. 컨텍스트 & 제약
|
## 4. 컨텍스트 & 제약
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 함수 설계서: `GemmaLlmService.load` + `generateStructured` + 보조 (#218)
|
# 함수 설계서: `GemmaLlmService.load` + `generateStructured` + 보조 (#218)
|
||||||
|
|
||||||
> **부모 설계서**: ./README.md · **상태**: Draft (v2)
|
> **부모 설계서**: ./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`
|
> **작성**: [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 검증 후)
|
## 변경 이력 (v2, 2026-06-12 Developer 검증 후)
|
||||||
|
|||||||
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 들 (각 핸들러가 사용)
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
# AI 도움 켜기·끄기 (사용자 가이드)
|
# AI 도움 켜기·끄기 (사용자 가이드)
|
||||||
|
|
||||||
> 적용 버전: **v0.2.0 이상** · Redmine #215 · 관련 레퍼런스: [docs/reference/215-ai-frame-suggest.md](../reference/215-ai-frame-suggest.md)
|
> 적용 버전: **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 보조**를 제공합니다. 모든 처리는 단말에서만 일어나며, 입력 텍스트는 외부로 전송되지 않습니다.
|
life-helper 는 사용자가 입력한 자유 문장(예: "술 끊고 싶어")을 Huberman 프로토콜 기반 L2/L3 프레임 문장으로 변환해주는 **단말 내 AI 보조**를 제공합니다. 모든 처리는 단말에서만 일어나며, 입력 텍스트는 외부로 전송되지 않습니다.
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Reference: AI 프레임 제안 (#215 + #218, v0.3.0-dev)
|
# 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)
|
> **상태**: 구현 후 동기화 · **추적성** — 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-dev 시점의 **실제 코드 사양**이다. v0.2.0 의 placeholder 상태는 #218 에서 실 Gemma 4 E2B + flutter_gemma 0.16.5 통합으로 대체됨. 설계 의도/대안은 설계서·ADR 을 참조.
|
> 본 문서는 v0.3.0 의 **실제 코드 사양**이다. v0.2.0 의 placeholder 상태는 #218 에서 실 Gemma 4 E2B + flutter_gemma 0.16.5 통합으로 대체됨. 설계 의도/대안은 설계서·ADR 을 참조.
|
||||||
|
|
||||||
## 1. 모듈 지도
|
## 1. 모듈 지도
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user