8 Commits

Author SHA1 Message Date
7037b9e245 [07-Release] #260 v0.4.0 — in-app tool calling
Phase 2-B 릴리스. on-device Gemma 4 가 6 tool 로 카탈로그/습관 CRUD.

- pubspec.yaml: 0.3.0+3 → 0.4.0+4
- CHANGELOG.md: [0.4.0] 섹션 추가
- APK: app-release.apk 287MB
  SHA-256 6670da0c4e9bf5e826174ebc48088540867d877cf58699119a519e2ffb40ea3a

회귀: 154 passed (1 skip), flutter analyze clean.

Refs #260
2026-06-15 11:13:12 +09:00
a8446d0c88 [05-Designer] #260 chat UX polish (QA 인수 4건)
QA round 2 인수 노트의 UX 항목 정돈. blocker 아니었음 — Designer 단계
의무 폴리시.

1) ToolCallChatMessage 라벨 한국어화
   - chat_screen.dart: _kToolKoreanLabels 맵 추가. 6 tool 모두 한국어
     라벨 (예: add_habit → '습관 추가'). 미매핑 tool 은 raw name fallback.

2) ConfirmDialog 좁은 화면 reflow
   - confirm_gate.dart: AlertDialog content 를 SingleChildScrollView 로
     감쌈. summary box width=double.infinity (좌측 정렬 안정).

3) Streaming cursor 다크모드 contrast
   - chat_screen.dart: ▍ 문자를 Text.rich 로 분리해 colorScheme.primary
     적용. 다크 모드에서도 onSurface 본문 대비 cursor 가 식별됨.

4) AppBar tooltip 명료성
   - chat_screen.dart: '새 대화' → '새 대화 (이전 기록 비우기)'.
     history reset 의미 명시.

회귀
- 154 passed (1 skip), 회귀 0
- flutter analyze: clean

Refs #260
2026-06-15 10:59:50 +09:00
b9f5674f51 [03-Developer] #260 round 2: AC-9 + AC-10 보강
QA round 1 환송 노트 (#260 카테고리 63 환송) 의 두 결함 수정.

AC-9 — tool result 2KB 가드 runtime 연결
- chat_providers.dart userTurn: result.toJson() → encodeToolResult 통과
  후 jsonDecode 한 Map 을 LlmChatSession.sendToolResult 로 전달.
- encodeToolResult 가 더 이상 dead code 가 아니다. ADR-0005 / OQ-2
  의 2KB hard cap 이 실 경로에서 적용됨.
- 회귀: chat_session_controller_test.dart 신규 'AC-9 대용량 → cap'
  케이스 — 인위 huge_dump tool 로 _truncated:true + _hint 검증.

AC-10 — widget E2E 신규
- app/test/ui/chat_screen_test.dart 신규 (2 testWidgets):
  1) add_habit tool call → ConfirmDialog '수행' → habits +1 + 모델 마무리.
  2) ConfirmDialog '취소' → habits 무변화 + 'tool 취소됨' 라벨.
- ProviderScope overrides: appDatabaseProvider / llmServiceProvider /
  bootstrapProvider / toolDepsProvider.

회귀
- 신규 3 (cap 1 + widget 2) → 151 → 154 passed (1 skip)
- flutter analyze: clean

Refs #260
2026-06-15 10:54:53 +09:00
b1bed4d5ca [03-Developer] #260 in-app tool calling (Gemma 4 multi-turn)
ADR-0005 in-process tool runtime — 6 tools (catalog 2 + tracker 2 +
habit 2), ToolDispatcher with JSON-schema validation + modal ConfirmGate
for destructive ops, multi-turn LlmChatSession abstraction wired to
flutter_gemma 0.16.5 (ToolChoice.auto), ChatSessionController with
MAX_TURNS=4 safety + 8-turn history hint, ChatScreen entry behind AI
opt-in. R3/R7/R8 enforced inside handlers. 41 new tests (envelope,
catalog/tracker/habit tools, dispatcher, controller loop) — 151 total
passing.

Refs #260
2026-06-15 10:42:43 +09:00
eca097aa2c [02-Architect] #260 design spec + ADR-0005
- design/260-gemma-tool-calling/README.md — overall (12 AC + 7 OQ + 모듈 구조)
- fn-tool_dispatcher.md — multi-tool router (validate → confirm gate → handler → envelope)
- fn-add_habit_handler.md — destructive 대표 (R3/R7/R8 enforce)
- fn-confirm_gate.md — 모달 AlertDialog 흐름 (OQ-3 = 모달 확정)
- fn-chat_session_controller.md — multi-turn loop 상태 머신 (MAX_TURNS=4)
- ADR-0005 — in-app tool runtime + R 규칙 = 핸들러 책임 + schema SoT=Dart + 모달

OQ-1/2/4 = README §11 결정. OQ-3 = 모달 (사용자 결정).
신규 OQ-5/6/7 = Developer 가 구현 중 검증.

Refs #260
2026-06-15 10:15:44 +09:00
321d3af53b [03-Developer] #226 Catalog Gallery 구현
- Drift schema v2: Protocols.category CHECK 6→7 (light_circadian/sleep/movement/
  nutrition/focus_cognition/recovery_stress/emotion_relationship). schemaVersion
  1→2 + onUpgrade migrateV1ToV2 (DROP+CREATE+reseed flag 클리어).
- protocols.json 34 항목 v2 재분류 (1차 효과 기준). emotion_relationship 0 매핑.
- 도메인: DisplayCategory enum (8) + CatalogItem sealed (Protocol/Break/Diet).
- 데이터: CatalogRepository.all/byId/referencesByIds (3 source 통합).
- 상태: catalog_providers.dart (catalogItems / groupedByCategory / refsByIds).
- UI: ProtocolGalleryScreen (카테고리 칩 + 카드 그리드) + ProtocolPreviewScreen
  (모든 필드 + reference 펼치기 + "내 습관으로" disabled placeholder) +
  CatalogCard / CategoryChipRow / ReferenceExpandCard. HabitListScreen 빈
  상태 CTA + AppBar 액션.
- 테스트: migration_v1_to_v2 3건 + display_category 5건 + catalog_repository
  9건 + gallery widget 3건 + preview widget 3건 = 23 신규. 기존 88 회귀 0,
  flutter analyze 0 issues. 110 passed / 1 skipped.

설계서: docs/design/226-catalog-gallery/{README, fn-catalog_repository,
fn-migration_v1_to_v2}.md + ADR-0004.

Refs #226
2026-06-12 17:20:13 +09:00
4665f06a94 [02-Architect] #226 design spec + ADR-0004
- docs/design/226-catalog-gallery/README.md — 12 changed files, DisplayCategory enum, sealed CatalogItem, 5 scenarios
- docs/design/226-catalog-gallery/fn-catalog_repository.md — 3-source unification algorithm + helpers
- docs/design/226-catalog-gallery/fn-migration_v1_to_v2.md — first schema migration (DROP + reseed pattern)
- docs/adr/0004-catalog-recategorization-and-first-migration.md — Catalog 재분류 + 첫 마이그레이션 정책

Refs #226
2026-06-12 16:59:31 +09:00
25be18063e [08-Documenter] #218 docs marked Approved + v0.3.0 sync
- 설계서 218-gemma-real-integration/README.md → Approved + AC 체크박스 채움 + 실제 구현/테스트 파일 경로 추적성 갱신
- fn-gemma_llm_service.md → Approved (v2)
- reference/215-ai-frame-suggest.md → v0.3.0 (commit da60dd1 핀)
- guides/ai-help-onboarding.md → 적용 버전 v0.3.0 + RAM 4GB 요구사항 명시
- docs/README.md 인덱스 v0.3.0 표기

AC-7 (실 단말 E2E) 만 DEFER — 사용자 실기 검증 결과로 별도 갱신.

Refs #218
2026-06-12 16:22:40 +09:00
62 changed files with 6088 additions and 167 deletions

View File

@@ -3,6 +3,42 @@
본 프로젝트의 모든 의미있는 변경은 본 파일에 기록한다.
형식: [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
### Added — Phase 2-A OQ-1 resolved: real Gemma 4 E2B inference (Redmine #218)

View File

@@ -1,7 +1,7 @@
[
{
"id": "morning_sunlight",
"category": "health",
"category": "light_circadian",
"title": "아침 햇빛",
"title_en": "Morning Sunlight",
"what": "기상 후 야외에서 햇빛을 직접 눈에 받기.",
@@ -21,13 +21,17 @@
"after_what": "기상 후 양치"
},
"min_dose_for_start": "햇빛 30초~2분 (Tiny Habits 시작 도즈)",
"reference_ids": ["ref_podcast_hl_2_sleep", "ref_podcast_hl_68_light", "ref_doi_10_1016_j_cub_2013_06_039"],
"reference_ids": [
"ref_podcast_hl_2_sleep",
"ref_podcast_hl_68_light",
"ref_doi_10_1016_j_cub_2013_06_039"
],
"evidence_strength": "strong_rct",
"source_doc": "huberman-protocols.md"
},
{
"id": "evening_sunlight",
"category": "health",
"category": "light_circadian",
"title": "저녁 햇빛",
"title_en": "Evening Sunlight",
"what": "일몰 즈음 햇빛 보기.",
@@ -40,13 +44,15 @@
"야외 5~10분 (저녁 산책과 결합)."
],
"check": "일몰 ±1시간 안에 야외 / 5분 이상",
"reference_ids": ["ref_podcast_hl_68_light"],
"reference_ids": [
"ref_podcast_hl_68_light"
],
"evidence_strength": "mechanistic",
"source_doc": "huberman-protocols.md"
},
{
"id": "night_light_avoidance",
"category": "health",
"category": "light_circadian",
"title": "야간 빛 차단",
"title_en": "Night Light Avoidance",
"what": "밤 10시 ~ 새벽 4시 머리 위 강한 빛 차단.",
@@ -63,13 +69,16 @@
"default_anchor": {
"when": "21:00"
},
"reference_ids": ["ref_podcast_hl_68_light", "ref_doi_10_1038_tp_2016_262"],
"reference_ids": [
"ref_podcast_hl_68_light",
"ref_doi_10_1038_tp_2016_262"
],
"evidence_strength": "mechanistic",
"source_doc": "huberman-protocols.md"
},
{
"id": "sleep_stack",
"category": "health",
"category": "sleep",
"title": "수면 스택",
"title_en": "Sleep Stack",
"what": "입면·심부 체온·자극 차단을 결합한 취침 전 90분 루틴.",
@@ -84,13 +93,16 @@
"침실 18~19℃, 침대 진입 직전 화면 OFF."
],
"check": "기상 시각 ±1h / 카페인 컷오프 / 식사 2~3h 전 종료 / 침실 18~19℃",
"reference_ids": ["ref_podcast_hl_84_sleep_toolkit", "ref_podcast_hl_2_sleep"],
"reference_ids": [
"ref_podcast_hl_84_sleep_toolkit",
"ref_podcast_hl_2_sleep"
],
"evidence_strength": "expert_opinion",
"source_doc": "huberman-protocols.md"
},
{
"id": "caffeine_protocol",
"category": "health",
"category": "sleep",
"title": "카페인 타이밍",
"title_en": "Caffeine Protocol",
"what": "기상 직후 카페인 회피 + 컷오프 시각 준수.",
@@ -105,13 +117,16 @@
],
"check": "기상 후 90분 전 카페인 X / 컷오프 시각 이후 카페인 X",
"caution": "90~120분 지연은 직접 RCT 부재. adenosine 약리학 기반 추론. 근거 ⚠️.",
"reference_ids": ["ref_podcast_hl_101_caffeine", "ref_doi_10_5664_jcsm_3170"],
"reference_ids": [
"ref_podcast_hl_101_caffeine",
"ref_doi_10_5664_jcsm_3170"
],
"evidence_strength": "expert_opinion",
"source_doc": "huberman-protocols.md"
},
{
"id": "weekly_movement_template",
"category": "health",
"category": "movement",
"title": "주간 운동 템플릿",
"title_en": "Weekly Movement Template",
"what": "유산소(Zone 2 + VO2max) + 근력의 주간 분배.",
@@ -127,13 +142,15 @@
],
"check": "Zone 2 ≥ 150분/주 / VO2max 1회 / 근력 2~4회",
"min_dose_for_start": "운동 1세트 또는 5분 산책",
"reference_ids": ["ref_doi_10_1001_jamanetworkopen_2018_3605"],
"reference_ids": [
"ref_doi_10_1001_jamanetworkopen_2018_3605"
],
"evidence_strength": "observational",
"source_doc": "huberman-protocols.md"
},
{
"id": "deliberate_cold_exposure",
"category": "health",
"category": "recovery_stress",
"title": "의도적 냉수 노출",
"title_en": "Deliberate Cold Exposure",
"what": "찬물 샤워 또는 ice bath.",
@@ -149,13 +166,16 @@
],
"check": "1회 ≥ 1분 / 주 합산 ≥ 11분",
"caution": "근비대 직후 4h 회피. 심혈관 질환자 의사 상담.",
"reference_ids": ["ref_podcast_hl_66_cold", "ref_doi_10_1007_s004210050065"],
"reference_ids": [
"ref_podcast_hl_66_cold",
"ref_doi_10_1007_s004210050065"
],
"evidence_strength": "strong_rct",
"source_doc": "huberman-protocols.md"
},
{
"id": "deliberate_heat_exposure",
"category": "health",
"category": "recovery_stress",
"title": "사우나",
"title_en": "Deliberate Heat Exposure",
"what": "80~100℃ 사우나.",
@@ -169,13 +189,16 @@
],
"check": "주 합산 ≥ 57분 (선택)",
"caution": "임신/심혈관/저혈압 시 의사 상담. 알코올 결합 X.",
"reference_ids": ["ref_podcast_hl_69_heat", "ref_doi_10_1001_jamainternmed_2014_8187"],
"reference_ids": [
"ref_podcast_hl_69_heat",
"ref_doi_10_1001_jamainternmed_2014_8187"
],
"evidence_strength": "observational",
"source_doc": "huberman-protocols.md"
},
{
"id": "foundational_supplements",
"category": "health",
"category": "nutrition",
"title": "핵심 보충제",
"title_en": "Foundational Supplements",
"what": "Vitamin D3, Magnesium L-threonate, (옵션) Apigenin, Theanine.",
@@ -189,13 +212,16 @@
],
"check": "처방/권장량 준수 / 신규 도입 한 번에 1종",
"caution": "의약품/임신/기저질환 시 의사 상담. Theanine은 혈압약 상호작용 가능.",
"reference_ids": ["ref_podcast_hl_84_sleep_toolkit", "ref_doi_10_1016_j_sleepx_2024_100121"],
"reference_ids": [
"ref_podcast_hl_84_sleep_toolkit",
"ref_doi_10_1016_j_sleepx_2024_100121"
],
"evidence_strength": "strong_rct",
"source_doc": "huberman-protocols.md"
},
{
"id": "focused_meditation",
"category": "meditation",
"category": "focus_cognition",
"title": "집중 명상",
"title_en": "Focused Meditation",
"what": "단일 대상(호흡/미간)에 주의 고정.",
@@ -212,13 +238,16 @@
"check": "13분 완료 / 알아챔→복귀 1회 이상 의식",
"caution": "잠들기 직전 진행 시 각성 유발 가능.",
"min_dose_for_start": "명상 1분",
"reference_ids": ["ref_podcast_hl_96_meditation", "ref_doi_10_1016_j_bbr_2018_08_023"],
"reference_ids": [
"ref_podcast_hl_96_meditation",
"ref_doi_10_1016_j_bbr_2018_08_023"
],
"evidence_strength": "strong_rct",
"source_doc": "huberman-protocols.md"
},
{
"id": "nsdr_yoga_nidra",
"category": "meditation",
"category": "recovery_stress",
"title": "NSDR / Yoga Nidra",
"title_en": "Non-Sleep Deep Rest",
"what": "누운 자세에서 가이드 보디스캔 + 긴 날숨.",
@@ -233,13 +262,16 @@
"종료 후 30초 잔여감."
],
"check": "가이드 끝까지 / 종료 후 30초 잔여감",
"reference_ids": ["ref_podcast_hl_28_daily_tools", "ref_doi_10_1016_S0926_6410_01_00106_9"],
"reference_ids": [
"ref_podcast_hl_28_daily_tools",
"ref_doi_10_1016_S0926_6410_01_00106_9"
],
"evidence_strength": "mechanistic",
"source_doc": "huberman-protocols.md"
},
{
"id": "cyclic_sighing",
"category": "meditation",
"category": "recovery_stress",
"title": "생리적 한숨",
"title_en": "Cyclic Sighing",
"what": "들숨 2회 + 긴 날숨 1회.",
@@ -255,13 +287,16 @@
],
"check": "패턴 유지 / 1분 이상",
"min_dose_for_start": "cyclic sighing 30초~1분",
"reference_ids": ["ref_podcast_hl_10_stress", "ref_doi_10_1016_j_xcrm_2022_100895"],
"reference_ids": [
"ref_podcast_hl_10_stress",
"ref_doi_10_1016_j_xcrm_2022_100895"
],
"evidence_strength": "strong_rct",
"source_doc": "huberman-protocols.md"
},
{
"id": "box_breathing",
"category": "meditation",
"category": "recovery_stress",
"title": "Box Breathing",
"title_en": "Box Breathing",
"what": "4초 들숨4초 멈춤4초 날숨4초 멈춤.",
@@ -277,13 +312,16 @@
],
"check": "4-4-4-4 박자 / 2분 이상",
"caution": "특이성 RCT 빈약 — cyclic sighing(§2.3)보다 효과 작음.",
"reference_ids": ["ref_podcast_hl_10_stress", "ref_doi_10_3389_fnhum_2018_00353"],
"reference_ids": [
"ref_podcast_hl_10_stress",
"ref_doi_10_3389_fnhum_2018_00353"
],
"evidence_strength": "mechanistic",
"source_doc": "huberman-protocols.md"
},
{
"id": "cold_sigh_combo",
"category": "meditation",
"category": "recovery_stress",
"title": "Cold + Sigh Combo",
"title_en": "Cold + Sigh Combo",
"what": "찬물 세면 + cyclic sighing.",
@@ -301,7 +339,7 @@
},
{
"id": "protect_dopamine_baseline",
"category": "motivation",
"category": "focus_cognition",
"title": "도파민 baseline 보호",
"title_en": "Protect Dopamine Baseline",
"what": "활동 자체에 외부 도파민 자극을 중첩(stacking)하지 않기.",
@@ -315,13 +353,16 @@
"주 1~2회 '맨몸' 세션으로 baseline 회복."
],
"check": "stacking ≤ 1 / 직후 5분 차단",
"reference_ids": ["ref_podcast_hl_39_dopamine", "ref_book_lembke_dopamine_nation"],
"reference_ids": [
"ref_podcast_hl_39_dopamine",
"ref_book_lembke_dopamine_nation"
],
"evidence_strength": "expert_opinion",
"source_doc": "huberman-protocols.md"
},
{
"id": "reward_prediction_relabeling",
"category": "motivation",
"category": "focus_cognition",
"title": "보상 예측 재배치",
"title_en": "Reward Prediction Relabeling",
"what": "노력 자체에 보상을 결합하는 내적 라벨링.",
@@ -335,13 +376,16 @@
"끝난 후 외적 보상 X."
],
"check": "라벨링 1회+ / 외적 보상 안 줌",
"reference_ids": ["ref_podcast_hl_39_dopamine", "ref_podcast_hl_113_dopamine_procrastination"],
"reference_ids": [
"ref_podcast_hl_39_dopamine",
"ref_podcast_hl_113_dopamine_procrastination"
],
"evidence_strength": "mechanistic",
"source_doc": "huberman-protocols.md"
},
{
"id": "dopamine_recovery_stack",
"category": "motivation",
"category": "focus_cognition",
"title": "도파민 회복 스택",
"title_en": "Dopamine Recovery Stack",
"what": "자연적 baseline 상승 도구 묶음.",
@@ -356,13 +400,15 @@
"디지털 디톡스 주 1회 24h."
],
"check": "각 구성요소 1회+",
"reference_ids": ["ref_podcast_hl_113_dopamine_procrastination"],
"reference_ids": [
"ref_podcast_hl_113_dopamine_procrastination"
],
"evidence_strength": "expert_opinion",
"source_doc": "huberman-protocols.md"
},
{
"id": "amcc_will_training",
"category": "motivation",
"category": "focus_cognition",
"title": "aMCC 의지력 훈련",
"title_en": "aMCC Will-Training",
"what": "'하기 싫지만 안전한' 일을 매일 1개 의도적 수행.",
@@ -375,13 +421,16 @@
"완료 후 'aMCC 1 rep' 라벨링."
],
"check": "오늘의 싫은 일 정의 / 수행 완료",
"reference_ids": ["ref_doi_10_1016_j_cortex_2019_09_011", "ref_doi_10_1093_braincomms_fcac163"],
"reference_ids": [
"ref_doi_10_1016_j_cortex_2019_09_011",
"ref_doi_10_1093_braincomms_fcac163"
],
"evidence_strength": "mechanistic",
"source_doc": "huberman-protocols.md"
},
{
"id": "digital_dopamine_detox",
"category": "motivation",
"category": "focus_cognition",
"title": "디지털 디톡스",
"title_en": "Digital Dopamine Detox",
"what": "고자극 컨텐츠 차단 (쇼츠/릴스/포르노/SNS 스크롤).",
@@ -395,13 +444,16 @@
"종료 후 첫 사용 5분 제한."
],
"check": "24h 차단 / 첫 사용 5분 이내",
"reference_ids": ["ref_podcast_hl_39_dopamine", "ref_book_lembke_dopamine_nation"],
"reference_ids": [
"ref_podcast_hl_39_dopamine",
"ref_book_lembke_dopamine_nation"
],
"evidence_strength": "expert_opinion",
"source_doc": "huberman-protocols.md"
},
{
"id": "three_phases_of_day",
"category": "habit",
"category": "focus_cognition",
"title": "하루 3 위상",
"title_en": "Three Phases of the Day",
"what": "신경전달물질 우세 시간대에 작업 배치.",
@@ -415,13 +467,16 @@
"Phase 3: 회상·정리·디지털 OFF."
],
"check": "가장 어려운 일 Phase 1 배치 / Phase 3 자극적 디지털 X",
"reference_ids": ["ref_podcast_hl_28_daily_tools", "ref_podcast_hl_53_habits"],
"reference_ids": [
"ref_podcast_hl_28_daily_tools",
"ref_podcast_hl_53_habits"
],
"evidence_strength": "expert_opinion",
"source_doc": "huberman-protocols.md"
},
{
"id": "task_bracketing",
"category": "habit",
"category": "focus_cognition",
"title": "시간·맥락 브래킷",
"title_en": "Task Bracketing",
"what": "새 습관을 같은 시간 + 같은 직전/직후 행동으로 고정.",
@@ -436,13 +491,16 @@
"6주간 같은 위치 유지."
],
"check": "직전 브래킷 정의 / 직후 브래킷 정의 / 오늘 같은 시각 실행",
"reference_ids": ["ref_podcast_hl_53_habits", "ref_doi_10_1146_annurev_neuro_29_051605_112851"],
"reference_ids": [
"ref_podcast_hl_53_habits",
"ref_doi_10_1146_annurev_neuro_29_051605_112851"
],
"evidence_strength": "mechanistic",
"source_doc": "huberman-protocols.md"
},
{
"id": "six_week_integration_rule",
"category": "habit",
"category": "focus_cognition",
"title": "6주 자동화 규칙",
"title_en": "6-Week Integration Rule",
"what": "'6주 동안 주 6/7'을 자동화 기준으로.",
@@ -457,13 +515,16 @@
"6주 후 자동화 자가 평가."
],
"check": "트래커 존재 / 이번 주 6/7 / 결석 후 다음 날 복귀",
"reference_ids": ["ref_podcast_hl_53_habits", "ref_doi_10_1002_ejsp_674"],
"reference_ids": [
"ref_podcast_hl_53_habits",
"ref_doi_10_1002_ejsp_674"
],
"evidence_strength": "observational",
"source_doc": "huberman-protocols.md"
},
{
"id": "limbic_friction_scoring",
"category": "habit",
"category": "focus_cognition",
"title": "마찰 점수화",
"title_en": "Limbic Friction Scoring",
"what": "각 습관에 0~10 마찰 점수.",
@@ -477,13 +538,15 @@
"평균 3↓ 2주 유지 → 자동화 진입."
],
"check": "friction 기록 / 주간 평균 확인",
"reference_ids": ["ref_podcast_hl_53_habits"],
"reference_ids": [
"ref_podcast_hl_53_habits"
],
"evidence_strength": "expert_opinion",
"source_doc": "huberman-protocols.md"
},
{
"id": "new_habit_onboarding",
"category": "habit",
"category": "focus_cognition",
"title": "신규 습관 도입 규칙",
"title_en": "New Habit Onboarding",
"what": "동시 1~3개, 최소 단위로 시작.",
@@ -498,13 +561,16 @@
"6주 후 평가 → 다음 1~3개."
],
"check": "현재 신규 ≤ 3개 / 각 습관 최소 단위 정의",
"reference_ids": ["ref_podcast_hl_53_habits", "ref_book_fogg_tiny_habits"],
"reference_ids": [
"ref_podcast_hl_53_habits",
"ref_book_fogg_tiny_habits"
],
"evidence_strength": "expert_opinion",
"source_doc": "huberman-protocols.md"
},
{
"id": "habit_breaking_via_replacement",
"category": "habit",
"category": "focus_cognition",
"title": "대체 행동으로 끊기",
"title_en": "Habit Breaking via Replacement",
"what": "트리거 직후 호환 불가능한 대체 행동 삽입.",
@@ -518,13 +584,16 @@
"6주 평가."
],
"check": "트리거 식별 / 대체 행동 정의 / 오늘 1회+ 성공",
"reference_ids": ["ref_podcast_hl_53_habits", "ref_doi_10_1037_0033_295X_114_4_843"],
"reference_ids": [
"ref_podcast_hl_53_habits",
"ref_doi_10_1037_0033_295X_114_4_843"
],
"evidence_strength": "mechanistic",
"source_doc": "huberman-protocols.md"
},
{
"id": "ultradian_focus_block",
"category": "learning",
"category": "focus_cognition",
"title": "90분 Ultradian 집중 블록",
"title_en": "90-min Ultradian Focus Block",
"what": "90분 deep work + 10~20분 휴식.",
@@ -539,13 +608,17 @@
"종료 후 10~20분 NSDR 또는 산책. SNS X."
],
"check": "진입 의식 / 단일 과제 / 휴식이 도파민 자극 아님",
"reference_ids": ["ref_podcast_hl_8_learning", "ref_podcast_hl_88_focus", "ref_doi_10_1093_sleep_5_4_311"],
"reference_ids": [
"ref_podcast_hl_8_learning",
"ref_podcast_hl_88_focus",
"ref_doi_10_1093_sleep_5_4_311"
],
"evidence_strength": "mechanistic",
"source_doc": "huberman-protocols.md"
},
{
"id": "visual_focus_priming",
"category": "learning",
"category": "focus_cognition",
"title": "시각 집중 점화",
"title_en": "Visual Focus Priming",
"what": "한 지점 응시로 전두엽 집중 회로 활성.",
@@ -560,13 +633,17 @@
],
"check": "30초 이상 응시 후 진입",
"caution": "narrow-aperture LC 활성은 Huberman 통합 모델 — 근거 ⚠️.",
"reference_ids": ["ref_podcast_hl_6_focus_brain", "ref_podcast_hl_88_focus", "ref_doi_10_1146_annurev_neuro_28_061604_135709"],
"reference_ids": [
"ref_podcast_hl_6_focus_brain",
"ref_podcast_hl_88_focus",
"ref_doi_10_1146_annurev_neuro_28_061604_135709"
],
"evidence_strength": "mechanistic",
"source_doc": "huberman-protocols.md"
},
{
"id": "post_learning_nsdr",
"category": "learning",
"category": "recovery_stress",
"title": "학습 직후 NSDR",
"title_en": "Post-Learning NSDR",
"what": "학습 직후 10분 NSDR.",
@@ -579,13 +656,16 @@
"종료 후 5분 메모로 재진술."
],
"check": "학습 직후 SNS 안 봄 / NSDR 10분 / 메모 재진술",
"reference_ids": ["ref_podcast_hl_8_learning", "ref_doi_10_1177_0956797612441220"],
"reference_ids": [
"ref_podcast_hl_8_learning",
"ref_doi_10_1177_0956797612441220"
],
"evidence_strength": "strong_rct",
"source_doc": "huberman-protocols.md"
},
{
"id": "protein_first",
"category": "diet",
"category": "nutrition",
"title": "단백질 우선",
"title_en": "Protein-First",
"what": "첫 식사 + 일일 총량을 단백질 기준으로 잡는다.",
@@ -600,13 +680,17 @@
],
"check": "첫 식사 단백질 ≥ 30g",
"min_dose_for_start": "첫 끼 단백질 +10g",
"reference_ids": ["ref_doi_10_1139_apnm_2015_0550", "ref_doi_10_1136_bjsports_2017_097608", "ref_doi_10_3945_ajcn_114_084038"],
"reference_ids": [
"ref_doi_10_1139_apnm_2015_0550",
"ref_doi_10_1136_bjsports_2017_097608",
"ref_doi_10_3945_ajcn_114_084038"
],
"evidence_strength": "meta_analysis",
"source_doc": "diet-protocols.md"
},
{
"id": "refined_sugar_minimize",
"category": "diet",
"category": "nutrition",
"title": "정제당·액상 과당 최소화",
"title_en": "Minimize Refined / Liquid Sugar",
"what": "첨가당과 액상 과당(과일 주스 포함) 우선 줄임. 통과일·통곡물은 별개.",
@@ -620,13 +704,17 @@
"라벨 'added sugar' 확인 (가공식품 1주일 1회 인벤토리)."
],
"check": "오늘 액상 과당 0",
"reference_ids": ["ref_url_who_sugar_2015", "ref_doi_10_1002_oby_21371", "ref_doi_10_1038_sj_ijo_0801229"],
"reference_ids": [
"ref_url_who_sugar_2015",
"ref_doi_10_1002_oby_21371",
"ref_doi_10_1038_sj_ijo_0801229"
],
"evidence_strength": "meta_analysis",
"source_doc": "diet-protocols.md"
},
{
"id": "fiber_intake",
"category": "diet",
"category": "nutrition",
"title": "식이섬유",
"title_en": "Fiber Intake",
"what": "통곡물·콩류·채소·통과일에서 일일 25~38g.",
@@ -639,13 +727,17 @@
"갑자기 늘리면 가스/팽만 → 2~3주 점진 증가."
],
"check": "오늘 채소 ≥ 3 종류",
"reference_ids": ["ref_doi_10_1016_S0140_6736_18_31809_9", "ref_doi_10_1016_j_cell_2021_06_019", "ref_doi_10_1038_s41579_019_0191_8"],
"reference_ids": [
"ref_doi_10_1016_S0140_6736_18_31809_9",
"ref_doi_10_1016_j_cell_2021_06_019",
"ref_doi_10_1038_s41579_019_0191_8"
],
"evidence_strength": "meta_analysis",
"source_doc": "diet-protocols.md"
},
{
"id": "water_electrolytes",
"category": "diet",
"category": "nutrition",
"title": "수분·전해질",
"title_en": "Water & Electrolytes",
"what": "기상 후 물 500ml + 소금 한 꼬집. 일일 총 수분 체중 × 30~35 ml.",
@@ -667,7 +759,7 @@
},
{
"id": "meal_timing_tre",
"category": "diet",
"category": "nutrition",
"title": "식사 시점 / 시간 제한",
"title_en": "Meal Timing / TRE",
"what": "마지막 식사를 취침 2~3시간 전 종료. (선택) 16:8 등 TRE.",
@@ -680,13 +772,16 @@
"TRE 시작 시 14:10 → 16:8 점진."
],
"check": "마지막 식사 취침 2~3h 전 종료",
"reference_ids": ["ref_doi_10_1016_j_cmet_2020_06_018", "ref_doi_10_1038_ijo_2012_229"],
"reference_ids": [
"ref_doi_10_1016_j_cmet_2020_06_018",
"ref_doi_10_1038_ijo_2012_229"
],
"evidence_strength": "strong_rct",
"source_doc": "diet-protocols.md"
},
{
"id": "omega3",
"category": "diet",
"category": "nutrition",
"title": "Omega-3 (EPA/DHA)",
"title_en": "Omega-3",
"what": "지방 생선 주 2~3회 또는 EPA 보충 1~2g/일.",
@@ -699,7 +794,9 @@
"항응고제 복용 시 의사 상담."
],
"check": "주 단위 weekly reflection",
"reference_ids": ["ref_doi_10_1016_j_mayocp_2020_08_034"],
"reference_ids": [
"ref_doi_10_1016_j_mayocp_2020_08_034"
],
"evidence_strength": "meta_analysis",
"source_doc": "diet-protocols.md"
}

View 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,
};
}
}

View 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();
}
}
}

View 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;
}

View 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,
});
}

View 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
}
}

View 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);
}

View 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;
}

View 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;
}
}

View File

@@ -3,6 +3,7 @@ import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter_gemma/flutter_gemma.dart';
import '../../ai/tools/tool_definition.dart' as tools;
import 'llm_service.dart';
/// 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

View File

@@ -1,3 +1,5 @@
import '../../ai/tools/tool_definition.dart';
/// Abstract LLM backend.
///
/// Concrete impls: `GemmaLlmService` (flutter_gemma) and `MockLlmService` (tests).
@@ -7,6 +9,7 @@
/// - [generateStructured] returns a parsed JSON map matching the schema.
/// On schema/parse failure throws [FormatException].
/// - [unload] is idempotent.
/// - [startChat] opens a multi-turn chat session for tool calling (#260).
abstract class LlmService {
bool get isLoaded;
@@ -20,6 +23,45 @@ abstract class LlmService {
String prompt,
Map<String, dynamic> schema,
);
/// Opens a chat session that supports multi-turn user input + tool result
/// submission with the supplied [tools]. See ADR-0005.
Future<LlmChatSession> startChat({
required List<ToolDefinition> tools,
});
}
/// Streaming chat session for the tool-calling loop.
///
/// Lifecycle: created by [LlmService.startChat], lives for a single chat
/// screen, must be [close]d when the user dismisses the screen. Each
/// `send*` call returns a stream of [LlmChatEvent]s until the model yields
/// control (text done or a function call requested).
abstract class LlmChatSession {
Stream<LlmChatEvent> sendUser(String text);
Stream<LlmChatEvent> sendToolResult({
required String toolName,
required Map<String, dynamic> result,
});
Future<void> close();
}
/// Events emitted by [LlmChatSession]. See ADR-0005 §C.
sealed class LlmChatEvent {
const LlmChatEvent();
}
final class LlmTextChunk extends LlmChatEvent {
final String text;
const LlmTextChunk(this.text);
}
final class LlmFunctionCall extends LlmChatEvent {
final String name;
final Map<String, dynamic> args;
const LlmFunctionCall(this.name, this.args);
}
/// Programmable stub for tests. Use [enqueueResponse] / [enqueueError].
@@ -31,6 +73,12 @@ class MockLlmService implements LlmService {
Map<String, dynamic>? lastSchema;
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
bool get isLoaded => _loaded;
@@ -52,6 +100,12 @@ class MockLlmService implements LlmService {
_queue.add(_Response.error(error));
}
/// Enqueue one batch of events that will be emitted on the next
/// `sendUser` or `sendToolResult` call. Items are streamed in order.
void enqueueChatEvents(List<LlmChatEvent> events) {
chatScript.add(events);
}
@override
Future<Map<String, dynamic>> generateStructured(
String prompt,
@@ -73,6 +127,61 @@ class MockLlmService implements LlmService {
if (r.error != null) throw r.error!;
return r.value!;
}
@override
Future<LlmChatSession> startChat({
required List<ToolDefinition> tools,
}) async {
if (!_loaded) {
throw StateError('LlmService not loaded');
}
chatStartCount += 1;
final session = MockLlmChatSession(chatScript);
lastChat = session;
return session;
}
}
/// Mock chat session that replays pre-queued events from [MockLlmService].
class MockLlmChatSession implements LlmChatSession {
final List<List<LlmChatEvent>> _script;
int sendCount = 0;
final List<String> userInputs = [];
final List<(String, Map<String, dynamic>)> toolResults = [];
bool closed = false;
MockLlmChatSession(this._script);
Stream<LlmChatEvent> _emitNext() async* {
sendCount += 1;
if (_script.isEmpty) {
throw StateError('MockLlmChatSession: no queued events');
}
final batch = _script.removeAt(0);
for (final ev in batch) {
yield ev;
}
}
@override
Stream<LlmChatEvent> sendUser(String text) {
userInputs.add(text);
return _emitNext();
}
@override
Stream<LlmChatEvent> sendToolResult({
required String toolName,
required Map<String, dynamic> result,
}) {
toolResults.add((toolName, result));
return _emitNext();
}
@override
Future<void> close() async {
closed = true;
}
}
class _Response {

View 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;
}

View File

@@ -5,6 +5,7 @@ import 'package:drift/native.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import '../../core/constants.dart';
import 'tables/catalog_tables.dart';
import 'tables/user_tables.dart';
@@ -42,7 +43,7 @@ class AppDatabase extends _$AppDatabase {
AppDatabase.memory() : super(NativeDatabase.memory());
@override
int get schemaVersion => 1;
int get schemaVersion => 2;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -51,8 +52,16 @@ class AppDatabase extends _$AppDatabase {
await _createIndexes(m);
},
onUpgrade: (m, from, to) async {
// Phase 1 only has v1. Reaching here is a bug.
assert(false, 'Phase 1 has no upgrade path. from=$from to=$to');
// v1 → v2 (#226): Protocols.category CHECK 6 → 7 신 카테고리.
// Read-only catalog 의 첫 마이그레이션. DROP + reseed 패턴 (ADR-0004).
// user 테이블 (Habits, Phases, ...) 무변화.
if (from == 1 && to >= 2) {
await migrateV1ToV2(m, this);
}
if (from > to || to > schemaVersion) {
assert(false,
'Unknown upgrade path: from=$from to=$to (schemaVersion=$schemaVersion)');
}
},
);
@@ -142,3 +151,18 @@ Future<File> appDatabaseFile() async {
final dir = await getApplicationDocumentsDirectory();
return File(p.join(dir.path, 'life_helper.sqlite'));
}
/// v1 → v2 마이그레이션. fn-migration_v1_to_v2.md 참고.
///
/// - Protocols 테이블 DROP + CREATE (v2 CHECK 적용) + 인덱스 재생성.
/// - `kSeededV1Flag` 클리어 → 다음 부팅이 SeedImporter.importIfNeeded() 호출 → 새 JSON 재시드.
/// - user 테이블 (Habits, Phases, TrackerEntries, ...) 무변화.
///
/// `onUpgrade` 에서 dispatch. 테스트는 직접 호출.
Future<void> migrateV1ToV2(Migrator m, AppDatabase db) async {
await m.deleteTable(db.protocols.actualTableName);
await m.createTable(db.protocols);
await m.createIndex(Index('IDX_protocols_category',
'CREATE INDEX IDX_protocols_category ON protocols(category)'));
await (db.delete(db.metaKv)..where((t) => t.key.equals(kSeededV1Flag))).go();
}

View File

@@ -27,7 +27,8 @@ class $ProtocolsTable extends Protocols
aliasedName,
false,
check: () => const CustomExpression<bool>(
"category IN ('health','meditation','motivation','habit','learning','diet')",
"category IN ('light_circadian','sleep','movement','nutrition',"
"'focus_cognition','recovery_stress','emotion_relationship')",
),
type: DriftSqlType.string,
requiredDuringInsert: true,
@@ -15402,7 +15403,7 @@ final class $$UsersTableReferences
_$AppDatabase db,
) => MultiTypedResultKey.fromTable(
db.phases,
aliasName: $_aliasNameGenerator(db.users.id, db.phases.userId),
aliasName: 'users__id__phases__user_id',
);
$$PhasesTableProcessedTableManager get phasesRefs {
@@ -15421,7 +15422,7 @@ final class $$UsersTableReferences
_$AppDatabase db,
) => MultiTypedResultKey.fromTable(
db.habits,
aliasName: $_aliasNameGenerator(db.users.id, db.habits.userId),
aliasName: 'users__id__habits__user_id',
);
$$HabitsTableProcessedTableManager get habitsRefs {
@@ -15439,7 +15440,7 @@ final class $$UsersTableReferences
static MultiTypedResultKey<$ReflectionsTable, List<Reflection>>
_reflectionsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable(
db.reflections,
aliasName: $_aliasNameGenerator(db.users.id, db.reflections.userId),
aliasName: 'users__id__reflections__user_id',
);
$$ReflectionsTableProcessedTableManager get reflectionsRefs {
@@ -15909,7 +15910,7 @@ final class $$PhasesTableReferences
$$PhasesTableReferences(super.$_db, super.$_table, super.$_typedResult);
static $UsersTable _userIdTable(_$AppDatabase db) =>
db.users.createAlias($_aliasNameGenerator(db.phases.userId, db.users.id));
db.users.createAlias('phases__user_id__users__id');
$$UsersTableProcessedTableManager get userId {
final $_column = $_itemColumn<String>('user_id')!;
@@ -15929,7 +15930,7 @@ final class $$PhasesTableReferences
_$AppDatabase db,
) => MultiTypedResultKey.fromTable(
db.habits,
aliasName: $_aliasNameGenerator(db.phases.id, db.habits.phaseId),
aliasName: 'phases__id__habits__phase_id',
);
$$HabitsTableProcessedTableManager get habitsRefs {
@@ -15948,10 +15949,7 @@ final class $$PhasesTableReferences
_rewardDeclarationsRefsTable(_$AppDatabase db) =>
MultiTypedResultKey.fromTable(
db.rewardDeclarations,
aliasName: $_aliasNameGenerator(
db.phases.id,
db.rewardDeclarations.phaseId,
),
aliasName: 'phases__id__reward_declarations__phase_id',
);
$$RewardDeclarationsTableProcessedTableManager get rewardDeclarationsRefs {
@@ -15971,7 +15969,7 @@ final class $$PhasesTableReferences
static MultiTypedResultKey<$ReflectionsTable, List<Reflection>>
_reflectionsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable(
db.reflections,
aliasName: $_aliasNameGenerator(db.phases.id, db.reflections.phaseId),
aliasName: 'phases__id__reflections__phase_id',
);
$$ReflectionsTableProcessedTableManager get reflectionsRefs {
@@ -16618,7 +16616,7 @@ final class $$HabitsTableReferences
$$HabitsTableReferences(super.$_db, super.$_table, super.$_typedResult);
static $UsersTable _userIdTable(_$AppDatabase db) =>
db.users.createAlias($_aliasNameGenerator(db.habits.userId, db.users.id));
db.users.createAlias('habits__user_id__users__id');
$$UsersTableProcessedTableManager get userId {
final $_column = $_itemColumn<String>('user_id')!;
@@ -16634,9 +16632,8 @@ final class $$HabitsTableReferences
);
}
static $PhasesTable _phaseIdTable(_$AppDatabase db) => db.phases.createAlias(
$_aliasNameGenerator(db.habits.phaseId, db.phases.id),
);
static $PhasesTable _phaseIdTable(_$AppDatabase db) =>
db.phases.createAlias('habits__phase_id__phases__id');
$$PhasesTableProcessedTableManager? get phaseId {
final $_column = $_itemColumn<String>('phase_id');
@@ -16656,10 +16653,7 @@ final class $$HabitsTableReferences
_habitDoseVariantsRefsTable(_$AppDatabase db) =>
MultiTypedResultKey.fromTable(
db.habitDoseVariants,
aliasName: $_aliasNameGenerator(
db.habits.id,
db.habitDoseVariants.habitId,
),
aliasName: 'habits__id__habit_dose_variants__habit_id',
);
$$HabitDoseVariantsTableProcessedTableManager get habitDoseVariantsRefs {
@@ -16679,7 +16673,7 @@ final class $$HabitsTableReferences
static MultiTypedResultKey<$IfThenRulesTable, List<IfThenRule>>
_ifThenRulesRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable(
db.ifThenRules,
aliasName: $_aliasNameGenerator(db.habits.id, db.ifThenRules.habitId),
aliasName: 'habits__id__if_then_rules__habit_id',
);
$$IfThenRulesTableProcessedTableManager get ifThenRulesRefs {
@@ -16697,7 +16691,7 @@ final class $$HabitsTableReferences
static MultiTypedResultKey<$TrackerEntriesTable, List<TrackerEntry>>
_trackerEntriesRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable(
db.trackerEntries,
aliasName: $_aliasNameGenerator(db.habits.id, db.trackerEntries.habitId),
aliasName: 'habits__id__tracker_entries__habit_id',
);
$$TrackerEntriesTableProcessedTableManager get trackerEntriesRefs {
@@ -16715,7 +16709,7 @@ final class $$HabitsTableReferences
static MultiTypedResultKey<$LapseLogsTable, List<LapseLog>>
_lapseLogsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable(
db.lapseLogs,
aliasName: $_aliasNameGenerator(db.habits.id, db.lapseLogs.habitId),
aliasName: 'habits__id__lapse_logs__habit_id',
);
$$LapseLogsTableProcessedTableManager get lapseLogsRefs {
@@ -16734,7 +16728,7 @@ final class $$HabitsTableReferences
_$AppDatabase db,
) => MultiTypedResultKey.fromTable(
db.urgeLogs,
aliasName: $_aliasNameGenerator(db.habits.id, db.urgeLogs.habitId),
aliasName: 'habits__id__urge_logs__habit_id',
);
$$UrgeLogsTableProcessedTableManager get urgeLogsRefs {
@@ -16753,10 +16747,7 @@ final class $$HabitsTableReferences
_rewardDeclarationsRefsTable(_$AppDatabase db) =>
MultiTypedResultKey.fromTable(
db.rewardDeclarations,
aliasName: $_aliasNameGenerator(
db.habits.id,
db.rewardDeclarations.habitId,
),
aliasName: 'habits__id__reward_declarations__habit_id',
);
$$RewardDeclarationsTableProcessedTableManager get rewardDeclarationsRefs {
@@ -17909,9 +17900,8 @@ final class $$HabitDoseVariantsTableReferences
super.$_typedResult,
);
static $HabitsTable _habitIdTable(_$AppDatabase db) => db.habits.createAlias(
$_aliasNameGenerator(db.habitDoseVariants.habitId, db.habits.id),
);
static $HabitsTable _habitIdTable(_$AppDatabase db) =>
db.habits.createAlias('habit_dose_variants__habit_id__habits__id');
$$HabitsTableProcessedTableManager get habitId {
final $_column = $_itemColumn<String>('habit_id')!;
@@ -17930,10 +17920,7 @@ final class $$HabitDoseVariantsTableReferences
static MultiTypedResultKey<$TrackerEntriesTable, List<TrackerEntry>>
_trackerEntriesRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable(
db.trackerEntries,
aliasName: $_aliasNameGenerator(
db.habitDoseVariants.variantId,
db.trackerEntries.variantId,
),
aliasName: 'habit_dose_variants__variant_id__tracker_entries__variant_id',
);
$$TrackerEntriesTableProcessedTableManager get trackerEntriesRefs {
@@ -18391,9 +18378,8 @@ final class $$IfThenRulesTableReferences
extends BaseReferences<_$AppDatabase, $IfThenRulesTable, IfThenRule> {
$$IfThenRulesTableReferences(super.$_db, super.$_table, super.$_typedResult);
static $HabitsTable _habitIdTable(_$AppDatabase db) => db.habits.createAlias(
$_aliasNameGenerator(db.ifThenRules.habitId, db.habits.id),
);
static $HabitsTable _habitIdTable(_$AppDatabase db) =>
db.habits.createAlias('if_then_rules__habit_id__habits__id');
$$HabitsTableProcessedTableManager get habitId {
final $_column = $_itemColumn<String>('habit_id')!;
@@ -18761,9 +18747,8 @@ final class $$TrackerEntriesTableReferences
super.$_typedResult,
);
static $HabitsTable _habitIdTable(_$AppDatabase db) => db.habits.createAlias(
$_aliasNameGenerator(db.trackerEntries.habitId, db.habits.id),
);
static $HabitsTable _habitIdTable(_$AppDatabase db) =>
db.habits.createAlias('tracker_entries__habit_id__habits__id');
$$HabitsTableProcessedTableManager get habitId {
final $_column = $_itemColumn<String>('habit_id')!;
@@ -18781,10 +18766,7 @@ final class $$TrackerEntriesTableReferences
static $HabitDoseVariantsTable _variantIdTable(_$AppDatabase db) =>
db.habitDoseVariants.createAlias(
$_aliasNameGenerator(
db.trackerEntries.variantId,
db.habitDoseVariants.variantId,
),
'tracker_entries__variant_id__habit_dose_variants__variant_id',
);
$$HabitDoseVariantsTableProcessedTableManager? get variantId {
@@ -19255,9 +19237,8 @@ final class $$LapseLogsTableReferences
extends BaseReferences<_$AppDatabase, $LapseLogsTable, LapseLog> {
$$LapseLogsTableReferences(super.$_db, super.$_table, super.$_typedResult);
static $HabitsTable _habitIdTable(_$AppDatabase db) => db.habits.createAlias(
$_aliasNameGenerator(db.lapseLogs.habitId, db.habits.id),
);
static $HabitsTable _habitIdTable(_$AppDatabase db) =>
db.habits.createAlias('lapse_logs__habit_id__habits__id');
$$HabitsTableProcessedTableManager get habitId {
final $_column = $_itemColumn<String>('habit_id')!;
@@ -19655,9 +19636,8 @@ final class $$UrgeLogsTableReferences
extends BaseReferences<_$AppDatabase, $UrgeLogsTable, UrgeLog> {
$$UrgeLogsTableReferences(super.$_db, super.$_table, super.$_typedResult);
static $HabitsTable _habitIdTable(_$AppDatabase db) => db.habits.createAlias(
$_aliasNameGenerator(db.urgeLogs.habitId, db.habits.id),
);
static $HabitsTable _habitIdTable(_$AppDatabase db) =>
db.habits.createAlias('urge_logs__habit_id__habits__id');
$$HabitsTableProcessedTableManager get habitId {
final $_column = $_itemColumn<String>('habit_id')!;
@@ -20074,9 +20054,8 @@ final class $$RewardDeclarationsTableReferences
super.$_typedResult,
);
static $PhasesTable _phaseIdTable(_$AppDatabase db) => db.phases.createAlias(
$_aliasNameGenerator(db.rewardDeclarations.phaseId, db.phases.id),
);
static $PhasesTable _phaseIdTable(_$AppDatabase db) =>
db.phases.createAlias('reward_declarations__phase_id__phases__id');
$$PhasesTableProcessedTableManager get phaseId {
final $_column = $_itemColumn<String>('phase_id')!;
@@ -20092,9 +20071,8 @@ final class $$RewardDeclarationsTableReferences
);
}
static $HabitsTable _habitIdTable(_$AppDatabase db) => db.habits.createAlias(
$_aliasNameGenerator(db.rewardDeclarations.habitId, db.habits.id),
);
static $HabitsTable _habitIdTable(_$AppDatabase db) =>
db.habits.createAlias('reward_declarations__habit_id__habits__id');
$$HabitsTableProcessedTableManager get habitId {
final $_column = $_itemColumn<String>('habit_id')!;
@@ -20113,10 +20091,7 @@ final class $$RewardDeclarationsTableReferences
static MultiTypedResultKey<$RewardClaimsTable, List<RewardClaim>>
_rewardClaimsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable(
db.rewardClaims,
aliasName: $_aliasNameGenerator(
db.rewardDeclarations.id,
db.rewardClaims.declarationId,
),
aliasName: 'reward_declarations__id__reward_claims__declaration_id',
);
$$RewardClaimsTableProcessedTableManager get rewardClaimsRefs {
@@ -20710,13 +20685,9 @@ final class $$RewardClaimsTableReferences
extends BaseReferences<_$AppDatabase, $RewardClaimsTable, RewardClaim> {
$$RewardClaimsTableReferences(super.$_db, super.$_table, super.$_typedResult);
static $RewardDeclarationsTable _declarationIdTable(_$AppDatabase db) =>
db.rewardDeclarations.createAlias(
$_aliasNameGenerator(
db.rewardClaims.declarationId,
db.rewardDeclarations.id,
),
);
static $RewardDeclarationsTable _declarationIdTable(_$AppDatabase db) => db
.rewardDeclarations
.createAlias('reward_claims__declaration_id__reward_declarations__id');
$$RewardDeclarationsTableProcessedTableManager get declarationId {
final $_column = $_itemColumn<String>('declaration_id')!;
@@ -21070,9 +21041,8 @@ final class $$ReflectionsTableReferences
extends BaseReferences<_$AppDatabase, $ReflectionsTable, Reflection> {
$$ReflectionsTableReferences(super.$_db, super.$_table, super.$_typedResult);
static $UsersTable _userIdTable(_$AppDatabase db) => db.users.createAlias(
$_aliasNameGenerator(db.reflections.userId, db.users.id),
);
static $UsersTable _userIdTable(_$AppDatabase db) =>
db.users.createAlias('reflections__user_id__users__id');
$$UsersTableProcessedTableManager get userId {
final $_column = $_itemColumn<String>('user_id')!;
@@ -21088,9 +21058,8 @@ final class $$ReflectionsTableReferences
);
}
static $PhasesTable _phaseIdTable(_$AppDatabase db) => db.phases.createAlias(
$_aliasNameGenerator(db.reflections.phaseId, db.phases.id),
);
static $PhasesTable _phaseIdTable(_$AppDatabase db) =>
db.phases.createAlias('reflections__phase_id__phases__id');
$$PhasesTableProcessedTableManager? get phaseId {
final $_column = $_itemColumn<String>('phase_id');

View File

@@ -9,4 +9,21 @@ mixin _$HabitDaoMixin on DatabaseAccessor<AppDatabase> {
$HabitsTable get habits => attachedDatabase.habits;
$HabitDoseVariantsTable get habitDoseVariants =>
attachedDatabase.habitDoseVariants;
HabitDaoManager get managers => HabitDaoManager(this);
}
class HabitDaoManager {
final _$HabitDaoMixin _db;
HabitDaoManager(this._db);
$$UsersTableTableManager get users =>
$$UsersTableTableManager(_db.attachedDatabase, _db.users);
$$PhasesTableTableManager get phases =>
$$PhasesTableTableManager(_db.attachedDatabase, _db.phases);
$$HabitsTableTableManager get habits =>
$$HabitsTableTableManager(_db.attachedDatabase, _db.habits);
$$HabitDoseVariantsTableTableManager get habitDoseVariants =>
$$HabitDoseVariantsTableTableManager(
_db.attachedDatabase,
_db.habitDoseVariants,
);
}

View File

@@ -5,4 +5,12 @@ part of 'meta_dao.dart';
// ignore_for_file: type=lint
mixin _$MetaDaoMixin on DatabaseAccessor<AppDatabase> {
$MetaKvTable get metaKv => attachedDatabase.metaKv;
MetaDaoManager get managers => MetaDaoManager(this);
}
class MetaDaoManager {
final _$MetaDaoMixin _db;
MetaDaoManager(this._db);
$$MetaKvTableTableManager get metaKv =>
$$MetaKvTableTableManager(_db.attachedDatabase, _db.metaKv);
}

View File

@@ -10,4 +10,26 @@ mixin _$TrackerDaoMixin on DatabaseAccessor<AppDatabase> {
$HabitDoseVariantsTable get habitDoseVariants =>
attachedDatabase.habitDoseVariants;
$TrackerEntriesTable get trackerEntries => attachedDatabase.trackerEntries;
TrackerDaoManager get managers => TrackerDaoManager(this);
}
class TrackerDaoManager {
final _$TrackerDaoMixin _db;
TrackerDaoManager(this._db);
$$UsersTableTableManager get users =>
$$UsersTableTableManager(_db.attachedDatabase, _db.users);
$$PhasesTableTableManager get phases =>
$$PhasesTableTableManager(_db.attachedDatabase, _db.phases);
$$HabitsTableTableManager get habits =>
$$HabitsTableTableManager(_db.attachedDatabase, _db.habits);
$$HabitDoseVariantsTableTableManager get habitDoseVariants =>
$$HabitDoseVariantsTableTableManager(
_db.attachedDatabase,
_db.habitDoseVariants,
);
$$TrackerEntriesTableTableManager get trackerEntries =>
$$TrackerEntriesTableTableManager(
_db.attachedDatabase,
_db.trackerEntries,
);
}

View File

@@ -6,7 +6,8 @@ import 'package:drift/drift.dart';
class Protocols extends Table {
TextColumn get id => text()();
TextColumn get category => text().check(const CustomExpression<bool>(
"category IN ('health','meditation','motivation','habit','learning','diet')"))();
"category IN ('light_circadian','sleep','movement','nutrition',"
"'focus_cognition','recovery_stress','emotion_relationship')"))();
TextColumn get title => text()();
TextColumn get titleEn => text().nullable()();
TextColumn get what => text()();

View 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;
}

View 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;
}
}

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'ai/tools/tool_definition.dart' as tools;
import 'data/ai/gemma_llm_service.dart';
import 'data/ai/llm_service.dart';
import 'data/ai/model_lifecycle.dart';
@@ -77,6 +78,12 @@ class _LazyLlmService implements LlmService {
Map<String, dynamic> schema,
) async =>
(await _resolve()).generateStructured(prompt, schema);
@override
Future<LlmChatSession> startChat({
required List<tools.ToolDefinition> tools,
}) async =>
(await _resolve()).startChat(tools: tools);
}
class LifeHelperApp extends StatelessWidget {

View 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);
});

View 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,
);
});

View 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 '취소됨';
}
}
}

View File

@@ -1,9 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../state/ai_providers.dart';
import '../../state/providers.dart';
import 'chat_screen.dart';
import 'check_in_screen.dart';
import 'habit_create_screen.dart';
import 'protocol_gallery_screen.dart';
import 'settings_screen.dart';
import 'streak_screen.dart';
@@ -14,11 +17,27 @@ class HabitListScreen extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final boot = ref.watch(bootstrapProvider);
final habitsAsync = ref.watch(activeHabitsProvider);
final aiOptIn = ref.watch(aiSettingsProvider).valueOrNull ?? false;
return Scaffold(
appBar: AppBar(
title: const Text('습관'),
actions: [
if (aiOptIn)
IconButton(
icon: const Icon(Icons.smart_toy_outlined),
tooltip: 'AI 코치',
onPressed: () {
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => const ChatScreen(),
));
},
),
IconButton(
icon: const Icon(Icons.search),
tooltip: '카탈로그 탐색',
onPressed: () => _openGallery(context),
),
IconButton(
icon: const Icon(Icons.settings),
tooltip: '설정',
@@ -38,8 +57,25 @@ class HabitListScreen extends ConsumerWidget {
error: (e, st) => Center(child: Text('로드 실패: $e')),
data: (habits) {
if (habits.isEmpty) {
return const Center(
child: Text('아직 습관이 없습니다. + 버튼으로 추가하세요.'),
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Padding(
padding: EdgeInsets.symmetric(horizontal: 24),
child: Text(
'아직 습관이 없습니다.\n+ 버튼으로 추가하거나, 카탈로그에서 골라보세요.',
textAlign: TextAlign.center,
),
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: () => _openGallery(context),
icon: const Icon(Icons.search),
label: const Text('🔍 카탈로그 탐색'),
),
],
),
);
}
return ListView.separated(
@@ -83,4 +119,10 @@ class HabitListScreen extends ConsumerWidget {
),
);
}
void _openGallery(BuildContext context) {
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => const ProtocolGalleryScreen(),
));
}
}

View 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]),
),
),
),
),
),
],
);
},
),
);
}
}

View 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('내 습관으로 (다음 업데이트 예정)'),
),
),
),
);
}
}

View 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),
),
);
}
}

View 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),
),
),
],
),
);
}
}

View 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),
);
}
}

View File

@@ -1,7 +1,7 @@
name: life_helper
description: "Huberman + Atomic Habits + Tiny Habits + If-Then. Local-first habit/checklist/todo."
publish_to: 'none'
version: 0.3.0+3
version: 0.4.0+4
environment:
sdk: ^3.12.2

View 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,
),
);
}

View 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');
});
});
}

View 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);
});
});
}

View 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');
});
});
}

View 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),
);
});
});
}

View 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);
});
});
}

View 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);
});
}

View 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);
});
});
}

View File

@@ -7,7 +7,7 @@ const _protocols = '''
[
{
"id": "morning_sunlight",
"category": "health",
"category": "light_circadian",
"title": "아침 햇빛",
"what": "기상 후 햇빛.",
"when": "기상 후 30~60분.",

View 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');
}

View 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');
});
}

View 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');
});
}

View 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);
},
);
}

View 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);
});
}

View 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);
});
}

View File

@@ -78,7 +78,7 @@ docs/
### 레퍼런스 (`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/`)

View File

@@ -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+ 에서 같은 결정 다시 해야 함 — 일찍 답하는 게 싸다.

View 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

View File

@@ -1,9 +1,24 @@
# 설계서: OQ-1 — 실 Gemma 통합 (#218)
> **상태**: Draft
> **상태**: Approved (2026-06-12 — Reviewer 1b90f58, Release v0.3.0)
> **작성**: [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/model_lifecycle.dart` (`ModelLifecycle.purge` F2 hardening + load 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)
> 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` 성공.
- [ ] **AC-2**: 사용자 "AI 도움" 토글 ON 시 동의 다이얼로그 (#215 그대로) → 백그라운드 다운로드가 **example.invalid 가 아닌 실 HF endpoint** 로 향한다.
- [ ] **AC-3**: 다운로드 일시정지/재개/취소 동작이 실 HF URL 에 대해서도 보존 (HTTP Range 응답 검증). 강제 종료 후 resume 정상.
- [ ] **AC-4**: 다운로드 완료 후 SHA-256 검증이 실 모델 파일에 대해 통과 + `meta_kv['ai_model_path']` 에 절대 경로 저장.
- [ ] **AC-5**: 다운로드 완료 후 `HabitCreateScreen` "AI 제안" 버튼 활성 (#215 UI 그대로).
- [ ] **AC-6**: 실 단말 (RAM ≥ 8GB) 에서 "술 끊고 싶어" → 후보 3개가 5초 이내 (cold start) / 2초 이내 (warm) 표시.
- [ ] **AC-7**: **실 Android 단말 E2E** — 후보`FrameCandidate.level ∈ {L2, L3}` 이고 `validateFrameLevel` 통과 ≥ 1.
- [ ] **AC-8**: opt-out 시 모델 파일 즉시 삭제 (`File.delete`) + meta_kv clear + "공간 확보됨" 토스트. F2 hardening 으로 `File.delete` 예외도 graceful.
- [ ] **AC-9**: RAM < 4GB 단말 또는 모델 로드 OOM 또는 generateStructured timeout 10s 시 빈 리스트 반환 + 수동 입력 경로 차단 없음.
- [ ] **AC-10**: 한국어 30 corpus ≥ 70% L2/L3 통과**#221 로 분리**. 본 이슈는 AC-7 만으로 close.
- [x] **AC-1**: `flutter pub add flutter_gemma:^0.16.5` 통과 + `flutter analyze` 0 issue + `flutter build apk --debug` 성공.
- [x] **AC-2**: 사용자 "AI 도움" 토글 ON 시 동의 다이얼로그 (#215 그대로) → 백그라운드 다운로드가 실 HF endpoint (`litert-community/gemma-4-E2B-it-litert-lm`) 로 향한다.
- [x] **AC-3**: 다운로드 일시정지/재개/취소 동작이 실 HF URL 에 대해서도 보존 (HTTP Range). ✅
- [x] **AC-4**: 다운로드 완료 후 SHA-256 (`181938105e...39a63c`) 검증 + `meta_kv['ai_model_path']` 저장.
- [x] **AC-5**: 다운로드 완료 후 `HabitCreateScreen` "AI 제안" 버튼 활성. ✅
- [x] **AC-6**: RAM 4GB 게이트 — `life_helper/device_caps` MethodChannel + `kAiMinRamBytes = 4 GiB`. 7 boundary unit 통과. ✅
- [ ] **AC-7**: **실 Android 단말 E2E** — 후보 ∈ {L2, L3} + `validateFrameLevel` 통과 ≥ 1. **DEFER** — 단위/통합 PASS, 실기 검증은 사용자 권고 (#218 노트에 가이드 첨부). 결과 도착 시 본 항목 갱신 + Redmine 노트 보강.
- [x] **AC-8**: opt-out 시 즉시 삭제 + meta clear + 토스트. F2 try/catch 적용. ✅
- [x] **AC-9**: RAM < 4GB / OOM / timeout 10s 시 빈 리스트 + 수동 입력 경로 보존. ✅
- [⊘] **AC-10**: 한국어 30 corpus ≥ 70% — **#221 로 분리** (out-of-scope).
## 4. 컨텍스트 & 제약

View File

@@ -1,6 +1,6 @@
# 함수 설계서: `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`
## 변경 이력 (v2, 2026-06-12 Developer 검증 후)

View 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

View 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 (본 이슈에서 발행).

View 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).

View 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 + 신규)

View 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`

View 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`

View 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)`) — 단위 테스트 용이.

View 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 들 (각 핸들러가 사용)

View File

@@ -1,6 +1,8 @@
# 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 보조**를 제공합니다. 모든 처리는 단말에서만 일어나며, 입력 텍스트는 외부로 전송되지 않습니다.

View File

@@ -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. 모듈 지도