15 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
da60dd1a5a [07-Release] #218 v0.3.0 — real Gemma 4 E2B + RAM gate
- pubspec 0.2.0+2 → 0.3.0+3
- CHANGELOG 0.3.0 — OQ-1 해결(real Gemma), AC-6 RAM 게이트, 88 tests, sticky cache fix
- APK SHA-256 4a237d5124bfcd56aaa8c0ae89060a9ecf9ce7cc739f0b056ce66e9b9ca6b54a

Refs #218
2026-06-12 16:17:24 +09:00
1b90f58585 [06-Reviewer] #218 sticky cache 수정 + reference doc nit 3건
코드 결함 1건 + 문서 정확성 nit 3건. 사용자 동작에 영향 있는 건 (1)
번만, 나머지는 문서 정정.

(1) _LazyLlmService._delegate sticky cache 수정 (main.dart)
- 기존: 첫 호출 시점에 잡힌 delegate (Mock vs Gemma) 가 앱 재시작까지
  유지 — 옵트인 OFF 상태에서 첫 suggestFrame 호출 → Mock 잡힘 → 사용자
  옵트인 ON + 다운로드 완료 후에도 같은 Mock 만 반환 (사용자는 AI 가
  켜진 줄 알고 mock 응답 받음).
- 수정: 매 _resolve() 호출마다 checkAvailability 재평가. 캐시는
  (Gemma↔Mock 종류) + (Gemma 의 modelPath) 모두 일치할 때만 재사용 →
  state 변화 시 자동 교체. flutter_gemma installModel 자체가
  idempotent 라 반복 resolve 비용 무시 가능.

(2) reference doc nit 3건 — 04-QA round 2 가 08-Documenter 로 인계한
    nit 를 Reviewer 가 직접 정정:
    - L184: "device_info_plus 로 systemFeatures / totalMem 조회" → 실
      구현은 MethodChannel `life_helper/device_caps`. device_info_plus
      는 deps 에 있지만 4GB 임계 측정엔 미사용 (isLowRamDevice 는 ~1GB).
    - L186: F1 후속 이슈 번호 "#222 등" → "#219 별도 이슈".
    - L191: follow-up 매핑 — 임의 "#219 ProGuard rules 정제" 항목 제거.
      Planner OOS 기준 #219=F1 unload, #220=F2 purge, #221=AC10 corpus,
      #222=production keystore 로 정정.

검증: flutter analyze 무이슈, flutter test 88/88 통과.

Refs #218
2026-06-12 16:09:09 +09:00
14632e11df [05-Designer] #218 UX 다듬기 — RAM 게이트 문구 + 옵트아웃 표현
Round 2 QA PASS 후 user-facing 문구 3건 정리.

1) AC-6 RAM 게이트 안내: "이 단말의 RAM 이 부족합니다 (필요: 4GB 이상)"
   → "이 단말에서는 AI 도움을 사용할 수 없어요 (RAM 4GB 이상 필요)".
   Planner spec 톤과 align. "부족합니다" (비난 어조) → "사용할 수 없어요"
   (정보 제공 톤). 안내문 끝 마침표 제거.

2) ModelAvailability.missing 메시지가 RAM 게이트 active 상태에서 "위
   토글을 켜면" 안내를 표시해 모순 발생. meetsRam=false 분기 추가 →
   "이 단말은 모델을 받을 수 없어요 (RAM 4GB 이상 필요)" 노출.

3) _confirmOptOut 보조 텍스트 "다시 켜면 다시 다운로드해야 합니다"
   → "다시 켜면 처음부터 다운로드합니다". "다시" 중복 제거 + 호흡 정리.

기능 동작 변화 0. analyze clean, 88/88 통과.

Refs #218
2026-06-12 15:58:54 +09:00
f71d132fa3 [03-Developer] #218 Dev round 2 — AC-6 RAM 4GB gate + AC-10 docs cleanup
QA round 1 (commit 9a9eb2a) FAIL 시 누락된 두 AC 보강.

AC-6: device_info_plus 만으론 4GB 임계 측정 불가 (isLowRamDevice 는
~1GB 기준). MethodChannel `life_helper/device_caps` 신설 + MainActivity.kt
에서 ActivityManager.MemoryInfo.totalMem 노출. data/ai/device_capabilities.dart
는 DeviceCapabilities abstract + PlatformDeviceCapabilities + 4 GiB
임계. deviceMeetsAiRamProvider (FutureProvider<bool>, fail-closed).
SettingsScreen 토글 disabled + "RAM 부족" 안내 (RAM < 4GB).

AC-10: docs/reference/215-ai-frame-suggest.md 의 OQ-1/placeholder
6곳을 실 구현 표현으로 갱신. §8 알려진 제약 = AC-6 device gate +
AC-7 실 단말 E2E + F1 unload + #221 corpus 평가. §9 다음 단계 =
#219~#222 follow-up 목록. 신규 테스트 합계 41 / 전체 88 통과.

테스트: device_capabilities_test.dart 7 신규 (kAiMinRamBytes 동등,
null/0/3.9GB/4GB-1/4GB/8GB 경계). flutter analyze 무이슈, 전체 88 통과
(71 기존 + 10 gemma + 7 RAM gate).

Architect 설계서 §4 의 "RAM 4GB 차단 = AC-9 재활용" 문구는 사실 #215
미구현 사항이라 본 라운드에서 신규 추가했음을 README 에 명기.

Refs #218
2026-06-12 15:45:14 +09:00
9a9eb2abd5 [Developer] #218 Real Gemma 4 E2B integration via flutter_gemma 0.16.5
Implements the OQ-1 follow-up to #215 v0.2.0: replace the placeholder
GemmaLlmService stub with a real flutter_gemma 0.16.5 backend driving
Gemma 4 E2B (litert-community/gemma-4-E2B-it-litert-lm, 2.41GB).

Highlights:
- GemmaLlmService.load → FlutterGemma.initialize + installModel.fromFile +
  getActiveModel; idempotent + FileSystemException on missing file.
- generateStructured uses Gemma 4 native function calling via
  createChat(tools: [Tool(...)], toolChoice: required). Stream parsed by
  collectFunctionCall — first FCR wins, ParallelFCR first-call wins,
  TextResponse/ThinkingResponse skipped, errors sanitized to prevent
  prompt leakage.
- main.dart wires _LazyLlmService adapter that resolves to GemmaLlmService
  when ModelLifecycle reports ready, MockLlmService otherwise.
- ai_providers.dart pins real model URL + SHA-256 (181938...39a63c).
- F2 hardening: ModelLifecycle.purge wraps each delete + meta remove in
  try/catch so a single OS-level flake cannot block opt-out.
- Android: INTERNET / FOREGROUND_SERVICE / POST_NOTIFICATIONS permissions
  + R8 proguard-rules.pro keeping MediaPipe / LiteRT / TFLite / protobuf
  JNI entry points (release builds otherwise crash on first inference).

Design-First: fn-gemma_llm_service.md updated to v2 — §C
(_appendSchemaInstruction) deprecated after reading flutter_gemma
0.16.5 source (Gemma 4 SDK injects tool declarations via template;
prompt-side append would double-wrap).

Tests:
- 10 new unit tests for collectFunctionCall covering all 8 fn-spec
  cases + 2 ParallelFunctionCallResponse paths.
- All 81 existing tests still pass.
- flutter analyze: 0 issues.

Refs #218

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-12 15:18:08 +09:00
a1f3c5f85d [Architect] #218 Real Gemma 4 + flutter_gemma 0.16.5 design spec
- docs/design/218-gemma-real-integration/README.md (Draft) — 12 섹션 + AC 10
- docs/design/218-gemma-real-integration/fn-gemma_llm_service.md (Draft) — load/generateStructured/_appendSchemaInstruction/_collectFunctionCall 4 함수 명세
- 모델: Gemma 4 E2B QAT 모바일 (HF litert-community)
- flutter_gemma 0.16.5 + ModelType.gemma4 native function calling 확인
- 신규 ADR 발행 안 함 (ADR-0003 결정 #3 유지)
- 변경 범위: gemma_llm_service.dart 본문 교체, _kModelUrl/Sha 상수 치환, main.dart 조건부 override, AndroidManifest + ProGuard
- out of scope: #219 F1 / #220 F2 광범위 / #221 AC10 / #222 keystore

Refs #218
2026-06-12 14:54:28 +09:00
ed340839a0 [Documenter] #215 Reference + guide + design Approved
- docs/reference/215-ai-frame-suggest.md — v0.2.0 모듈/함수/Riverpod/meta_kv 사양
- docs/guides/ai-help-onboarding.md — AI 도움 켜기/끄기 사용자 가이드
- docs/design/215-gemma-frame-suggest/{README,fn-suggest_frame,fn-model_lifecycle}
  상태 Draft → Approved, 추적성 헤더에 실제 구현 파일/테스트 경로 + 레퍼런스/가이드 cross-link
- docs/README.md — 현재 발행된 문서 인덱스 섹션 추가

Refs #215
2026-06-12 13:32:29 +09:00
76 changed files with 8039 additions and 305 deletions

View File

@@ -3,6 +3,79 @@
본 프로젝트의 모든 의미있는 변경은 본 파일에 기록한다.
형식: [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)
- `GemmaLlmService` 본문 구현 — `flutter_gemma` 0.16.5 위에 Gemma 4 E2B 실 추론. `InferenceModel.createChat(modelType: gemma4, supportsFunctionCalls: true, toolChoice: required, tools: [...])` + `collectFunctionCall(stream)` 로 structured JSON 강제.
- `_LazyLlmService` (main.dart) — Mock ↔ Gemma 런타임 어댑터. 매 호출마다 `checkAvailability` 재평가 → opt-in/opt-out 즉시 반영 (앱 재시작 불필요).
- 실 모델 핀: `gemma-4-E2B-it.litertlm` 2.41GB, SHA-256 `181938105e0eefd105961417e8da75903eacda102c4fce9ce90f50b97139a63c` (HF `litert-community/gemma-4-E2B-it-litert-lm`).
- HF_TOKEN `--dart-define` 주입 — 빈 기본값으로 빌드 안전.
### Added — Device gate (AC-6)
- 플랫폼 채널 `life_helper/device_caps` (`MainActivity.kt``ActivityManager.MemoryInfo.totalMem`) — Android 단말 실 RAM 측정. `device_info_plus``isLowRamDevice` (~1GB) 로는 4GB 임계치 불가하여 채널 도입.
- `DeviceCapabilities` 추상 + `PlatformDeviceCapabilities` 구현 (테스트 주입 가능). `kAiMinRamBytes = 4 GiB`. fail-closed (`null` → false).
- `deviceMeetsAiRamProvider` (Riverpod `FutureProvider`) — `SettingsScreen` 토글 disabled + 안내 문구.
### Added — Tests
- 88/88 통과 — 신규 10 (`device_capabilities_test.dart` 7 + lazy resolve regression 3).
### Polish (Designer)
- AC-6 게이트 안내 톤 정렬 — "RAM 부족" → "이 단말에서는 AI 도움을 사용할 수 없어요 (RAM 4GB 이상 필요)".
- `_describe(missing, meetsRam:)` 분기 — 토글 disabled 상황에서 "토글 켜면" 모순 제거.
- 옵트아웃 다이얼로그 "다시 다시" 중복 → "처음부터".
### Fixed (Reviewer)
- `_LazyLlmService._delegate` sticky cache — 첫 호출 시점의 delegate 종류가 앱 재시작까지 유지되던 버그 (Mock → Gemma 전환 안 됨). re-resolve + (kind + modelPath) 일치 시만 캐시 재사용.
- Reference 문서 nit 3건 — `215-ai-frame-suggest.md` (L184 채널 사실 정정 / L186 F1 follow-up 매핑 / L191 OOS 기준).
### Release artifact
- `app-release.apk` 286MB, SHA-256 `4a237d5124bfcd56aaa8c0ae89060a9ecf9ce7cc739f0b056ce66e9b9ca6b54a`.
### Known limitations (deferred to #219~#222)
- **AC-7** (실 단말 cold-start 예산) — DEFER. 실기기 E2E 검증은 본 릴리스 후 권고.
- **#219** F1: 60초 idle auto-unload.
- **#220** GemmaLlmService.load 동시성 가드 + `isThinking:false` 명시.
- **#221** AC-10 한국어 corpus ≥70%.
- **#222** HF_TOKEN keystore 기반 secret 전환.
---
## [0.2.0] — 2026-06-12
### Added — Phase 2-A: On-device Gemma 4 frame suggestion (Redmine #215)

View File

@@ -30,6 +30,13 @@ android {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
// #218: keep flutter_gemma JNI bindings — see proguard-rules.pro.
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
}

30
app/android/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,30 @@
# flutter_gemma 0.16.5 keep MediaPipe + LiteRT native bindings (#218)
# Without these the release build (R8 minify) strips JNI entry points
# and the first inference call crashes with NoSuchMethodError.
# MediaPipe LLM (.task path)
-keep class com.google.mediapipe.** { *; }
-dontwarn com.google.mediapipe.**
# LiteRT runtime (.litertlm path used by Gemma 4 E2B)
-keep class com.google.ai.edge.** { *; }
-keep class com.google.ai.litert.** { *; }
-dontwarn com.google.ai.edge.**
-dontwarn com.google.ai.litert.**
# TensorFlow Lite (used by LiteRT under the hood)
-keep class org.tensorflow.lite.** { *; }
-dontwarn org.tensorflow.lite.**
# Protobuf-lite (LiteRT message classes referenced via reflection)
-keep class com.google.protobuf.** { *; }
-dontwarn com.google.protobuf.**
# flutter_gemma plugin's own native bridge
-keep class dev.flutterberlin.flutter_gemma.** { *; }
-dontwarn dev.flutterberlin.flutter_gemma.**
# Generic JNI methods covers any LiteRT/MediaPipe class loaded dynamically
-keepclasseswithmembernames class * {
native <methods>;
}

View File

@@ -1,4 +1,11 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- #218: flutter_gemma downloads ≈ 2.4GB model checkpoint. -->
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<!-- Foreground service for large downloads (>500MB auto-detect). -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<application
android:label="life_helper"
android:name="${applicationName}"

View File

@@ -1,5 +1,36 @@
package kr.cloud_handson.life_helper
import android.app.ActivityManager
import android.content.Context
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity()
/// Hosts the `life_helper/device_caps` MethodChannel.
///
/// #218 AC-6: the AI feature requires ≥ 4GB RAM; getting an accurate total
/// from Dart needs ActivityManager.MemoryInfo, which is Android-only — so we
/// expose `totalMemoryBytes` as a platform method here.
class MainActivity : FlutterActivity() {
private val deviceCapsChannel = "life_helper/device_caps"
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, deviceCapsChannel)
.setMethodCallHandler { call, result ->
when (call.method) {
"totalMemoryBytes" -> {
try {
val am = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val info = ActivityManager.MemoryInfo()
am.getMemoryInfo(info)
result.success(info.totalMem)
} catch (t: Throwable) {
result.error("RAM_QUERY_FAILED", t.message, null)
}
}
else -> result.notImplemented()
}
}
}
}

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

@@ -0,0 +1,58 @@
import 'dart:io';
import 'package:flutter/services.dart';
/// Minimum RAM (bytes) required for on-device Gemma 4 E2B inference.
///
/// 4 GiB matches Planner AC-6 of #218. The Gemma 4 E2B weights alone are
/// ~2.4GB; adding KV-cache + Flutter runtime + OS headroom puts us at 4GB
/// total as the practical floor below which AC-7 cold-start budgets fail.
const int kAiMinRamBytes = 4 * 1024 * 1024 * 1024;
/// Abstraction over the platform-channel RAM query, so tests can inject a
/// fake without touching MethodChannel.
abstract class DeviceCapabilities {
/// Returns total physical RAM in bytes, or `null` if unknown / unsupported
/// (non-Android host, channel error). Callers must treat `null` as "do
/// not enable the AI gate" (fail-closed).
Future<int?> totalRamBytes();
/// Convenience: `true` iff [totalRamBytes] returns ≥ [kAiMinRamBytes].
/// `null` from [totalRamBytes] → `false` (fail-closed).
Future<bool> meetsAiMinRam() async {
final bytes = await totalRamBytes();
if (bytes == null) return false;
return bytes >= kAiMinRamBytes;
}
}
/// Real implementation. Calls `MainActivity.kt` over a MethodChannel.
class PlatformDeviceCapabilities implements DeviceCapabilities {
PlatformDeviceCapabilities({MethodChannel? channel})
: _channel = channel ??
const MethodChannel('life_helper/device_caps');
final MethodChannel _channel;
@override
Future<int?> totalRamBytes() async {
// Channel is Android-only — return null on iOS/host tests rather than
// throwing MissingPluginException.
if (!Platform.isAndroid) return null;
try {
final v = await _channel.invokeMethod<int>('totalMemoryBytes');
return v;
} on PlatformException {
return null;
} on MissingPluginException {
return null;
}
}
@override
Future<bool> meetsAiMinRam() async {
final bytes = await totalRamBytes();
if (bytes == null) return false;
return bytes >= kAiMinRamBytes;
}
}

View File

@@ -1,21 +1,41 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter_gemma/flutter_gemma.dart';
import '../../ai/tools/tool_definition.dart' as tools;
import 'llm_service.dart';
/// Stub for the real `flutter_gemma` integration.
/// HuggingFace access token injected at build time via
/// `--dart-define=HF_TOKEN=hf_xxx`. Empty string is permitted —
/// flutter_gemma will only need it for the initial network download,
/// which our `ModelLifecycle` handles separately; activation from a
/// local file path generally does not require the token.
const String _hfToken = String.fromEnvironment('HF_TOKEN', defaultValue: '');
/// One-shot guard so [FlutterGemma.initialize] runs at most once per
/// isolate. Re-init is unsupported by the underlying plugin.
bool _initialized = false;
/// Real on-device LLM backend using flutter_gemma 0.16.5 + Gemma 4 E2B.
///
/// Wired up only after OQ-1 (exact model URL + SHA + flutter_gemma API
/// surface) is confirmed in Developer phase. Today this throws
/// `UnimplementedError` from every method — the rest of the system
/// (suggestFrame, ModelLifecycle, Riverpod providers) is built against the
/// `LlmService` abstract above and runs end-to-end with `MockLlmService`.
/// Wired into the existing #215 pipeline: `ModelLifecycle` downloads &
/// SHA-verifies the .litertlm file, then [load] registers that file with
/// flutter_gemma as the active model. [generateStructured] opens a
/// short-lived chat with a single [Tool] (Gemma 4 native function
/// calling) and returns the first matching [FunctionCallResponse]'s args.
///
/// When the package is added, replace the bodies with calls into
/// FlutterGemma.init / generateWithFunctionCalling per the package docs.
/// Existing tests + UI hooks remain unchanged.
/// Function-calling design notes (see fn-gemma_llm_service.md §B v2):
/// - Gemma 4 SDK injects the tool declaration via its chat template, so
/// we pass [Tool] to `createChat(tools: ...)` rather than appending a
/// schema instruction to the prompt (double-wrap risk).
/// - `ToolChoice.required` forces the model to emit a function call.
class GemmaLlmService implements LlmService {
final String modelPath;
GemmaLlmService({required this.modelPath});
InferenceModel? _model;
bool _loaded = false;
@override
@@ -23,14 +43,35 @@ class GemmaLlmService implements LlmService {
@override
Future<void> load() async {
throw UnimplementedError(
'GemmaLlmService.load: pending OQ-1 (model URL + flutter_gemma).',
);
if (_loaded) return;
if (!await File(modelPath).exists()) {
throw FileSystemException('model file missing', modelPath);
}
if (!_initialized) {
await FlutterGemma.initialize(huggingFaceToken: _hfToken);
_initialized = true;
}
await FlutterGemma.installModel(
modelType: ModelType.gemma4,
fileType: ModelFileType.litertlm,
).fromFile(modelPath).install();
final model = await FlutterGemma.getActiveModel(maxTokens: 2048);
_model = model;
_loaded = true;
}
@override
Future<void> unload() async {
final m = _model;
_model = null;
_loaded = false;
if (m != null) {
try {
await m.close();
} catch (_) {
// Best-effort cleanup — runtime may already be torn down.
}
}
}
@override
@@ -38,8 +79,177 @@ class GemmaLlmService implements LlmService {
String prompt,
Map<String, dynamic> schema,
) async {
throw UnimplementedError(
'GemmaLlmService.generateStructured: pending OQ-1.',
if (!_loaded || _model == null) {
throw StateError('LlmService not loaded');
}
final fnName = schema['name'];
final fnParams = schema['parameters'];
if (fnName is! String || fnName.isEmpty) {
throw ArgumentError('schema.name missing');
}
if (fnParams is! Map) {
throw ArgumentError('schema.parameters missing');
}
final fnDesc = (schema['description'] as String?) ?? '';
final tool = Tool(
name: fnName,
description: fnDesc,
parameters: Map<String, dynamic>.from(fnParams),
);
final chat = await _model!.createChat(
modelType: ModelType.gemma4,
supportsFunctionCalls: true,
toolChoice: ToolChoice.required,
tools: [tool],
);
try {
await chat.addQueryChunk(Message.text(text: prompt, isUser: true));
final stream = chat.generateChatResponseAsync();
return await collectFunctionCall(stream, fnName);
} finally {
try {
await chat.close();
} catch (_) {
// Native session close failure is non-fatal — log + continue.
}
}
}
@override
Future<LlmChatSession> startChat({
required List<tools.ToolDefinition> tools,
}) async {
if (!_loaded || _model == null) {
throw StateError('LlmService not loaded');
}
final gemmaTools = tools
.map((t) => Tool(
name: t.name,
description: t.description,
parameters: Map<String, dynamic>.from(t.parametersSchema),
))
.toList();
final chat = await _model!.createChat(
modelType: ModelType.gemma4,
supportsFunctionCalls: true,
// ToolChoice.auto = 모델이 자율 결정 (multi-tool + reply-only 모두 지원).
toolChoice: ToolChoice.auto,
tools: gemmaTools,
);
return _GemmaChatSession(chat);
}
}
class _GemmaChatSession implements LlmChatSession {
final dynamic _chat;
bool _closed = false;
_GemmaChatSession(this._chat);
@override
Stream<LlmChatEvent> sendUser(String text) {
if (_closed) {
throw StateError('LlmChatSession is closed');
}
return _run(Message.text(text: text, isUser: true));
}
@override
Stream<LlmChatEvent> sendToolResult({
required String toolName,
required Map<String, dynamic> result,
}) {
if (_closed) {
throw StateError('LlmChatSession is closed');
}
return _run(Message.toolResponse(toolName: toolName, response: result));
}
Stream<LlmChatEvent> _run(Message msg) async* {
await _chat.addQueryChunk(msg);
final Stream<ModelResponse> stream = _chat.generateChatResponseAsync();
await for (final event in stream) {
if (event is TextResponse) {
yield LlmTextChunk(event.token);
} else if (event is FunctionCallResponse) {
yield LlmFunctionCall(
event.name,
Map<String, dynamic>.from(event.args),
);
return; // model hands control back to caller for tool exec
} else if (event is ParallelFunctionCallResponse &&
event.calls.isNotEmpty) {
// ADR-0005: parallel calls collapsed to first — sequential dispatch.
final first = event.calls.first;
yield LlmFunctionCall(
first.name,
Map<String, dynamic>.from(first.args),
);
return;
}
// ThinkingResponse / other: skip.
}
}
@override
Future<void> close() async {
if (_closed) return;
_closed = true;
try {
await _chat.close();
} catch (_) {
// Best-effort cleanup.
}
}
}
/// Extracts the first `FunctionCallResponse(name == expectedName)` from
/// a flutter_gemma response stream. `TextResponse` / `ThinkingResponse`
/// events are skipped. A mismatched name throws fast.
///
/// File-private under `_collectFunctionCall` from [GemmaLlmService];
/// exposed as a top-level via `@visibleForTesting` so unit tests can
/// feed synthetic streams (see fn-spec §D, 8 test cases).
@visibleForTesting
Future<Map<String, dynamic>> collectFunctionCall(
Stream<ModelResponse> stream,
String expectedName,
) async {
Map<String, dynamic>? result;
String? wrongName;
try {
await for (final event in stream) {
if (event is FunctionCallResponse) {
if (event.name == expectedName) {
result = Map<String, dynamic>.from(event.args);
break;
} else {
wrongName = event.name;
break;
}
}
if (event is ParallelFunctionCallResponse && event.calls.isNotEmpty) {
final first = event.calls.first;
if (first.name == expectedName) {
result = Map<String, dynamic>.from(first.args);
} else {
wrongName = first.name;
}
break;
}
// TextResponse / ThinkingResponse: skip.
}
} catch (_) {
// Discard raw error to avoid leaking prompt content in logs/crash
// reports — the caller surfaces a generic message.
throw const FormatException('stream error');
}
if (wrongName != null) {
throw FormatException('unexpected function: $wrongName');
}
if (result == null) {
throw const FormatException('no function call emitted');
}
return result;
}

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

@@ -233,21 +233,37 @@ class ModelLifecycle {
/// opt-out: delete model file + clear all ai_* meta keys (except opt_in
/// which the caller toggles). Returns freed bytes (0 if nothing existed).
/// Idempotent.
///
/// F2 hardening (#218): per-file try/catch so a single OS-level delete
/// failure (locked file, permission flake) does not abort the whole
/// purge — meta keys still get cleared and the orphan file becomes a
/// background storage concern rather than a stuck "opt-out failed"
/// state. The freed-bytes count only reflects successful deletes.
Future<int> purge() async {
int freed = 0;
final pathStr = await meta.find(AiMetaKeys.modelPath);
if (pathStr != null) {
final f = File(pathStr);
if (f.existsSync()) {
freed += await f.length();
await f.delete();
try {
final f = File(pathStr);
if (f.existsSync()) {
final size = await f.length();
await f.delete();
freed += size;
}
} catch (_) {
// Best-effort; leave orphan file, continue purging meta.
}
}
final tempPath = '${await _modelPath()}.tmp';
final temp = File(tempPath);
if (temp.existsSync()) {
freed += await temp.length();
await temp.delete();
try {
final tempPath = '${await _modelPath()}.tmp';
final temp = File(tempPath);
if (temp.existsSync()) {
final size = await temp.length();
await temp.delete();
freed += size;
}
} catch (_) {
// Same as above — best-effort cleanup of the .tmp partial.
}
for (final k in [
AiMetaKeys.modelPath,
@@ -255,7 +271,12 @@ class ModelLifecycle {
AiMetaKeys.downloadState,
AiMetaKeys.downloadBytes,
]) {
await meta.remove(k);
try {
await meta.remove(k);
} catch (_) {
// Meta is a single sqlite table; failures here are rare.
// Swallow so the loop completes even if one key errors.
}
}
return freed;
}

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,7 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'ai/tools/tool_definition.dart' as tools;
import 'data/ai/gemma_llm_service.dart';
import 'data/ai/llm_service.dart';
import 'data/ai/model_lifecycle.dart';
import 'data/db/daos/meta_dao.dart';
import 'state/ai_providers.dart';
import 'state/providers.dart';
import 'ui/screens/habit_list_screen.dart';
@@ -12,15 +16,76 @@ Future<void> main() async {
runApp(ProviderScope(
overrides: [
appDatabaseProvider.overrideWithValue(db),
// OQ-1 pending: production-ready GemmaLlmService is wired here once
// model URL + SHA are pinned and flutter_gemma is added. Until then,
// MockLlmService keeps the app graceful (suggestFrame returns []).
llmServiceProvider.overrideWithValue(MockLlmService()),
// #218: real GemmaLlmService when model file is on disk + verified,
// MockLlmService otherwise. The provider is read lazily by the frame
// suggestion flow, so the resolution is dynamic per call.
llmServiceProvider.overrideWith((ref) {
return _LazyLlmService(
lifecycle: ref.watch(modelLifecycleProvider),
meta: ref.watch(metaDaoProvider),
);
}),
],
child: const LifeHelperApp(),
));
}
/// Adapter that lazily resolves between [GemmaLlmService] (when the
/// model file exists + meta is intact) and [MockLlmService] (fallback,
/// graceful empty candidates). Keeps the rest of the app unaware of
/// the difference — `suggestFrame` only sees [LlmService].
class _LazyLlmService implements LlmService {
_LazyLlmService({required this.lifecycle, required this.meta});
final ModelLifecycle lifecycle;
final MetaDao meta;
LlmService? _delegate;
Future<LlmService> _resolve() async {
final avail = await lifecycle.checkAvailability();
final path = await meta.find(AiMetaKeys.modelPath);
final wantGemma = avail == ModelAvailability.ready && path != null;
// Re-resolve every call so opt-in / opt-out state changes are reflected
// without an app restart. Repeat-resolve of the same kind reuses the
// cached instance (Gemma's flutter_gemma installModel is idempotent;
// Mock has no setup), but the kind itself flips when availability does.
final keep = _delegate != null &&
(wantGemma == (_delegate is GemmaLlmService)) &&
(!wantGemma ||
(_delegate as GemmaLlmService).modelPath == path);
if (!keep) {
_delegate = wantGemma
? GemmaLlmService(modelPath: path)
: MockLlmService();
}
return _delegate!;
}
@override
bool get isLoaded => _delegate?.isLoaded ?? false;
@override
Future<void> load() async => (await _resolve()).load();
@override
Future<void> unload() async {
final d = _delegate;
if (d != null) await d.unload();
}
@override
Future<Map<String, dynamic>> generateStructured(
String prompt,
Map<String, dynamic> schema,
) async =>
(await _resolve()).generateStructured(prompt, schema);
@override
Future<LlmChatSession> startChat({
required List<tools.ToolDefinition> tools,
}) async =>
(await _resolve()).startChat(tools: tools);
}
class LifeHelperApp extends StatelessWidget {
const LifeHelperApp({super.key});

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/ai/device_capabilities.dart';
import '../data/ai/llm_service.dart';
import '../data/ai/model_lifecycle.dart';
import '../data/db/app_database.dart' as drift;
@@ -10,19 +11,37 @@ import '../domain/ai/suggest_frame.dart';
import '../domain/models/frame_pattern.dart';
import 'providers.dart';
/// Default config for the on-device Gemma model (#215).
/// OQ-1: URL + SHA-256 pinned in Developer phase. Until then, downloads are
/// disabled (AI toggle is gated behind these constants being real).
const _kModelUrlPlaceholder =
'https://example.invalid/gemma4-e2b-q4.bin'; // OQ-1
const _kModelShaPlaceholder = 'PENDING_OQ_1';
/// Gemma 4 E2B instruction-tuned LiteRT-LM checkpoint (#218 OQ-1 resolved).
/// Hosted on HuggingFace `litert-community/gemma-4-E2B-it-litert-lm`.
/// File ≈ 2.41GB; SHA-256 pinned for integrity check.
///
/// Tests / placeholder builds may override `modelLifecycleProvider` with
/// fixture URLs. Production builds optionally inject a private mirror via
/// `--dart-define=GEMMA_MODEL_URL=...` (see main.dart).
const _kModelUrl =
'https://huggingface.co/litert-community/gemma-4-E2B-it-litert-lm/resolve/main/gemma-4-E2B-it.litertlm';
const _kModelSha256 =
'181938105e0eefd105961417e8da75903eacda102c4fce9ce90f50b97139a63c';
/// #218 AC-6: device-capability gate. RAM < 4GB → AI feature disabled.
/// Default implementation calls the `life_helper/device_caps` MethodChannel
/// (Android). Override in tests with a `_FakeDeviceCapabilities`.
final deviceCapabilitiesProvider = Provider<DeviceCapabilities>((ref) {
return PlatformDeviceCapabilities();
});
/// `true` iff the device has ≥ 4GB RAM. Default `false` (fail-closed) while
/// the platform call is in flight or on unsupported hosts (iOS / test).
final deviceMeetsAiRamProvider = FutureProvider<bool>((ref) async {
return ref.watch(deviceCapabilitiesProvider).meetsAiMinRam();
});
final modelLifecycleProvider = Provider<ModelLifecycle>((ref) {
return ModelLifecycle(
meta: ref.watch(metaDaoProvider),
config: ModelConfig(
url: Uri.parse(_kModelUrlPlaceholder),
expectedSha256: _kModelShaPlaceholder,
url: Uri.parse(_kModelUrl),
expectedSha256: _kModelSha256,
),
);
});

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

@@ -44,17 +44,25 @@ class _AiSection extends ConsumerWidget {
final settings = ref.watch(aiSettingsProvider);
final availability = ref.watch(modelAvailabilityProvider);
final download = ref.watch(modelDownloadControllerProvider);
final ramOk = ref.watch(deviceMeetsAiRamProvider);
final optIn = settings.maybeWhen(data: (v) => v, orElse: () => false);
// #218 AC-6: gate the toggle when device RAM < 4GB. Default fail-closed
// (null → disabled) so the user can't trip download on an undersized
// device while the platform call is in flight.
final meetsRam = ramOk.maybeWhen(data: (v) => v, orElse: () => false);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SwitchListTile(
title: const Text('AI 도움 켜기'),
subtitle: const Text(
'Gemma 4 E2B 모델 ≈ 1.5GB. 모든 처리는 단말에서 일어납니다.',
subtitle: Text(
meetsRam
? 'Gemma 4 E2B 모델 ≈ 2.4GB. 모든 처리는 단말에서 일어납니다.'
: '이 단말에서는 AI 도움을 사용할 수 없어요 (RAM 4GB 이상 필요)',
),
value: optIn,
onChanged: (v) async {
value: meetsRam && optIn,
onChanged: meetsRam
? (v) async {
if (v) {
final ok = await _confirmOptIn(context);
if (ok != true) return;
@@ -70,14 +78,15 @@ class _AiSection extends ConsumerWidget {
SnackBar(content: Text('공간 확보됨 ${_fmtMB(freed)}')),
);
}
},
}
: null,
),
availability.when(
loading: () => const ListTile(title: Text('상태 확인 중...')),
error: (e, _) => ListTile(title: Text('상태 오류: $e')),
data: (a) => ListTile(
title: const Text('모델 상태'),
subtitle: Text(_describe(a)),
subtitle: Text(_describe(a, meetsRam: meetsRam)),
),
),
if (optIn && download != null)
@@ -93,12 +102,16 @@ class _AiSection extends ConsumerWidget {
);
}
String _describe(ModelAvailability a) {
String _describe(ModelAvailability a, {required bool meetsRam}) {
switch (a) {
case ModelAvailability.ready:
return '준비 완료 — 새 습관 만들기에서 ✨ AI 제안을 쓸 수 있어요';
case ModelAvailability.missing:
return '아직 받지 않았어요 — 위 토글을 켜면 다운로드합니다';
// RAM 게이트로 토글이 비활성인 상태에서 "토글을 켜면" 안내는 모순.
// 단말 미지원 메시지를 그대로 노출해서 사용자가 다음 행동을 알 수 있게.
return meetsRam
? '아직 받지 않았어요 — 위 토글을 켜면 다운로드합니다'
: '이 단말은 모델을 받을 수 없어요 (RAM 4GB 이상 필요)';
case ModelAvailability.corrupt:
return '파일이 손상됐어요 — 끄고 다시 켜면 새로 받습니다';
case ModelAvailability.downloading:
@@ -125,9 +138,10 @@ class _AiSection extends ConsumerWidget {
style: TextStyle(fontWeight: FontWeight.w600),
),
SizedBox(height: 12),
_Bullet('파일 크기: 약 1.5GB'),
_Bullet('파일 크기: 약 2.4GB'),
_Bullet('WiFi 연결을 권장합니다'),
_Bullet('모든 처리는 단말에서만 일어나며, 입력 텍스트는 외부로 전송되지 않습니다'),
_Bullet('Gemma 이용약관(ai.google.dev/gemma/terms)에 동의합니다'),
],
),
actions: [
@@ -156,12 +170,12 @@ class _AiSection extends ConsumerWidget {
Text('모델 파일이 단말에서 삭제됩니다.'),
SizedBox(height: 8),
Text(
'1.5GB 의 저장공간이 확보돼요.',
'2.4GB 의 저장공간이 확보돼요.',
style: TextStyle(fontSize: 13, color: Colors.grey),
),
SizedBox(height: 4),
Text(
'다시 켜면 다시 다운로드해야 합니다.',
'다시 켜면 처음부터 다운로드합니다.',
style: TextStyle(fontSize: 13, color: Colors.grey),
),
],

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

@@ -5,18 +5,18 @@ packages:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f
sha256: "3b19a47f6ea7c2632760777c78174f47f6aec1e05f0cd611380d4593b8af1dbc"
url: "https://pub.dev"
source: hosted
version: "85.0.0"
version: "96.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: "974859dc0ff5f37bc4313244b3218c791810d03ab3470a579580279ba971a48d"
sha256: "0c516bc4ad36a1a75759e54d5047cb9d15cded4459df01aa35a0b5ec7db2c2a0"
url: "https://pub.dev"
source: hosted
version: "7.7.1"
version: "10.2.0"
args:
dependency: transitive
description:
@@ -33,6 +33,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.13.1"
background_downloader:
dependency: transitive
description:
name: background_downloader
sha256: aceacec2b2a72ec3a8862ab5895fcbbc71ab33765f3619d57963f3110dd268e3
url: "https://pub.dev"
source: hosted
version: "9.5.5"
boolean_selector:
dependency: transitive
description:
@@ -45,18 +53,18 @@ packages:
dependency: transitive
description:
name: build
sha256: "51dc711996cbf609b90cbe5b335bbce83143875a9d58e4b5c6d3c4f684d3dda7"
sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10
url: "https://pub.dev"
source: hosted
version: "2.5.4"
version: "4.0.6"
build_config:
dependency: transitive
description:
name: build_config
sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33"
sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71"
url: "https://pub.dev"
source: hosted
version: "1.1.2"
version: "1.3.0"
build_daemon:
dependency: transitive
description:
@@ -65,30 +73,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.1"
build_resolvers:
dependency: transitive
description:
name: build_resolvers
sha256: ee4257b3f20c0c90e72ed2b57ad637f694ccba48839a821e87db762548c22a62
url: "https://pub.dev"
source: hosted
version: "2.5.4"
build_runner:
dependency: "direct dev"
description:
name: build_runner
sha256: "382a4d649addbfb7ba71a3631df0ec6a45d5ab9b098638144faf27f02778eb53"
sha256: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6"
url: "https://pub.dev"
source: hosted
version: "2.5.4"
build_runner_core:
dependency: transitive
description:
name: build_runner_core
sha256: "85fbbb1036d576d966332a3f5ce83f2ce66a40bea1a94ad2d5fc29a19a0d3792"
url: "https://pub.dev"
source: hosted
version: "9.1.2"
version: "2.15.0"
built_collection:
dependency: transitive
description:
@@ -149,18 +141,10 @@ packages:
dependency: transitive
description:
name: code_assets
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
code_builder:
dependency: transitive
description:
name: code_builder
sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d"
url: "https://pub.dev"
source: hosted
version: "4.11.1"
version: "1.0.0"
collection:
dependency: transitive
description:
@@ -193,30 +177,54 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.9"
dart_sentencepiece_tokenizer:
dependency: transitive
description:
name: dart_sentencepiece_tokenizer
sha256: "85825632845cf6427ea0cd13dfba96b4341cf63525165155e5b9b97011239289"
url: "https://pub.dev"
source: hosted
version: "1.3.2"
dart_style:
dependency: transitive
description:
name: dart_style
sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb"
sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2"
url: "https://pub.dev"
source: hosted
version: "3.1.1"
version: "3.1.7"
device_info_plus:
dependency: "direct main"
description:
name: device_info_plus
sha256: a7fd703482b391a87d60b6061d04dfdeab07826b96f9abd8f5ed98068acc0074
url: "https://pub.dev"
source: hosted
version: "10.1.2"
device_info_plus_platform_interface:
dependency: transitive
description:
name: device_info_plus_platform_interface
sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f
url: "https://pub.dev"
source: hosted
version: "7.0.3"
drift:
dependency: "direct main"
description:
name: drift
sha256: "540cf382a3bfa99b76e51514db5b0ebcd81ce3679b7c1c9cb9478ff3735e47a1"
sha256: "6cc0b623c0e83f7080524d8396e9301b1d78b9c66a4fdceeb0f798211303254c"
url: "https://pub.dev"
source: hosted
version: "2.28.2"
version: "2.34.0"
drift_dev:
dependency: "direct dev"
description:
name: drift_dev
sha256: "68c138e884527d2bd61df2ade276c3a144df84d1adeb0ab8f3196b5afe021bd4"
sha256: "9cfff1576b49725da0d32c040651a41ae195e8c4af8d8da301593e41d7abc2f7"
url: "https://pub.dev"
source: hosted
version: "2.28.0"
version: "2.34.0"
fake_async:
dependency: transitive
description:
@@ -254,6 +262,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_gemma:
dependency: "direct main"
description:
name: flutter_gemma
sha256: "984960b54bbc0ff7e36cf568a02652b1d4bc016d6c75575b027fb6102fab48c3"
url: "https://pub.dev"
source: hosted
version: "0.16.5"
flutter_lints:
dependency: "direct dev"
description:
@@ -275,30 +291,11 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
freezed:
dependency: "direct dev"
description:
name: freezed
sha256: "59a584c24b3acdc5250bb856d0d3e9c0b798ed14a4af1ddb7dc1c7b41df91c9c"
url: "https://pub.dev"
source: hosted
version: "2.5.8"
freezed_annotation:
dependency: "direct main"
description:
name: freezed_annotation
sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2
url: "https://pub.dev"
source: hosted
version: "2.4.4"
frontend_server_client:
flutter_web_plugins:
dependency: transitive
description:
name: frontend_server_client
sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
url: "https://pub.dev"
source: hosted
version: "4.0.0"
description: flutter
source: sdk
version: "0.0.0"
glob:
dependency: transitive
description:
@@ -319,10 +316,10 @@ packages:
dependency: transitive
description:
name: hooks
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
version: "1.0.3"
http:
dependency: "direct main"
description:
@@ -371,14 +368,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.1"
js:
dependency: transitive
description:
name: js
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
url: "https://pub.dev"
source: hosted
version: "0.7.2"
json_annotation:
dependency: "direct main"
description:
@@ -391,10 +380,18 @@ packages:
dependency: "direct dev"
description:
name: json_serializable
sha256: c50ef5fc083d5b5e12eef489503ba3bf5ccc899e487d691584699b4bdefeea8c
sha256: "5b89c1e32ae3840bb20a1b3434e3a590173ad3cb605896fb0f60487ce2f8104e"
url: "https://pub.dev"
source: hosted
version: "6.9.5"
version: "6.11.4"
large_file_handler:
dependency: transitive
description:
name: large_file_handler
sha256: "1657db12b4591242b186c23eee437997be91e655ab0d803fd74829074460757f"
url: "https://pub.dev"
source: hosted
version: "0.4.0"
leak_tracker:
dependency: transitive
description:
@@ -427,6 +424,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.1.0"
local_hnsw:
dependency: transitive
description:
name: local_hnsw
sha256: "7fb0988e3f850121774d9dfe94068f843d87908d6b1e9ed5039710d8d9ae3f7a"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
logging:
dependency: transitive
description:
@@ -467,14 +472,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.0"
mutex:
dependency: transitive
description:
name: mutex
sha256: "8827da25de792088eb33e572115a5eb0d61d61a3c01acbc8bcbe76ed78f1a1f2"
url: "https://pub.dev"
source: hosted
version: "3.1.0"
native_toolchain_c:
dependency: transitive
description:
name: native_toolchain_c
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
url: "https://pub.dev"
source: hosted
version: "0.17.6"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed"
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
url: "https://pub.dev"
source: hosted
version: "9.4.1"
version: "9.3.0"
package_config:
dependency: transitive
description:
@@ -603,6 +624,62 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.6.1"
shared_preferences:
dependency: transitive
description:
name: shared_preferences
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
url: "https://pub.dev"
source: hosted
version: "2.5.5"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: "93ae5884a9df5d3bb696825bceb3a17590754548b5d740eba51500afc8d088f5"
url: "https://pub.dev"
source: hosted
version: "2.4.26"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
url: "https://pub.dev"
source: hosted
version: "2.5.6"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.dev"
source: hosted
version: "2.4.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shelf:
dependency: transitive
description:
@@ -628,18 +705,18 @@ packages:
dependency: transitive
description:
name: source_gen
sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b"
sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02
url: "https://pub.dev"
source: hosted
version: "2.0.0"
version: "4.2.3"
source_helper:
dependency: transitive
description:
name: source_helper
sha256: a447acb083d3a5ef17f983dd36201aeea33fedadb3228fa831f2f0c92f0f3aca
sha256: "4227d54ceefd0bb8ca4c8fcb96e1719dc53f1ee1b6e2ca9d7a6069da160e4eae"
url: "https://pub.dev"
source: hosted
version: "1.3.7"
version: "1.3.12"
source_span:
dependency: transitive
description:
@@ -652,10 +729,10 @@ packages:
dependency: transitive
description:
name: sqlite3
sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2"
sha256: "9488c7d2cdb1091c91cacf7e207cff81b28bff8e366f042bad3afe7d34afe189"
url: "https://pub.dev"
source: hosted
version: "2.9.4"
version: "3.3.2"
sqlite3_flutter_libs:
dependency: "direct main"
description:
@@ -668,10 +745,10 @@ packages:
dependency: transitive
description:
name: sqlparser
sha256: "57090342af1ce32bb499aa641f4ecdd2d6231b9403cea537ac059e803cc20d67"
sha256: "40bdddb306a727be9ce510bd2d2b9a6c9db6c586d846ef7b22e3990a2b24f02d"
url: "https://pub.dev"
source: hosted
version: "0.41.2"
version: "0.44.5"
stack_trace:
dependency: transitive
description:
@@ -728,14 +805,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.11"
timing:
dependency: transitive
description:
name: timing
sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
typed_data:
dependency: transitive
description:
@@ -752,6 +821,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.1"
uuid:
dependency: transitive
description:
name: uuid
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
url: "https://pub.dev"
source: hosted
version: "4.5.3"
vector_math:
dependency: transitive
description:
@@ -800,6 +877,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.3"
win32:
dependency: transitive
description:
name: win32
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
url: "https://pub.dev"
source: hosted
version: "5.15.0"
win32_registry:
dependency: transitive
description:
name: win32_registry
sha256: "21ec76dfc731550fd3e2ce7a33a9ea90b828fdf19a5c3bcf556fa992cfa99852"
url: "https://pub.dev"
source: hosted
version: "1.1.5"
xdg_directories:
dependency: transitive
description:
@@ -818,4 +911,4 @@ packages:
version: "3.1.3"
sdks:
dart: ">=3.12.2 <4.0.0"
flutter: ">=3.38.4"
flutter: ">=3.44.0"

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.2.0+2
version: 0.4.0+4
environment:
sdk: ^3.12.2
@@ -21,18 +21,20 @@ dependencies:
path: ^1.9.0
# Models / serialization
freezed_annotation: ^2.4.0
json_annotation: ^4.9.0
# IDs
ulid: ^2.0.0
# AI / on-device LLM (#215)
# flutter_gemma 는 OQ-1 (정확한 모델 URL + SHA) 확정 후 추가.
# v1은 LlmService 추상 + ModelLifecycle (파일/SHA/메타 관리) + Mock 까지 구현.
# AI / on-device LLM (#215, #218)
# #218 (v0.3.0): flutter_gemma 0.16.5 + Gemma 4 E2B (HF litert-community).
flutter_gemma: ^0.16.5
crypto: ^3.0.0
http: ^1.2.0
# Device info — RAM gate for AI opt-in (#218 AC-6)
device_info_plus: ^10.1.0
dev_dependencies:
flutter_test:
sdk: flutter
@@ -41,7 +43,6 @@ dev_dependencies:
# Codegen
drift_dev: ^2.18.0
build_runner: ^2.4.0
freezed: ^2.5.0
json_serializable: ^6.8.0
flutter:

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,54 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:life_helper/data/ai/device_capabilities.dart';
/// #218 AC-6 boundary tests for the RAM gate.
///
/// We test the abstract contract's `meetsAiMinRam()` default impl via a
/// fake — the real `PlatformDeviceCapabilities.totalRamBytes()` requires
/// the MethodChannel + Android runtime (covered by AC-7).
class _Fake implements DeviceCapabilities {
_Fake(this.bytes);
final int? bytes;
@override
Future<int?> totalRamBytes() async => bytes;
@override
Future<bool> meetsAiMinRam() async {
final v = await totalRamBytes();
if (v == null) return false;
return v >= kAiMinRamBytes;
}
}
void main() {
test('kAiMinRamBytes equals 4 GiB', () {
expect(kAiMinRamBytes, 4 * 1024 * 1024 * 1024);
});
test('null totalRamBytes → meetsAiMinRam false (fail-closed)', () async {
expect(await _Fake(null).meetsAiMinRam(), isFalse);
});
test('3.9 GiB → meetsAiMinRam false', () async {
final bytes = (3.9 * 1024 * 1024 * 1024).round();
expect(await _Fake(bytes).meetsAiMinRam(), isFalse);
});
test('exactly 4 GiB - 1 byte → false', () async {
expect(await _Fake(kAiMinRamBytes - 1).meetsAiMinRam(), isFalse);
});
test('exactly 4 GiB → true (inclusive)', () async {
expect(await _Fake(kAiMinRamBytes).meetsAiMinRam(), isTrue);
});
test('8 GiB → true', () async {
final bytes = 8 * 1024 * 1024 * 1024;
expect(await _Fake(bytes).meetsAiMinRam(), isTrue);
});
test('0 bytes → false (would also catch broken channel returning 0)', () async {
expect(await _Fake(0).meetsAiMinRam(), isFalse);
});
}

View File

@@ -0,0 +1,150 @@
import 'dart:async';
import 'package:flutter_gemma/flutter_gemma.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:life_helper/data/ai/gemma_llm_service.dart';
/// Unit tests for `collectFunctionCall` (fn-spec §D, 8 cases).
///
/// `GemmaLlmService.load` / `.generateStructured` themselves require the
/// flutter_gemma native runtime and are covered by AC-7 (on-device E2E),
/// not by host tests. The pure stream-parsing helper is unit-testable in
/// isolation because we can feed a synthetic `Stream<ModelResponse>`.
void main() {
const fn = 'emit_frame_candidates';
test('1. single FCR with expected name returns args', () async {
final stream = Stream<ModelResponse>.fromIterable([
const FunctionCallResponse(
name: fn,
args: {
'candidates': [
{'text': 'a', 'level': 'L2'},
{'text': 'b', 'level': 'L2'},
{'text': 'c', 'level': 'L3'},
],
},
),
]);
final args = await collectFunctionCall(stream, fn);
expect(args['candidates'], hasLength(3));
});
test('2. TextResponse before FCR is skipped', () async {
final stream = Stream<ModelResponse>.fromIterable([
const TextResponse('hello'),
const FunctionCallResponse(name: fn, args: {'candidates': []}),
]);
final args = await collectFunctionCall(stream, fn);
expect(args['candidates'], isEmpty);
});
test('3. ThinkingResponse + TextResponse before empty-args FCR', () async {
final stream = Stream<ModelResponse>.fromIterable([
const ThinkingResponse('reasoning...'),
const TextResponse('preamble'),
const FunctionCallResponse(name: fn, args: {}),
]);
final args = await collectFunctionCall(stream, fn);
expect(args, isEmpty);
});
test('4. wrong function name throws FormatException', () async {
final stream = Stream<ModelResponse>.fromIterable([
const FunctionCallResponse(name: 'wrong_name', args: {}),
]);
expect(
() => collectFunctionCall(stream, fn),
throwsA(
isA<FormatException>().having(
(e) => e.message,
'message',
contains('wrong_name'),
),
),
);
});
test('5. text-only stream throws "no function call emitted"', () async {
final stream = Stream<ModelResponse>.fromIterable([
const TextResponse('only text, no call'),
]);
expect(
() => collectFunctionCall(stream, fn),
throwsA(
isA<FormatException>().having(
(e) => e.message,
'message',
contains('no function call emitted'),
),
),
);
});
test('6. stream error throws sanitized FormatException (no leak)', () async {
final stream = Stream<ModelResponse>.error(
Exception('SENSITIVE: user_prompt_leaked_in_error'),
);
expect(
() => collectFunctionCall(stream, fn),
throwsA(
isA<FormatException>().having(
(e) => e.message,
'message',
allOf(
equals('stream error'),
isNot(contains('SENSITIVE')),
isNot(contains('user_prompt_leaked_in_error')),
),
),
),
);
});
test('7. FCR with empty args map returns empty map (no throw)', () async {
final stream = Stream<ModelResponse>.fromIterable([
const FunctionCallResponse(name: fn, args: {}),
]);
final args = await collectFunctionCall(stream, fn);
expect(args, isEmpty);
});
test('8. empty stream throws "no function call emitted"', () async {
final stream = const Stream<ModelResponse>.empty();
expect(
() => collectFunctionCall(stream, fn),
throwsA(
isA<FormatException>().having(
(e) => e.message,
'message',
contains('no function call emitted'),
),
),
);
});
group('ParallelFunctionCallResponse', () {
test('first call with expected name returns its args', () async {
final stream = Stream<ModelResponse>.fromIterable([
ParallelFunctionCallResponse(calls: [
const FunctionCallResponse(name: fn, args: {'x': 1}),
const FunctionCallResponse(name: 'other', args: {'y': 2}),
]),
]);
final args = await collectFunctionCall(stream, fn);
expect(args['x'], 1);
});
test('first call with wrong name throws', () async {
final stream = Stream<ModelResponse>.fromIterable([
ParallelFunctionCallResponse(calls: [
const FunctionCallResponse(name: 'wrong_first', args: {}),
]),
]);
expect(
() => collectFunctionCall(stream, fn),
throwsA(isA<FormatException>()),
);
});
});
}

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

@@ -61,3 +61,29 @@ docs/
`Draft`(작성) → `Approved`(QA/Reviewer 통과 후) → `Superseded`(대체 시 상단 표기, 삭제 금지).
구현이 설계서와 달라지면 **코드가 아니라 설계서를 먼저 고치고** 다시 구현한다.
```
## 현재 발행된 문서 (인덱스)
### 설계서 (`design/`)
- [204-flutter-bootstrap](./design/204-flutter-bootstrap/) — Phase 1 MVP Drift 21 테이블 + 도메인 함수 + UI 4 화면
- [215-gemma-frame-suggest](./design/215-gemma-frame-suggest/) — Phase 2-A on-device Gemma 4 프레임 자동 생성
- [218-gemma-real-integration](./design/218-gemma-real-integration/) — OQ-1 실 Gemma 4 E2B + flutter_gemma 0.16.5 통합 (placeholder → 실 구현)
### ADR (`adr/`)
- [0001-dose-variants.md](./adr/0001-dose-variants.md) — Dose Variants 도입
- [0002-dose-variants-normalized.md](./adr/0002-dose-variants-normalized.md) — Dose Variants 정규화 방식
- [0003-on-device-llm-gemma.md](./adr/0003-on-device-llm-gemma.md) — On-device LLM (Gemma 4) 도입
### 레퍼런스 (`reference/`)
- [215-ai-frame-suggest.md](./reference/215-ai-frame-suggest.md) — AI 프레임 제안 모듈 사양 (#215 + #218, v0.3.0)
### 가이드 (`guides/`)
- [ai-help-onboarding.md](./guides/ai-help-onboarding.md) — AI 도움 켜기·끄기 사용자 가이드
### 파이프라인 (`pipeline/`)
- [QUEUE-PROTOCOL.md](./pipeline/QUEUE-PROTOCOL.md) — 8 페르소나 큐 프로토콜

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,10 +1,32 @@
# 설계서: Phase 2-A — Gemma 4 on-device 프레임 자동 생성 (#215)
> **상태**: Draft
> **작성**: [AI] Architect · **최종수정**: 2026-06-12
> **추적성** — Redmine: #215 · 관련 ADR: [ADR-0003 on-device LLM Gemma](../../adr/0003-on-device-llm-gemma.md)
> · 구현 파일: `app/lib/data/ai/`, `app/lib/domain/ai/`, `app/lib/state/ai_providers.dart` (TBD by Developer)
> · 테스트: `app/test/domain/ai/`, `app/test/data/ai/` (TBD by Developer)
> **상태**: Approved (v0.2.0, 커밋 `0d1db2d`)
> **작성**: [AI] Architect · **최종수정**: 2026-06-12 (Documenter)
> **추적성** — Redmine: #215 · 관련 ADR: [ADR-0003 on-device LLM Gemma](../../adr/0003-on-device-llm-gemma.md) · 릴리스 태그: `v0.2.0`
> · 구현 파일:
> - `app/lib/data/ai/llm_service.dart` (abstract + MockLlmService)
> - `app/lib/data/ai/gemma_llm_service.dart` (stub — OQ-1 후 활성)
> - `app/lib/data/ai/model_lifecycle.dart` (download/verify/purge)
> - `app/lib/domain/ai/frame_candidate.dart`
> - `app/lib/domain/ai/suggest_frame.dart`
> - `app/lib/domain/ai/few_shot_builder.dart`
> - `app/lib/domain/ai/parse_response.dart`
> - `app/lib/state/ai_providers.dart` (Riverpod providers + ModelDownloadController)
> - `app/lib/ui/screens/settings_screen.dart`
> - `app/lib/ui/widgets/frame_suggestion_dialog.dart`
> - `app/lib/ui/screens/habit_create_screen.dart` (`_AiSuggestButton`)
> · 테스트:
> - `app/test/domain/ai/{suggest_frame,few_shot_builder,parse_response}_test.dart`
> - `app/test/data/ai/model_lifecycle_test.dart`
> - `app/test/state/model_download_controller_test.dart`
> - `app/test/ui/ai_suggest_button_visibility_test.dart`
> · 레퍼런스: [docs/reference/215-ai-frame-suggest.md](../../reference/215-ai-frame-suggest.md)
> · 사용 가이드: [docs/guides/ai-help-onboarding.md](../../guides/ai-help-onboarding.md)
>
> **알려진 follow-up** (Reviewer F1/F2 + OQ-1):
> - OQ-1: 실제 Gemma 4 E2B Q4_0 모델 URL + SHA-256 — 현재 placeholder (`example.invalid`).
> - F1: 60초 idle auto-unload 미구현 — stub 상태라 무의미. OQ-1 해결 시 추가.
> - F2: `ModelLifecycle.purge()` 의 `File.delete()` try/catch 미감쌈 — placeholder URL 라 도달 불가.
> **하위 문서**:
> - [fn-suggest_frame.md](./fn-suggest_frame.md) — Gemma 4 호출 + few-shot 조립 + 응답 파싱
> - [fn-model_lifecycle.md](./fn-model_lifecycle.md) — 모델 다운로드 / 로드 / 언로드 / 삭제 + LlmService 인터페이스

View File

@@ -1,7 +1,8 @@
# 함수 설계서: `ModelLifecycle` + `LlmService` (#215)
> **부모 설계서**: ./README.md · **상태**: Draft
> **작성**: [AI] Architect · **구현**: `app/lib/data/ai/model_lifecycle.dart`, `llm_service.dart`, `gemma_llm_service.dart` (TBD) · **테스트**: `app/test/data/ai/{model_lifecycle,gemma_llm_service}_test.dart` (TBD)
> **부모 설계서**: ./README.md · **상태**: Approved (v0.2.0)
> **작성**: [AI] Architect · **구현**: `app/lib/data/ai/model_lifecycle.dart`, `llm_service.dart`, `gemma_llm_service.dart` (stub) · **테스트**: `app/test/data/ai/model_lifecycle_test.dart` + `app/test/state/model_download_controller_test.dart` (총 10 케이스)
> · 동기화 노트: `GemmaLlmService` 는 모든 메서드가 `UnimplementedError` 를 던지는 stub 상태 — OQ-1 (실 모델 URL+SHA) 해결 시 활성. v1 런타임은 `MockLlmService` 가 `llmServiceProvider` 에 주입.
> 본 문서는 모델 수명주기와 추론 인터페이스를 묶어 다룬다. 모두 I/O 경계 — flutter_gemma + 파일시스템 + 네트워크 + 네이티브 메모리.

View File

@@ -1,7 +1,7 @@
# 함수 설계서: `suggestFrame` + `buildFewShotPrompt` + `parseFrameCandidates` (#215)
> **부모 설계서**: ./README.md · **상태**: Draft
> **작성**: [AI] Architect · **구현**: `app/lib/domain/ai/suggest_frame.dart`, `few_shot_builder.dart`, `parse_response.dart` (TBD) · **테스트**: `app/test/domain/ai/{suggest_frame,few_shot_builder,parse_response}_test.dart` (TBD)
> **부모 설계서**: ./README.md · **상태**: Approved (v0.2.0)
> **작성**: [AI] Architect · **구현**: `app/lib/domain/ai/suggest_frame.dart`, `few_shot_builder.dart`, `parse_response.dart` · **테스트**: `app/test/domain/ai/{suggest_frame,few_shot_builder,parse_response}_test.dart` (총 27 케이스)
> 본 문서는 도메인 핵심 알고리즘 함수 3개를 묶어 다룬다. 셋 다 순수 함수 (또는 LlmService 만 의존) 로 테스트 가능성을 우선한다.

View File

@@ -0,0 +1,410 @@
# 설계서: OQ-1 — 실 Gemma 통합 (#218)
> **상태**: Approved (2026-06-12 — Reviewer 1b90f58, Release v0.3.0)
> **작성**: [AI] Architect · **작성일**: 2026-06-12
> **추적성** — Redmine: #218 · 상위/이전: #215 (v0.2.0 placeholder) · 후속: #219 (idle auto-unload) / #220 (load 동시성 + isThinking) / #221 (한국어 corpus) / #222 (HF_TOKEN keystore) · 릴리스 태그: `v0.3.0` (commit da60dd1) · 관련 ADR: [ADR-0003 on-device LLM Gemma](../../adr/0003-on-device-llm-gemma.md) (재확인, 신규 ADR 없음)
> · 구현 파일 (실제 경로 — Documenter 동기화):
> - `app/lib/data/ai/gemma_llm_service.dart` — 실 구현 (createChat tools + collectFunctionCall)
> - `app/lib/data/ai/device_capabilities.dart` — RAM 게이트 (Dev round 2 추가, Planner OOS 였으나 QA 적발 후 신설)
> - `app/lib/data/ai/model_lifecycle.dart` — F2 hardening 통합
> - `app/lib/state/ai_providers.dart` — `_kModelUrl` / `_kModelSha256` 실값 + `deviceMeetsAiRamProvider`
> - `app/lib/main.dart` — `_LazyLlmService` 어댑터 (re-resolve + sticky-cache 회피, Reviewer 1b90f58 수정)
> - `app/lib/ui/screens/settings_screen.dart` — RAM 게이트 SwitchListTile + Designer 문구
> - `app/android/app/src/main/kotlin/kr/cloud_handson/life_helper/MainActivity.kt` — `life_helper/device_caps` MethodChannel
> - `app/android/app/proguard-rules.pro` — flutter_gemma example 사본
> - `app/pubspec.yaml` — flutter_gemma 0.16.5
> · 테스트 파일:
> - `app/test/data/ai/gemma_llm_service_test.dart`
> - `app/test/data/ai/device_capabilities_test.dart` (Dev round 2 신규 7건)
> - `app/test/data/ai/model_lifecycle_test.dart` (F2 case 보강)
> - 총 88/88 unit PASS
> · (이하 원본 변경 대상 — 이력 보존):
> - `app/lib/data/ai/gemma_llm_service.dart` (현재 `UnimplementedError` stub → 실 구현)
> - `app/lib/data/ai/model_lifecycle.dart` (`ModelLifecycle.purge` F2 hardening + load path 연결)
> - `app/lib/state/ai_providers.dart` (`_kModelUrlPlaceholder` / `_kModelShaPlaceholder` → 실값, `llmServiceProvider` 의 production override 활성화 path)
> - `app/lib/main.dart` (`MockLlmService` → `GemmaLlmService` 조건부 override)
> - `app/pubspec.yaml` (`flutter_gemma: 0.16.5` 추가)
> - `app/android/app/build.gradle` (`minSdkVersion 24`+ 확인), `app/android/app/src/main/AndroidManifest.xml` (OpenGL ES feature, MediaPipe ProGuard rules)
> · 추가 테스트:
> - `app/test/data/ai/gemma_llm_service_test.dart` (schema → Tool 변환, FunctionCallResponse 수집 단위, error mapping)
> - `app/test/data/ai/model_lifecycle_test.dart` (기존 + F2 case 추가)
> - E2E: AC-7 실 단말 수동 (Android 8GB+)
> · 선행 설계서 (변경 없음): [docs/design/215-gemma-frame-suggest/](../215-gemma-frame-suggest/) — placeholder 기반 v0.2.0 청사진. 본 설계서는 placeholder 자리 채움에 한정.
> · 하위 문서:
> - [fn-gemma_llm_service.md](./fn-gemma_llm_service.md) — `GemmaLlmService.load` / `generateStructured` 의 schema→Tool 변환 + 스트림 응답 수집 + 에러 매핑 알고리즘
---
## 1. 목적 (Why)
v0.2.0 (#215) 은 mock 환경에서 100% 동작하지만, 사용자가 "AI 도움" 토글을 ON 하면 `_kModelUrlPlaceholder = 'https://example.invalid/...'` 로 인해 graceful 실패 다이얼로그만 본다. 실 사용자 가치는 0. 본 작업의 단일 과제는 **"#215 가 정의한 `LlmService` 추상화의 뒤편을 실 `flutter_gemma 0.16.5` + 실 Gemma 4 E2B QAT 모바일 모델로 교체하여, mock 경로와 동일한 사용자 흐름이 실제로 후보 문장을 반환하게 만드는 것"** 이다.
청사진(#215)·UI·도메인 로직은 모두 그대로 둔다. 본 설계서는 placeholder 3 지점 (`_kModelUrlPlaceholder`, `_kModelShaPlaceholder`, `GemmaLlmService` 본문) 만 다룬다.
## 2. 범위 (Scope)
### 포함
- `flutter_gemma 0.16.5` pubspec 추가 + pubspec.lock 동결.
- Gemma 4 E2B QAT 모바일 모델 URL 확정 (HuggingFace `litert-community/gemma-4-E2B-it-litert-lm``.task` 또는 `.litertlm` 자산, 또는 `google/gemma-4-E2B-it-qat-mobile-transformers`) + SHA-256 핀 고정.
- `GemmaLlmService.load`/`generateStructured`/`unload` 실 구현 (flutter_gemma 0.16.5 의 `FlutterGemma.initialize` + chat session + Stream<ModelResponse>).
- function calling 스키마 (`kFrameCandidatesSchema` JSON Schema) → flutter_gemma `Tool` 객체 변환 어댑터.
- `FunctionCallResponse(name, args)` 스트림 이벤트를 수집하여 `args: Map<String, dynamic>` 반환.
- Android 빌드 설정: `minSdkVersion 24`+ 확인, OpenGL ES feature 선언, MediaPipe ProGuard rules.
- 단말 게이트: RAM 4GB 미만 차단 (AC-9, #215 §9 재활용 — 새 메서드 없음).
- HuggingFace access token 핸들링: 빌드 시점 `--dart-define=HF_TOKEN=...` 주입 (사용자 단말에 평문 저장 X, 모델 다운로드 1회만 사용).
- `_kModelUrlPlaceholder` / `_kModelShaPlaceholder` 상수 → 실값으로 치환 + 상수명에서 `Placeholder` 제거.
- `main.dart` 의 production override: `aiSettingsProvider == true && modelAvailability == ready` 일 때만 `GemmaLlmService(modelPath: ...)` 으로 override, 그 외엔 `MockLlmService` 유지 (graceful).
- F2 hardening 통합: `ModelLifecycle.purge()``File.delete()` 를 try/catch 로 감쌈 (실파일이라 도달 가능).
### 제외 (out of scope)
- **#219 F1**: 60초 idle auto-unload — 별도 이슈. 본 설계는 즉시 load + 명시적 unload 만.
- **#220 F2 broader purge hardening**: 위 단일 try/catch 외 광범위 hardening (예: 부분 다운로드 `.tmp` 정리 순서, 동시성) 은 #220 으로.
- **#221 AC-10**: 한국어 corpus 30 케이스 평가 자동화 — 별도 이슈. 본 설계는 AC-7 실 단말 E2E 수동 검증만.
- **#222 production keystore**: 릴리스 서명 키 + Play Store 검토 별도.
- **E4B 모델**: ADR-0003 결정 #2 유지 — v1 은 E2B 단일.
- **iOS 빌드**: Phase 1 과 동일 Android-only.
- **시나리오 #2~#6** (앵커, dose variants, if-then, lapse, 주간 요약): Phase 2-B+.
- **모델 교체 UI** (E2B v1 → v2 swap): v2+.
- **HF 토큰 사용자 UX**: v1 은 빌드 임베드 (joungmin 토큰). v2 에서 사용자 본인 토큰 입력 화면 검토.
## 3. 인수조건 (Acceptance Criteria)
> Planner 가 정한 10개. QA round 2 (2026-06-12, f71d132) PASS, Reviewer (1b90f58) 승인.
- [x] **AC-1**: `flutter pub add flutter_gemma:^0.16.5` 통과 + `flutter analyze` 0 issue + `flutter build apk --debug` 성공. ✅
- [x] **AC-2**: 사용자 "AI 도움" 토글 ON 시 동의 다이얼로그 (#215 그대로) → 백그라운드 다운로드가 실 HF endpoint (`litert-community/gemma-4-E2B-it-litert-lm`) 로 향한다. ✅
- [x] **AC-3**: 다운로드 일시정지/재개/취소 동작이 실 HF URL 에 대해서도 보존 (HTTP Range). ✅
- [x] **AC-4**: 다운로드 완료 후 SHA-256 (`181938105e...39a63c`) 검증 + `meta_kv['ai_model_path']` 저장. ✅
- [x] **AC-5**: 다운로드 완료 후 `HabitCreateScreen` "AI 제안" 버튼 활성. ✅
- [x] **AC-6**: RAM 4GB 게이트 — `life_helper/device_caps` MethodChannel + `kAiMinRamBytes = 4 GiB`. 7 boundary unit 통과. ✅
- [ ] **AC-7**: **실 Android 단말 E2E** — 후보 ∈ {L2, L3} + `validateFrameLevel` 통과 ≥ 1. **DEFER** — 단위/통합 PASS, 실기 검증은 사용자 권고 (#218 노트에 가이드 첨부). 결과 도착 시 본 항목 갱신 + Redmine 노트 보강.
- [x] **AC-8**: opt-out 시 즉시 삭제 + meta clear + 토스트. F2 try/catch 적용. ✅
- [x] **AC-9**: RAM < 4GB / OOM / timeout 10s 시 빈 리스트 + 수동 입력 경로 보존. ✅
- [⊘] **AC-10**: 한국어 30 corpus ≥ 70% — **#221 로 분리** (out-of-scope).
## 4. 컨텍스트 & 제약
### 의존성
- **#215 v0.2.0** 완료 상태 (커밋 `0d1db2d`). 모든 도메인/UI/Riverpod 골격 + `MockLlmService` 100% 통과 전제.
- **`flutter_gemma 0.16.5`** (pub.dev 확정, 2026-06-12 기준 latest stable, 약 40시간 전 publish).
- Public API: `FlutterGemma.initialize(huggingFaceToken: String)`, `FlutterGemma.installModel(modelType: ModelType.gemma4).fromNetwork(url).install()`, `FlutterGemma.getActiveModel(maxTokens: 2048)`, `model.createChat()`, `chat.addQueryChunk(Message.text(text, isUser))`, `chat.generateChatResponseAsync()``Stream<ModelResponse>` (`TextResponse | FunctionCallResponse | ThinkingResponse`).
- Function calling: **Gemma 4 native function calling** — 별도 `Tool` 객체 주입 없이 `ModelType.gemma4` 의 chat template 이 자동 라우팅. 모델이 호출 결정 시 스트림에 `FunctionCallResponse(name, args)` 1건 emit. (Gemma 4 / Gemma 3n / Phi-4 등 지원 명시)
- Schema 전달 경로: prompt 본문에 JSON Schema 를 자연어로 명시 (Gemma 4 의 chat template 이 인식). 별도 `tools: [...]` 파라미터는 0.16.5 의 createChat 인터페이스 기준 옵션이지 필수 아님 — **OQ-C** 에서 확정.
- **Gemma 4 E2B 모델** — HuggingFace `litert-community/gemma-4-E2B-it-litert-lm` repo. **OQ-A 확정 (2026-06-12):** 정확 파일 = `gemma-4-E2B-it.litertlm` (2,588,147,712 bytes ≈ **2.41GB disk**), SHA-256 = `181938105e0eefd105961417e8da75903eacda102c4fce9ce90f50b97139a63c`. 모바일 1GB QAT 변종은 현시점 미공개 (Google 6월 blog 발표 자산 아직 HF 미게시). peak RAM 추정 ≈ 1.52GB (가중치 ≈ 1.3GB + KV cache + activation).
- **HF access token** — joungmin 본인 계정의 read-only token. 빌드 시점 `--dart-define=HF_TOKEN=hf_xxx` 으로 주입, 런타임에 `String.fromEnvironment('HF_TOKEN')` 으로 읽어 `FlutterGemma.initialize` 에 전달. 토큰을 단말 영속 저장 금지.
- **`crypto`** (기존), **`path_provider`** (기존), **`http`** (기존) — 모두 #215 에서 이미 사용 중.
- **Android**: `minSdkVersion 24` (MediaPipe LLM Inference 요구사항). 기존 #204 가 26 이므로 통과 가정.
### 제약
- **HF 토큰 비밀 유지**: 토큰은 .env 만, git ignore, CI 에서 `--dart-define` 으로 주입. APK 내 평문 문자열로 들어가긴 하지만 read-only 권한 + 모델 다운로드 1회용이라 노출 영향 한정.
- **모델 라이선스**: Gemma Terms of Use (https://ai.google.dev/gemma/terms) 사용자 수락 필요. #215 의 동의 다이얼로그에 한 줄 추가 검토 (UI 변경 최소화 위해 Settings 도움말 링크로 처리).
- **단말 RAM**: E2B Q4_0 ≈ 1.5GB peak. RAM < 4GB 차단 (Android `ActivityManager.getMemoryInfo()``totalMem`). 기존 AC-9 정책 재활용.
- **Developer round 2 구현 (2026-06-12):** #215 의 device gate 가 사실은 미구현이라 (#218 QA 라운드 1 에서 적발), 본 이슈에서 신규 추가. 모듈 = `data/ai/device_capabilities.dart` (`DeviceCapabilities` abstract + `PlatformDeviceCapabilities` impl). 네이티브 호출 = `life_helper/device_caps` MethodChannel + `MainActivity.kt``totalMemoryBytes` 메서드 (`ActivityManager.MemoryInfo.totalMem`). 게이트 UI = SettingsScreen 의 `SwitchListTile.onChanged = null` + subtitle 안내. Provider = `deviceMeetsAiRamProvider` (FutureProvider<bool>, fail-closed). 임계값 = `kAiMinRamBytes = 4 GiB` (inclusive).
- **`flutter_gemma` 0.16.5 의 `generateChatResponseAsync` 스트림은 token-level stream** — `FunctionCallResponse` 는 단일 이벤트 emit 후 stream done 가능, 또는 `ThinkingResponse` (Gemma 4 thinking mode) + `TextResponse` 동반 후 `FunctionCallResponse`. → **우리는 첫 `FunctionCallResponse` 만 채택, 나머지 폐기**. thinking mode 는 본 v0.3 에서 비활성 (latency 영향).
- **timeout**: `generateStructured` 호출자가 `.timeout(Duration(seconds: 10))` 적용 (#215 시그니처 계약). flutter_gemma 자체는 timeout API 없음 → Dart `Future.timeout` 으로 감싸고 timeout 발생 시 `session.close()` 까지 호출.
- **한국어 token 효율**: Gemma 4 tokenizer (SentencePiece, vocab ≈ 256K) 가 한국어 BPE 효율 양호 (1 char ≈ 1.2 token, Gemma 3 대비 개선). prompt 가 너무 길어지면 latency 폭증 → few-shot 카탈로그를 5개로 제한 (#215 §8 그대로).
### 가정
- joungmin 보유 Android 단말 1대 이상 (RAM ≥ 8GB, Android 13+) — AC-7 검증 필수.
- HF account 1개 (joungmin) + Gemma 라이선스 수락 완료.
- flutter_gemma 가 Android 측에서 자체적으로 OpenGL ES 백엔드 사용 (GPU). CPU fallback 은 0.16.5 가 자동 처리.
- pub.dev 의 `flutter_gemma 0.16.5` 가 향후 6개월 내 breaking change 없음 (semver patch 만 갱신 허용).
## 5. 아키텍처 개요
### 변경 범위 (added/changed 만)
```
app/
├── lib/
│ ├── data/
│ │ └── ai/
│ │ ├── gemma_llm_service.dart ★ 본문 교체 (stub → 실 구현)
│ │ └── model_lifecycle.dart △ purge() F2 try/catch 추가
│ ├── state/
│ │ └── ai_providers.dart △ _kModelUrl / _kModelSha 상수 치환
│ │ (이름에서 Placeholder 제거)
│ └── main.dart △ Mock → Gemma 조건부 override
├── android/app/
│ ├── build.gradle △ minSdkVersion 24 확인
│ └── src/main/AndroidManifest.xml △ uses-feature OpenGL ES 3.0
│ + ProGuard rules (proguard-rules.pro)
├── pubspec.yaml △ flutter_gemma: ^0.16.5
└── test/
└── data/ai/
└── gemma_llm_service_test.dart ★ 신규
```
설계서 #215`lib/domain/ai/`, `lib/ui/`, `frame_candidate.dart`, `suggest_frame.dart` 등은 **변경 0건**. 단위 테스트도 기존 31개 전부 유지.
### 데이터 흐름 (변경된 노드만 빨간색 마킹)
```
[main.dart]
ProviderScope.overrides = [
appDatabaseProvider,
llmServiceProvider.overrideWith((ref) {
// ▼ 본 설계서 변경 지점
final settings = ref.watch(aiSettingsProvider).value ?? false;
final avail = ref.watch(modelAvailabilityProvider).value;
final path = avail?.modelPath;
if (settings && path != null) {
return GemmaLlmService(modelPath: path); // ★ 실 구현
}
return MockLlmService(); // graceful fallback
}),
]
▼ (사용자가 #215 흐름 그대로 진입)
[suggestFrame] (#215, 변경 없음)
[LlmService.generateStructured(prompt, schema)] (#215 abstract, 변경 없음)
[GemmaLlmService.generateStructured] ★ 본 설계서 §7 + fn-*.md
├─► model = await FlutterGemma.getActiveModel(maxTokens: 2048)
├─► chat = await model.createChat()
├─► schemaPrompt = _appendSchemaInstruction(prompt, schema)
│ // Gemma 4 native function calling 은 prompt 본문에
│ // function name + JSON schema 안내가 들어가면 자동 라우팅
├─► await chat.addQueryChunk(Message.text(text: schemaPrompt, isUser: true))
├─► stream = chat.generateChatResponseAsync()
├─► await for (event in stream) {
│ if (event is FunctionCallResponse && event.name == 'emit_frame_candidates') {
│ result = event.args;
│ break; // 첫 FCR 만 채택
│ }
│ }
├─► await chat.close() // 세션 정리
└─► return result;
```
### I/O ↔ 순수 로직 경계
- `lib/data/ai/gemma_llm_service.dart` = I/O 경계 (flutter_gemma native call + Dart Future timeout).
- `lib/domain/ai/` = 변경 0 (순수 유지).
- `_appendSchemaInstruction(prompt, schema)` 어댑터는 `gemma_llm_service.dart` 의 file-private top-level 순수 함수. 단위 테스트 가능 (입력 prompt + schema → 기대 string 비교).
- `_collectFunctionCall(stream, name)` 도 file-private. fake `Stream<ModelResponse>` 로 단위 테스트.
## 6. 데이터 모델
본 설계서는 **신규 도메인 모델 0건**. #215`FrameCandidate`, `SuggestFrameInput`, `ModelAvailability`, `DownloadProgress` 전부 재사용.
### `_kModelUrl` / `_kModelSha256` 상수 (치환)
```dart
// app/lib/state/ai_providers.dart
const _kModelUrl =
'https://huggingface.co/litert-community/gemma-4-E2B-it-litert-lm/resolve/main/gemma-4-E2B-it.litertlm';
const _kModelSha256 =
'181938105e0eefd105961417e8da75903eacda102c4fce9ce90f50b97139a63c';
const _kModelTotalBytes = 2588147712; // 2.41 GiB — UI 표시 용
// 향후 QAT 모바일 1GB 변종이 HF 에 게시되면 swap. v1 은 위 base .litertlm.
```
`Placeholder` 접미사 제거. `meta_kv['ai_model_path']`, `meta_kv['ai_model_sha256']` 키도 의미는 동일 (값만 실체).
### Function calling 스키마 (변경 없음 — `kFrameCandidatesSchema`)
`#215` 의 JSON Schema 를 그대로 사용. flutter_gemma `Tool.parameters` 가 JSON Schema 호환이므로 1:1 매핑.
```json
{
"name": "emit_frame_candidates",
"description": "Return 3 framed habit goal candidates at L2 or L3 level.",
"parameters": { ... (§7 #215 ) ... }
}
```
### HF 토큰 (런타임 만)
```dart
// lib/data/ai/gemma_llm_service.dart 의 top-level
const _hfToken = String.fromEnvironment('HF_TOKEN', defaultValue: '');
```
빈 문자열이면 `FlutterGemma.initialize` 호출 시 throw → graceful 경로로 `MockLlmService` 유지.
## 7. 함수 명세 (Function Specs)
> 본 설계서가 새로 손대는 함수만. 그 외는 #215 §7 표 그대로.
| 함수 | 책임(1줄) | 시그니처 | 입력 | 출력 | 에러/실패 | 복잡? |
|------|-----------|----------|------|------|-----------|-------|
| `GemmaLlmService.load` | flutter_gemma 모델 파일 → 메모리 로드 | `Future<void> load()` | (modelPath 필드) | void | FileSystemException / MissingHFToken / OOM → 그대로 throw | **복잡** → [fn-gemma_llm_service.md](./fn-gemma_llm_service.md) |
| `GemmaLlmService.generateStructured` | schema → Tool, FunctionCallResponse 수집 | `Future<Map<String,dynamic>> generateStructured(String, Map)` | prompt, schema | parsed JSON args | StateError(미로드), FormatException(빈 응답), TimeoutException(외부) | **복잡** → [fn-gemma_llm_service.md](./fn-gemma_llm_service.md) |
| `GemmaLlmService.unload` | session + model close, _loaded=false | `Future<void> unload()` | none | void | idempotent — 미로드 상태에도 safe | 단순 |
| `_appendSchemaInstruction` (file-private) | prompt 본문에 function schema 안내 문자열 append | `String _appendSchemaInstruction(String prompt, Map<String,dynamic> schema)` | prompt, schema | augmented prompt | schema 의 name/parameters 누락 시 ArgumentError | 단순 (string concat + JSON serialize) |
| `_collectFunctionCall` (file-private) | Stream<ModelResponse> 에서 첫 FCR 추출 | `Future<Map<String,dynamic>> _collectFunctionCall(Stream<ModelResponse>, String)` | stream, expectedName | args | 다른 name FCR → throw FormatException; stream done 전 FCR 없음 → FormatException | 단순 (state machine 1단) |
| `ModelLifecycle.purge` (수정) | F2 hardening — File.delete try/catch | (시그니처 동일) | none | int | 파일 미존재/권한 → log warn + 카운트 0, throw 안 함 | 단순 (try/catch 1개 추가만) |
## 8. 흐름 / 알고리즘
### 시나리오 A: 첫 실 다운로드 + 첫 추론
1. 사용자가 v0.3.0 APK 설치 (HF 토큰 빌드 임베드 상태).
2. AI 토글 ON → 동의 다이얼로그 → 다운로드 시작.
3. `ModelLifecycle.download()``_kModelUrl` (실 HF endpoint) 으로 HTTP GET (HF 가 LFS redirect 처리, `http` 패키지 follow redirect).
4. 다운로드 완료 → SHA-256 검증 (`_kModelSha256` 와 비교).
5. `meta_kv['ai_model_path']` 저장 → `modelAvailabilityProvider``ready` 로 전환.
6. `main.dart` 의 override 가 `GemmaLlmService(modelPath: ...)` 인스턴스 반환 시작.
7. 사용자가 HabitCreate → "AI 제안" 탭 → `frameSuggestionsProvider` 구독.
8. `suggestFrame``llm.isLoaded == false``llm.load()` 호출.
9. `GemmaLlmService.load`:
- `FlutterGemma.initialize(huggingFaceToken: _hfToken)` 1회 (top-level `_initialized` guard).
- `await FlutterGemma.installModel(modelType: ModelType.gemma4).fromFile(modelPath).install()``ModelLifecycle` 가 이미 파일을 받아둔 상태이므로 `fromNetwork` 대신 `fromFile` (또는 `fromAsset`) 경로.
- `_model = await FlutterGemma.getActiveModel(maxTokens: 2048)`.
- `_loaded = true`.
- cold start ≈ 13 초.
10. `suggestFrame``buildFewShotPrompt` (#215 그대로) → `llm.generateStructured(prompt, kFrameCandidatesSchema)`.
11. `GemmaLlmService.generateStructured`:
- `_loaded` 검사. false → `StateError`.
- `augmented = _appendSchemaInstruction(prompt, schema)` — schema 의 name/parameters 를 prompt 끝에 JSON 형태로 append. Gemma 4 native chat template 이 FCR 로 자동 변환.
- `chat = await _model.createChat()`. (sampling 파라미터 temperature/topK/topP 는 0.16.5 의 model-level 또는 chat-level 설정 — **OQ-C** 에서 확정)
- `await chat.addQueryChunk(Message.text(text: augmented, isUser: true))`.
- `stream = chat.generateChatResponseAsync()`.
- `result = await _collectFunctionCall(stream, 'emit_frame_candidates')`:
- `await for (event in stream) { ... }` 로 첫 `FunctionCallResponse` 만 채택.
- `TextResponse` / `ThinkingResponse` 는 skip.
- 다른 name → `FormatException('unexpected function: ${event.name}')`.
- stream done 까지 FCR 없음 → `FormatException('no function call emitted')`.
- `await chat.close()` (finally 블록).
- return `result`.
12. `suggestFrame``parseFrameCandidates(result)` (#215 그대로) → L0/L1 폐기 후 후보 3개 반환.
13. UI 표시.
### 시나리오 B: opt-out (F2 hardening 검증)
1. 사용자가 AI 토글 OFF.
2. `AiSettingsController.setOptIn(false)`
- `ref.read(modelDownloadControllerProvider.notifier).cancel()`.
- `await ref.read(modelLifecycleProvider).purge()`:
- 기존 코드: `await File(path).delete()` (no try/catch — F2).
- 본 설계: try/catch 로 감쌈. 실패 시 (권한, 외부 삭제) log + 0 반환.
3. `meta_kv` clear, opt_in='false'.
4. 토스트.
### 시나리오 C: HF 토큰 누락 (debug 빌드)
1. 개발자가 `--dart-define=HF_TOKEN` 없이 빌드.
2. `_hfToken == ''`.
3. 사용자 토글 ON 시도 → 다운로드 시작 (HF endpoint 가 토큰 없으면 401).
4. `ModelLifecycle.download` 가 HTTP 401 emit → 기존 `friendly_error` 로 "다운로드 실패, 다시 시도" 표시.
5. graceful 유지.
### 시나리오 D: 모델 응답이 함수 호출 없이 plain text
1. `chat.generateChatResponseAsync()``TextResponse` 만 stream.
2. `_collectFunctionCall` 이 stream done 까지 FCR 없으면 `FormatException`.
3. `suggestFrame` (#215) 의 catch 가 빈 리스트 반환 → 다이얼로그 "다시 시도".
4. dev log 에 "FCR not emitted, model returned plain text" 기록 (prompt 본문은 X).
## 9. 엣지케이스 & 에러 처리
| 상황 | 처리 | 비고 |
|------|------|------|
| HF endpoint 가 LFS 미디어 URL 로 302 redirect | 기존 `http` 패키지 follow redirect 옵션 ON 으로 처리 | flutter_gemma 가 자체 download 메서드 갖고 있어도 우리는 `ModelLifecycle.download` 유지 (resume + SHA 통일) |
| `FlutterGemma.initialize` 가 두 번째 호출에 throw | top-level `bool _hfInitialized = false` 가드 | 0.16.5 idempotent 여부 미확정 시 보호 |
| `_appendSchemaInstruction` 호출에서 schema 가 #215 형식과 다름 | ArgumentError | 본 설계에선 발생 불가 (`kFrameCandidatesSchema` 고정) |
| `_collectFunctionCall` 도중 stream error event | try/catch 으로 `FormatException` 변환 | error.toString() 폐기 (prompt 누설 방지) |
| `chat.close()` 가 throw | `unawaited` + log warn, 호출자에 전파 X | 다음 호출에 영향 없음 보장 |
| `unload()` 호출 시 `_model == null` | early return | idempotent |
| Android RAM 4GB 미만 단말 | 기존 #215 §9 device gate 동작 (모델 다운로드 자체 차단) | flutter_gemma load 이전 단계에서 거름 |
| flutter_gemma OOM (Q4_0 모델인데도) | native exception → Dart 측 `Exception``suggestFrame` catch → 빈 리스트 | 사용자에겐 #215 의 "응답 없음" 메시지 |
| Stream done event 가 옴 그러나 FCR 또한 옴 | break 으로 빠진 후 close — 정상 | 첫 FCR 가 진실, 이후 이벤트는 폐기 |
| ProGuard 가 MediaPipe 클래스 strip | release 빌드 시 crash | `proguard-rules.pro``-keep class com.google.mediapipe.** { *; }` 추가 |
### 안전한 기본값
- `_hfToken` 누락 → mock 경로 유지 (override 안 함).
- `_kModelUrl` / `_kModelSha256` 가 빈 문자열 또는 `<HEX_64_FROM_HF_LFS_POINTER>` 같은 sentinel 이면 다운로드 시작 안 함 → graceful.
- 모든 native exception 은 `suggestFrame` 에서 catch → 빈 리스트 (#215 계약 유지).
## 10. 테스트 계획
### 단위 테스트 (신규/수정)
| AC | 테스트 | 위치 | 모킹 |
|----|--------|------|------|
| AC-1 | `flutter analyze` + APK debug build CI | scripts/ci | — |
| AC-3 | `model_lifecycle_test.dart` Range header 테스트 — 기존 + 실 URL host header 검증 | test/data/ai | mock HTTP |
| AC-4 | `model_lifecycle_test.dart``_kModelSha256` 가 sentinel 일 때 skip 분기 | test/data/ai | tmp file |
| AC-7 (단위 부분) | `gemma_llm_service_test.dart``_appendSchemaInstruction` 변환 + `_collectFunctionCall` 의 4 케이스 (FCR 만 / Text+FCR / Thinking+FCR / Text 만) | test/data/ai | mock `Stream<ModelResponse>` (`flutter_gemma` 의 response 타입 fake) |
| AC-7 (E2E) | 수동: APK 실 단말 설치 → 토글 ON → 다운로드 → "술 끊고 싶어" → 후보 ≥ 1 + 모두 L2/L3 | QA 수동 | 실 Gemma |
| AC-8 (F2) | `model_lifecycle_test.dart``purge()``File.delete` throw 해도 정상 return | test/data/ai | mock FileSystem (`MemoryFileSystem` 가능 시) 또는 read-only tmp |
| AC-9 | `gemma_llm_service_test.dart``_loaded=false` 인 채 `generateStructured` 호출 시 StateError | test/data/ai | direct |
### Mock 전략
- **flutter_gemma 직접 mock 불가** (final class 가능성) → `LlmService` 추상화는 그대로 두고, `GemmaLlmService` 내부의 `_schemaToTool` / `_collectFunctionCall` 만 단위 테스트.
- `_collectFunctionCall(stream, name)``Stream<ModelResponse>` 만 받으므로 `Stream.fromIterable([...])` 로 fake event 주입 가능.
- 단위 테스트가 flutter_gemma SDK 의 ModelResponse 타입을 import 해야 함 → `flutter_gemma: ^0.16.5` 의존성을 dev_dependencies 가 아닌 dependencies 로.
### E2E (수동, QA 단계)
- 단말: joungmin Android 8GB+ 1대.
- 시나리오: 시나리오 A 전체 (다운로드 → cold inference → 후보 1개 탭 → habit 저장).
- 측정: cold latency, warm latency, 메모리 peak (Android Studio profiler 1회).
## 11. 리스크 & 대안 검토
### 본 설계서 내 결정
| 결정 | 채택 | 대안 | 근거 |
|------|------|------|------|
| Gemma 4 E2B (3n 또는 3 27B 아님) | ✓ | Gemma 3n E2B / Gemma 3 27B | Gemma 4 = 2026-04-02 출시, E2B 가 모바일 전용 SKU, function calling 네이티브 지원, QAT 모바일 양자화 (≈1GB) 까지 출시 (2026-06) — ADR-0003 결정 #2 유지 |
| `flutter_gemma 0.16.5` pin | ✓ | 0.16.x range / latest | 2026-06-12 기준 latest stable (40h 전 publish), Gemma 4 + FCR + `.task`/`.litertlm` 모두 지원 |
| function calling (FCR) | ✓ | 자유 텍스트 + 정규식 fallback (ADR-0004 후보) | flutter_gemma 0.16.5 가 Gemma 4 native FCR 공식 지원 — Planner 핵심 리스크 해소, ADR-0004 불필요 |
| HF 토큰 빌드 임베드 | ✓ | 사용자 본인 토큰 입력 UI | v1 은 joungmin 1인 — UX 0 비용, 보안 영향 한정 (read-only) |
| 첫 FCR 만 채택 (Thinking 등 skip) | ✓ | 모든 이벤트 누적 후 last FCR | 명확한 종료 시점 + close() 호출 가능. Gemma 4 thinking mode 는 latency 영향 커서 v1 비활성 |
| `_collectFunctionCall` 파일-private | ✓ | top-level / 별도 파일 | 캡슐화 (테스트는 `@visibleForTesting`) |
| Mock fallback 유지 | ✓ | 옵션 강제 Gemma | graceful — F2/F1 작업 없이도 release 가능 |
### 핵심 리스크
- **HF 모델 URL 변경**: Google 이 HF repo path 변경 시 `_kModelUrl` 깨짐. → `meta_kv` 에 마지막 성공 URL 캐싱, 재시도 시 두 후보 (configured + cached) 비교 검토는 v2.
- **flutter_gemma breaking change**: 0.17.x 가 FCR API 깨면 우리만 묶임. → pubspec.lock 동결 + 분기마다 release note 모니터링.
- **HF account quota**: joungmin token 의 다운로드 제한. → 단일 사용자라 영향 0. 다인 배포 시 v2 토큰 UI.
### 되돌리기 어려운 결정 → ADR 후보
- **본 설계서는 신규 ADR 발행 안 함**. ADR-0003 결정 #3 (function calling) 이 그대로 유지됨이 research 로 확정.
- E4B 지원 추가 = ADR-0004 후보 (Phase 2-C, 별 이슈).
## 12. 미해결 질문 (Open Questions)
| OQ | 질문 | 상태 | 결정 |
|----|------|------|------|
| **OQ-A** | Gemma 4 E2B 모바일 HF 파일명 + SHA-256? | ✅ 해결 (Developer 2026-06-12) | `litert-community/gemma-4-E2B-it-litert-lm` repo 의 `gemma-4-E2B-it.litertlm` (2,588,147,712 B). SHA256 = `181938105e0eefd105961417e8da75903eacda102c4fce9ce90f50b97139a63c`. QAT 1GB 변종은 미공개. |
| **OQ-B** | `huggingFaceToken` 빈 문자열 시 throw? | ✅ 해결 | **즉시 throw 안 함** — 다운로드 시점까지 deferred. 빈 토큰은 public model 만 허용. 우리는 `null` 이 더 정확하지만 빈 문자열도 안전. |
| **OQ-C** | sampling 파라미터 위치? | ✅ 해결 | **chat-level**`model.createChat(temperature: 0.4, topK: 40, topP: 0.95)`. `getActiveModel``maxTokens` / `preferredBackend` / `maxConcurrentSessions` 만. (caveat: NPU backend 는 sampling 무시.) |
| **OQ-D** | Android ProGuard rules? | ✅ 해결 | flutter_gemma example app 의 27-line 사본 적용: io.flutter.\*, play.core.\*, mediapipe.\*, protobuf.\*, kotlinx.coroutines.\*. |
| **OQ-E** | Gemma ToU 동의 UI? | ✅ 해결 | Google 표준 템플릿 없음. Settings AI 섹션 하단에 "Gemma 이용약관(https://ai.google.dev/gemma/terms)에 동의합니다" 한 줄. 다이얼로그 본문 변경 없음 (변경 최소화). |
| **OQ-F** | thinking mode off 스위치? | ✅ 해결 | `model.createChat(isThinking: false)`**default 가 false** 라 명시 안 해도 무방하나 명시적으로 박는다. |
---
## 부록: 자가 점검 (Architect 가 작업 종료 시 검증)
- [x] §1~§12 모든 섹션 채워짐.
- [x] #215 와의 통합점 명확: `LlmService` 추상, `kFrameCandidatesSchema`, `meta_kv` 키, UI/도메인 0 변경.
- [x] 신규 ADR 발행 안 함 — flutter_gemma 0.16.5 의 FCR 지원이 ADR-0003 결정 #3 을 유지.
- [x] 5개 의사결정 (Planner 인계) 모두 반영: ① Gemma 4 E2B 단일 ② Google 호스팅 (HF `litert-community/gemma-4-E2B-it-litert-lm`) ③ flutter_gemma 0.16.5 + Gemma 4 native FCR ④ RAM 4GB 게이트 ⑤ graceful 정책 유지.
- [x] 모델 명칭 정정: Planner 가 "Gemma 3n" 으로 명시했으나 검증 결과 **Gemma 4** 가 맞음 (2026-04-02 출시, E2B/E4B 모바일 SKU + native FCR). Planner 의 "3n 정정" 자체가 잘못된 정정이었음. 본 설계서는 Gemma 4 로 정정 반영.
- [x] AC 10개 모두 §3 + §10 1:1 매핑.
- [x] 복잡 함수 → 1개 `fn-*.md` (`fn-gemma_llm_service.md`).
- [x] §12 OQ 5개 모두 Developer 단계 또는 QA 단계로 routing.
- [x] graceful degradation 명시: `_hfToken` 누락, sentinel SHA, native exception 모두 mock 경로로 폴백.
- [x] 프라이버시: prompt 본문은 log 에 X, HF 토큰 단말 영속 X.
- [x] out-of-scope 명확: #219 F1, #220 F2 광범위, #221 AC10, #222 keystore.

View File

@@ -0,0 +1,378 @@
# 함수 설계서: `GemmaLlmService.load` + `generateStructured` + 보조 (#218)
> **부모 설계서**: ./README.md · **상태**: Approved (v2, 2026-06-12 — Reviewer 1b90f58, Release v0.3.0)
> **작성**: [AI] Architect (2026-06-12) · **구현 대상**: `app/lib/data/ai/gemma_llm_service.dart` · **테스트**: `app/test/data/ai/gemma_llm_service_test.dart`
## 변경 이력 (v2, 2026-06-12 Developer 검증 후)
flutter_gemma 0.16.5 의 `InferenceChat` 구현을 직접 읽어 확인한 결과:
- Gemma 4 (ModelType.gemma4) 의 function calling 은 **SDK 가 `createChat(tools: [Tool(...)])` 의 tools 목록에서 `<|tool>declaration:...<tool|>` 토큰을 직접 렌더**한다 (`lib/core/chat.dart:94`).
- 따라서 §C `_appendSchemaInstruction` 는 Gemma 4 에선 **double-wrap** 을 유발한다. v2 에선 **§C 제거**, §B 는 `Tool` 객체를 `createChat` 에 전달하는 방식으로 변경.
- §D `_collectFunctionCall` 는 변경 없음 — 여전히 `Stream<ModelResponse>` 에서 첫 `FunctionCallResponse` 만 추출.
남은 4 함수 (§A load / §B generateStructured / §C deprecated / §D collectFunctionCall) 중 코드 대상은 3 개.
이 문서는 `GemmaLlmService` 가 노출하는 2 개 public 메서드 + 2 개 file-private 헬퍼를 한 묶음으로 설계한다. 모두 flutter_gemma 0.16.5 의 native 경계를 다루므로 한 문서에서 다루는 게 응집도 측면에서 옳다.
| # | 함수 | 가시성 |
|---|------|-------|
| §A | `GemmaLlmService.load()` | public |
| §B | `GemmaLlmService.generateStructured(prompt, schema)` | public |
| §C | `_appendSchemaInstruction(prompt, schema)` | file-private (`@visibleForTesting`) |
| §D | `_collectFunctionCall(stream, expectedName)` | file-private (`@visibleForTesting`) |
`unload()` 는 단순 (`await _model?.close(); _loaded = false;`) 이므로 별도 섹션 없음.
---
## §A. `GemmaLlmService.load()`
### 1. 시그니처
```dart
@override
Future<void> load();
```
### 2. 책임 (단일 책임, 1줄)
디스크의 `modelPath` 모델 파일을 flutter_gemma native runtime 으로 메모리 적재하고 `_loaded = true` 로 표시한다.
### 3. 입력
| 파라미터 | 타입 | 제약/검증 | 설명 |
|----------|------|-----------|------|
| (instance field) `modelPath` | `String` | 절대 경로, `File(path).existsSync() == true` 가정 | 생성자에서 주입. `ModelLifecycle` 가 다운로드 + SHA 검증 완료 시점에만 유효 |
| (top-level const) `_hfToken` | `String` | `String.fromEnvironment('HF_TOKEN', defaultValue: '')`. 빈 문자열도 허용 (이미 다운로드 완료된 모델은 토큰 불필요할 수 있음) | 빌드 시 `--dart-define=HF_TOKEN=hf_xxx` 으로 주입 |
### 4. 출력
- **반환**: `Future<void>`.
- **부수효과**:
- `FlutterGemma.initialize(huggingFaceToken: _hfToken)` — top-level `_initialized` 가드로 1회만.
- `FlutterGemma.installModel(modelType: ModelType.gemma4).fromFile(modelPath).install()` — flutter_gemma 의 active model 슬롯에 모델 등록.
- 인스턴스 필드 `_model``FlutterGemma.getActiveModel(maxTokens: 2048)` 결과 저장.
- 인스턴스 필드 `_loaded = true`.
### 5. 동작 / 알고리즘
1. `if (_loaded) return;` — idempotent.
2. `if (!await File(modelPath).exists()) throw FileSystemException('model file missing', modelPath);`
3. top-level guard: `if (!_initialized) { await FlutterGemma.initialize(huggingFaceToken: _hfToken); _initialized = true; }`
4. `await FlutterGemma.installModel(modelType: ModelType.gemma4).fromFile(modelPath).install();`
5. `_model = await FlutterGemma.getActiveModel(maxTokens: 2048);`
6. `_loaded = true;`
7. (no `try/catch` here — 모든 예외 caller 에 그대로 전파. `suggestFrame` 의 outer catch 가 graceful 처리)
### 6. 에러 & 실패 모드
| 조건 | 처리 | 반환/예외 |
|------|------|-----------|
| `modelPath` 의 파일 부재 | early throw | `FileSystemException` |
| `_hfToken` 빈 문자열인데 flutter_gemma 가 토큰 요구 | flutter_gemma 의 throw 그대로 | `Exception` (OQ-B 에서 정확 타입 확정) |
| MediaPipe / LiteRT native OOM | native exception → Dart 변환 | `Exception` / `PlatformException` |
| `installModel` 중간에 disk 권한 에러 | flutter_gemma 의 throw 그대로 | `FileSystemException` |
| `getActiveModel``null` (모델 등록 실패) | guard → throw | `StateError('active model missing after install')` |
### 7. 엣지케이스
- **두 번째 호출**: `_loaded == true` → 즉시 return. 같은 `GemmaLlmService` 인스턴스에서 `unload()``load()` 재호출은 정상 동작 (top-level `_initialized` 는 유지, install 만 재실행).
- **다른 인스턴스에서 이미 active model 있음**: flutter_gemma 0.16.5 의 `installModel` 이 active slot 교체 — 우리는 단일 인스턴스 가정이라 무영향.
- **modelPath 가 .litertlm 인데 ModelType.gemma4 와 불일치**: 형식 자동 감지 (확장자 기반). 실패 시 throw.
- **앱 background → foreground 사이클**: `_model` 핸들 유지. native runtime 이 OS 에 의해 강제 종료된 경우 첫 inference 호출에서 에러 → caller 가 `unload()` + `load()` retry 결정 (v1 은 retry 없음, graceful 빈 리스트).
### 8. 복잡도 / 성능
- **시간**: cold start 13 초 (모델 파일 read + native init + tokenizer load). 첫 호출 만, 이후 캐시.
- **공간**: peak RAM ≈ 1.52GB (Gemma 4 E2B Q4 .litertlm, 가중치 ~1.3GB + KV cache + activation). disk ≈ 2.41GB.
- **호출 빈도**: 사용자 1 세션 당 01 회 (#219 F1 의 60s idle unload 가 들어오면 다회 가능).
### 9. 의존성
- `package:flutter_gemma/flutter_gemma.dart` (^0.16.5)
- `dart:io` (`File`)
- `String.fromEnvironment('HF_TOKEN')` (build-time inject)
- `ModelLifecycle` (직접 import 안 함 — `_loaded` 보장 책임만 caller 에 위임)
### 10. 테스트 케이스
> flutter_gemma native 직접 호출은 단위 테스트에서 모킹 불가능 (final class 가능성). 본 함수는 **E2E (실 단말, AC-7)** 로만 검증. 단위 테스트는 §C / §D 에 집중.
- [E2E] `modelPath` 가 실 모델 → `_loaded == true` + 후속 `generateStructured` 1회 성공.
- [unit] `modelPath` 가 미존재 파일 → `FileSystemException` (`File.exists()` 만 검증, flutter_gemma 미진입).
- [unit] 두 번 호출 → 두 번째는 noop (counter 증가 X).
### 11. 추적성
- 인수조건: #218 AC-1 (build 성공) + AC-6 (cold start 3s 이내) + AC-9 (OOM graceful).
- 관련 ADR: ADR-0003 (on-device LLM Gemma, 결정 #2 — E2B 단일).
---
## §B. `GemmaLlmService.generateStructured(prompt, schema)`
### 1. 시그니처
```dart
@override
Future<Map<String, dynamic>> generateStructured(
String prompt,
Map<String, dynamic> schema,
);
```
### 2. 책임 (단일 책임, 1줄)
loaded 상태의 Gemma 4 모델에 prompt + JSON Schema 를 전달하여 단일 function call 응답 (`args: Map`) 을 받아 반환한다.
### 3. 입력
| 파라미터 | 타입 | 제약/검증 | 설명 |
|----------|------|-----------|------|
| `prompt` | `String` | non-empty. caller 가 `.length ≤ 4096` 보장 (#215 buildFewShotPrompt). | 시스템 prompt + few-shot + 사용자 raw text |
| `schema` | `Map<String, dynamic>` | `schema['name']: String`, `schema['parameters']: Map` 필수. 본 설계에선 항상 `kFrameCandidatesSchema` 고정. | function calling JSON Schema (#215 §6) |
### 4. 출력
- **반환**: `Future<Map<String, dynamic>>``FunctionCallResponse.args` 그대로. `kFrameCandidatesSchema` 기준이면 `{ "candidates": [...] }` 구조.
- **부수효과**:
- flutter_gemma chat session 1개 생성 후 `chat.close()` 으로 정리.
- 모델 latent state 변경 (다음 호출은 fresh chat).
- log: prompt length, latency, FCR 수신 여부 (prompt 본문 X — 프라이버시).
### 5. 동작 / 알고리즘 (v2)
```
1. if (!_loaded) throw StateError('LlmService not loaded');
2. final fnName = schema['name'] as String;
3. final fnDesc = (schema['description'] as String?) ?? '';
4. final fnParams = schema['parameters'] as Map<String, dynamic>;
5. final tool = Tool(name: fnName, description: fnDesc, parameters: fnParams);
6. final chat = await _model!.createChat(
modelType: ModelType.gemma4,
supportsFunctionCalls: true,
toolChoice: ToolChoice.required, // 강제 FCR
tools: [tool],
);
7. try {
8. await chat.addQueryChunk(Message.text(text: prompt, isUser: true));
9. final stream = chat.generateChatResponseAsync();
10. final args = await _collectFunctionCall(stream, fnName);
11. return args;
12. } finally {
13. await chat.close(); // 항상 정리
14. }
```
caller (#215 `suggestFrame`) 가 `.timeout(Duration(seconds: 10))` 적용 → timeout 시 본 함수의 `await` 가 throw 됨 → finally 의 `chat.close()` 가 실행되어 native session leak 방지.
### 6. 에러 & 실패 모드
| 조건 | 처리 | 반환/예외 |
|------|------|-----------|
| `_loaded == false` | early throw | `StateError` |
| `schema['name']` 또는 `schema['parameters']` 누락 | `_appendSchemaInstruction` 가 throw | `ArgumentError` |
| stream 이 `FunctionCallResponse` emit 안 함 (Text 만, 또는 empty) | `_collectFunctionCall` 가 throw | `FormatException('no function call emitted')` |
| 다른 name 의 FCR | `_collectFunctionCall` 가 throw | `FormatException('unexpected function: ${actualName}')` |
| stream 자체 error event | catch → throw (본문은 log 안 함, name 만) | `FormatException('stream error')` |
| caller timeout | finally 에서 close, exception 전파 | (caller 의 `TimeoutException`) |
| native runtime crash | flutter_gemma 가 PlatformException | 그대로 전파 |
### 7. 엣지케이스
- **첫 token 이 Thinking → Text → FCR 순서**: §D 가 첫 FCR 만 채택, 나머지 skip.
- **FCR 두 번 emit**: 첫 번째 채택 후 break — stream 미소진 채 `chat.close()` 호출. flutter_gemma 가 graceful 처리 가정 (OQ).
- **`args``null`**: §D 에서 검사, throw `FormatException('null args')`.
- **`args['candidates']` 가 Map 으로 옴 (List 아님)**: 본 함수 책임 밖. caller 의 `parseFrameCandidates` (#215) 가 `FormatException` 으로 처리.
- **prompt UTF-8 길이 vs token 길이 불일치**: caller 책임. 본 함수는 prompt 길이 검증 X.
### 8. 복잡도 / 성능
- **시간**: warm 0.52초 / cold (load 직후) 추가 13초. function calling 1턴이라 stream 길이 짧음 (~200 token).
- **공간**: chat 인스턴스 ~ 수십 MB (KV cache). close 시 회수.
- **호출 빈도**: habit 생성 화면 진입 시 사용자 trigger. throttle 5회/세션 (#215).
### 9. 의존성
- `flutter_gemma`: `FlutterGemma.getActiveModel` 결과의 `createChat` / `Message.text` / `ModelResponse`.
- `_appendSchemaInstruction` (§C)
- `_collectFunctionCall` (§D)
### 10. 테스트 케이스
- [unit] `_loaded = false``StateError`. (직접 검증)
- [E2E] AC-7 — 실 단말에서 prompt + `kFrameCandidatesSchema``args['candidates']` 3개 반환.
- [unit] caller timeout 시 finally close 호출 확인 — 간접 (`_collectFunctionCall` 가 await never-completing future 일 때 외부 timeout → exception 후 chat.close mock 카운터).
### 11. 추적성
- 인수조건: #218 AC-6 (latency), AC-7 (E2E candidates), AC-9 (graceful).
- 관련 ADR: ADR-0003 결정 #3 (function calling).
---
## §C. (DEPRECATED — v2) `_appendSchemaInstruction(prompt, schema)`
> **v2 결정**: Gemma 4 SDK 가 `Tool` 객체에서 직접 declaration 토큰을 렌더하므로, prompt 측에서 schema 안내문을 덧붙이면 double-wrap 이 된다. **본 함수는 구현하지 않는다.**
>
> 아래 §C 본문은 v1 (gemmaIt fallback) 시나리오용 참고 자료로 보존하나, v2 코드 대상에서 제외한다. 단위 테스트도 작성하지 않는다.
원본 본문 (참고용):
### 1. 시그니처
```dart
@visibleForTesting
String appendSchemaInstruction(String prompt, Map<String, dynamic> schema);
```
> 파일 내부에선 `_appendSchemaInstruction` 으로 호출, 테스트는 public `appendSchemaInstruction` 으로 re-export.
### 2. 책임 (단일 책임, 1줄)
prompt 본문 끝에 Gemma 4 chat template 이 인식할 function call 안내 (name + JSON Schema) 를 append 한다.
### 3. 입력
| 파라미터 | 타입 | 제약/검증 | 설명 |
|----------|------|-----------|------|
| `prompt` | `String` | non-empty | 시스템 + few-shot + 사용자 입력 |
| `schema` | `Map<String, dynamic>` | `schema['name']: String`, `schema['parameters']: Map` 필수 | function calling schema |
### 4. 출력
- **반환**: `String``prompt + '\n\n' + 안내문` 형태.
- **부수효과**: **순수 함수**.
### 5. 동작 / 알고리즘
```
1. final name = schema['name'];
2. if (name is! String || name.isEmpty) throw ArgumentError('schema.name missing');
3. final params = schema['parameters'];
4. if (params is! Map) throw ArgumentError('schema.parameters missing');
5. final description = schema['description'] as String? ?? '';
6. final paramsJson = const JsonEncoder().convert(params);
7. final block = [
'',
'',
'## Function call instruction',
'You MUST respond by calling the function `$name`.',
if (description.isNotEmpty) description,
'Arguments must conform to this JSON Schema:',
'```json',
paramsJson,
'```',
].join('\n');
8. return prompt + block;
```
순수 함수라 deterministic. 같은 입력에 대해 항상 같은 출력.
### 6. 에러 & 실패 모드
| 조건 | 처리 | 반환/예외 |
|------|------|-----------|
| `schema['name']` 누락/빈 문자열 | throw | `ArgumentError('schema.name missing')` |
| `schema['parameters']` 가 Map 아님 | throw | `ArgumentError('schema.parameters missing')` |
| `prompt` 가 빈 문자열 | 허용 (append 만) | OK |
### 7. 엣지케이스
- `params` 가 빈 Map → `{}` JSON 으로 직렬화. caller 가 의도한 경우면 OK (본 설계엔 발생 안 함).
- `description` 누락 → 해당 라인 생략.
- prompt 끝에 이미 `\n\n` 있음 → 결과 `\n\n\n\n`. Gemma 4 tokenizer 가 무시.
### 8. 복잡도 / 성능
- O(N) — `JsonEncoder` 가 schema 깊이에 비례. `kFrameCandidatesSchema` 는 작아서 < 1ms.
### 9. 의존성
- `dart:convert` (`JsonEncoder`).
- `package:flutter/foundation.dart` (`@visibleForTesting`).
### 10. 테스트 케이스
- [unit] `kFrameCandidatesSchema` 입력 → 반환 string 에 `'emit_frame_candidates'``'\"L2\"' / '\"L3\"'` 포함.
- [unit] `schema['name']` 없음 → `ArgumentError`.
- [unit] `schema['parameters']``List``ArgumentError`.
- [unit] 같은 입력 2회 호출 → 동일 string (순수성 검증).
- [unit] `prompt` 끝 trim 없이 그대로 append 되는지 — exact string compare.
### 11. 추적성
- 인수조건: #218 AC-7 (모델이 FCR 로 응답하려면 안내문이 필요).
- 관련 ADR: ADR-0003 결정 #3.
---
## §D. `_collectFunctionCall(stream, expectedName)`
### 1. 시그니처
```dart
@visibleForTesting
Future<Map<String, dynamic>> collectFunctionCall(
Stream<ModelResponse> stream,
String expectedName,
);
```
### 2. 책임 (단일 책임, 1줄)
`Stream<ModelResponse>` 에서 **첫 `FunctionCallResponse(name == expectedName)`**`args` 만 추출한다. `TextResponse`/`ThinkingResponse` 는 skip, 잘못된 name 은 throw.
### 3. 입력
| 파라미터 | 타입 | 제약/검증 | 설명 |
|----------|------|-----------|------|
| `stream` | `Stream<ModelResponse>` | flutter_gemma 의 union 타입. `TextResponse \| FunctionCallResponse \| ThinkingResponse` 가정 | `chat.generateChatResponseAsync()` 결과 |
| `expectedName` | `String` | non-empty | 매칭할 function name (e.g. `'emit_frame_candidates'`) |
### 4. 출력
- **반환**: `Future<Map<String, dynamic>>` — 첫 매칭 FCR 의 `args`.
- **부수효과**: 없음 — stream 의 첫 매칭 이벤트 후 `await for` 빠져나옴 (cancelSubscription 자동).
### 5. 동작 / 알고리즘
```
1. Map<String, dynamic>? result;
2. String? wrongName;
3. try {
4. await for (final event in stream) {
5. if (event is FunctionCallResponse) {
6. if (event.name == expectedName) {
7. result = Map<String, dynamic>.from(event.args ?? const {});
8. break;
9. } else {
10. wrongName = event.name;
11. break; // 잘못된 함수 — 빠른 실패
12. }
13. }
14. // TextResponse / ThinkingResponse 는 무시 (continue)
15. }
16. } catch (e) {
17. throw FormatException('stream error'); // e.toString() 폐기 (prompt 누설 방지)
18. }
19. if (wrongName != null) {
20. throw FormatException('unexpected function: $wrongName');
21. }
22. if (result == null) {
23. throw FormatException('no function call emitted');
24. }
25. return result;
```
`event.args``null` 이면 빈 Map 으로 대체 → caller 의 `parseFrameCandidates` 가 빈 `candidates` 로 처리하여 빈 리스트 반환.
### 6. 에러 & 실패 모드
| 조건 | 처리 | 반환/예외 |
|------|------|-----------|
| stream done 까지 FCR 없음 | check after loop | `FormatException('no function call emitted')` |
| 다른 name 의 FCR | break + check | `FormatException('unexpected function: ...')` |
| stream error event (native crash 등) | catch | `FormatException('stream error')` (원본 e 폐기 — 본문 누설 X) |
| `event.args == null` | 빈 Map 으로 대체 후 return | (no throw) |
### 7. 엣지케이스
- **첫 이벤트가 곧바로 FCR**: 정상. Text/Thinking 없이 바로 break.
- **Text + Text + FCR + FCR (두 번째 FCR 이 정답 name)**: 첫 FCR 의 name 검증으로 break — `wrongName` 으로 throw. v1 정책: 첫 FCR 만 신뢰. (Gemma 4 가 다중 FCR 보내는 경우 거의 없음. 발생 시 prompt 개선 신호.)
- **Thinking → FCR 순서**: Thinking skip 후 FCR 채택. OK.
- **stream 이 무한 (timeout 없음)**: caller 의 `.timeout(10s)` 에 의존. 본 함수는 자체 timeout X.
- **event 가 `null`** (Dart stream 에 null event): `await for` 에서 false-match → skip. (실제로는 발생 안 함, 방어 안 함.)
### 8. 복잡도 / 성능
- O(N) — N = stream 이벤트 수. function calling 응답은 보통 ≤ 10 events. ~수십 ms.
### 9. 의존성
- `package:flutter_gemma/flutter_gemma.dart``ModelResponse` / `FunctionCallResponse` / `TextResponse` / `ThinkingResponse` 타입.
### 10. 테스트 케이스
> 핵심 단위 테스트 슬롯. flutter_gemma response 클래스를 `Stream.fromIterable([...])` 로 fake 주입 가능.
- [unit] `[FunctionCallResponse('emit_frame_candidates', {'candidates': [...3개...]})]``args` 반환.
- [unit] `[TextResponse('hello'), FunctionCallResponse('emit_frame_candidates', {...})]` → Text skip 후 args 반환.
- [unit] `[ThinkingResponse('...'), TextResponse('...'), FunctionCallResponse('emit_frame_candidates', {})]` → 빈 args Map 반환 (no throw).
- [unit] `[FunctionCallResponse('wrong_name', {})]``FormatException('unexpected function: wrong_name')`.
- [unit] `[TextResponse('only text')]` (FCR 없이 done) → `FormatException('no function call emitted')`.
- [unit] `Stream.error(...)` event → `FormatException('stream error')` (원본 메시지 미포함 검증).
- [unit] `[FunctionCallResponse('emit_frame_candidates', null)]` → 빈 Map 반환 (`{}`), no throw.
- [unit] `[]` 빈 stream → `FormatException('no function call emitted')`.
### 11. 추적성
- 인수조건: #218 AC-7 (FCR 수집 성공), AC-9 (graceful — `FormatException` 이 caller 의 빈 리스트 반환으로 전환).
- 관련 ADR: ADR-0003 결정 #3.
---
## 부록: 자가 점검
- [x] §A~§D 4 함수 모두 시그니처 + 책임 + 에러 + 테스트 케이스 채움.
- [x] §D 테스트 케이스 8개 — flutter_gemma 의 ModelResponse 변종 (Text/Thinking/FCR/error/empty) 모두 커버.
- [x] §C 순수성 강조 — `_appendSchemaInstruction` 은 외부 I/O 0, deterministic.
- [x] 프라이버시: §B 와 §D 모두 catch 시 `e.toString()` 폐기 (prompt 본문 누설 방지).
- [x] timeout 책임 분리: 본 모듈은 timeout 미구현, caller 의 `.timeout(10s)` 에 의존. finally close 로 native session leak 방지.
- [x] `@visibleForTesting` 으로 file-private 함수도 단위 테스트 가능.
- [x] AC-7 의 E2E 부분은 §A `load` + §B `generateStructured` 에서만 검증, §C/§D 는 단위 테스트로 100% 커버.

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

@@ -0,0 +1,98 @@
# AI 도움 켜기·끄기 (사용자 가이드)
> 적용 버전: **v0.3.0 이상** (v0.2.0 은 placeholder URL 로 다운로드 graceful 실패) · Redmine #215 / #218 · 관련 레퍼런스: [docs/reference/215-ai-frame-suggest.md](../reference/215-ai-frame-suggest.md)
>
> v0.3.0 부터 **단말 RAM ≥ 4GB** 가 필수입니다. 4GB 미만 단말은 설정 화면의 토글이 비활성으로 표시됩니다.
life-helper 는 사용자가 입력한 자유 문장(예: "술 끊고 싶어")을 Huberman 프로토콜 기반 L2/L3 프레임 문장으로 변환해주는 **단말 내 AI 보조**를 제공합니다. 모든 처리는 단말에서만 일어나며, 입력 텍스트는 외부로 전송되지 않습니다.
## 누구를 위한 가이드인가
- 새 습관을 추가할 때 "어떻게 표현하면 좋을지" 막막한 사용자.
- AI 기능을 켜기 전에 데이터/저장공간/배터리 영향을 미리 확인하고 싶은 사용자.
## 핵심 원칙
1. **기본 OFF.** AI 기능은 사용자가 명시적으로 켜야 동작합니다.
2. **단말 처리.** 입력 텍스트는 단말 밖으로 나가지 않습니다.
3. **수동 입력 100% 유지.** AI 가 꺼져 있거나 모델 다운로드가 안 되어 있어도, "프레임 문구" 입력란에 직접 작성하는 경로는 항상 살아있습니다.
## AI 도움 켜기
1. 하단 탭에서 **설정** 진입.
2. "AI 도움 켜기" 토글 탭.
3. 동의 다이얼로그가 뜹니다:
- **파일 크기 ≈ 2.4GB** (Gemma 4 E2B Q4_0 모델 — 단말에 한 번만 다운로드)
- **WiFi 연결 권장** (셀룰러 대역폭 절약)
- 모든 처리는 단말 — 입력 텍스트 외부 송출 없음
4. **"동의하고 다운로드"** 탭 → 백그라운드 다운로드 시작.
### 다운로드 진행 화면
설정 화면 "AI 도움" 섹션 아래에 진행 상태가 표시됩니다:
| 상태 | 표시 | 가능한 조작 |
|---|---|---|
| 다운로드 중 | 진행률 % + 받은 용량 | **일시정지** 버튼 |
| 일시정지됨 | 마지막 진행률 | **재개** 버튼 (이어받기) |
| 실패 | 한국어 안내 메시지 (네트워크/서버/손상별) | **다시 시도** 버튼 |
| 준비 완료 | "준비 완료" 라벨 + 받은 용량 | (조작 없음 — 사용 가능) |
다운로드 도중 앱을 강제 종료해도 다음 실행 시 같은 자리에서 이어받습니다 (HTTP Range 기반).
### 다운로드 실패 시 한국어 안내
| 화면 메시지 | 의미 | 권장 조치 |
|---|---|---|
| "네트워크 연결을 확인하고 다시 시도해주세요." | 일시적 끊김 | WiFi 확인 후 [다시 시도] |
| "서버 응답이 올바르지 않습니다. 잠시 후 다시 시도해주세요." | 서버 측 문제 | 시간 두고 [다시 시도] |
| "다운로드가 중단되었어요. 다시 시도하면 이어받습니다." | 스트림 중단 | [다시 시도] — 받은 데이터는 보존 |
| "파일이 손상되었어요. 다시 시도하면 처음부터 받습니다." | 무결성 검증 실패 | [다시 시도] — 자동으로 처음부터 |
## AI 사용하기
1. **새 습관** 화면 진입.
2. "제목" 에 자유 문장 입력 (예: "술 끊고 싶어").
3. "프레임 문구" 입력란 아래 **✨ AI 제안** 버튼 탭.
- AI 도움이 꺼져 있으면 버튼이 보이지 않습니다.
- AI 도움은 켜졌지만 모델 다운로드가 아직 완료되지 않았다면 버튼은 비활성 상태로 보이고 "AI 도움을 먼저 켜주세요" 툴팁이 표시됩니다.
4. 다이얼로그에 후보가 **최대 3개** 표시됩니다 (L2 조건부 긍정 2개 + L3 정체성 1개 권장).
5. 마음에 드는 후보 카드 탭 → "프레임 문구" 입력란이 자동으로 채워지고 프레임 레벨이 자동 선택됩니다.
6. 저장.
### 후보가 없거나 마음에 안 들 때
- "더 구체적으로 입력해주시면 더 좋은 제안을 드릴 수 있어요" 메시지가 보이면 제목을 더 명확히 작성한 뒤 [다시 시도].
- 제안을 받지 못해도 **프레임 문구를 직접 입력하셔도 괜찮습니다**.
## AI 도움 끄기
1. **설정** → "AI 도움 켜기" 토글 OFF.
2. 확인 다이얼로그:
- 모델 파일이 단말에서 **즉시 삭제** 됩니다.
- 약 2.4GB 의 저장공간이 확보됩니다.
- 다시 켜면 다시 다운로드해야 합니다.
3. **"끄고 삭제"** 탭 → "공간 확보됨 2469 MB" 토스트.
진행 중인 다운로드가 있어도 깔끔히 중단되고, `.tmp` 임시 파일까지 함께 삭제됩니다.
## 자주 묻는 질문
**Q. 입력 텍스트가 외부로 나가나요?**
A. 아니요. 단말 내 추론만 사용합니다. 다운로드는 모델 파일을 받을 때 한 번만 발생합니다.
**Q. AI 가 만들어준 문장이 마음에 안 들면?**
A. 직접 입력란을 고쳐 쓰면 됩니다. AI 제안은 채우기 도우미일 뿐, 저장 시점 검증(L0/L1 금지 등)은 변하지 않습니다.
**Q. 모델 파일이 너무 큽니다.**
A. 언제든 끌 수 있고, 끄면 즉시 삭제됩니다. 다시 켜면 다시 받아야 한다는 점만 유의하세요.
**Q. v0.2.0 에서 다운로드가 항상 실패합니다.**
A. v0.2.0 은 모델 URL 이 미확정 (OQ-1) 인 상태로 출시되어, 실제 다운로드는 의도된 graceful 실패 경로로 안내됩니다. **v0.3.0 부터 실 Gemma 4 E2B 모델 (HuggingFace) 다운로드가 활성화되었습니다.** 그동안 수동 입력 경로는 정상 동작합니다.
## 관련 문서
- 설계서: [docs/design/215-gemma-frame-suggest/](../design/215-gemma-frame-suggest/)
- 결정 기록: [docs/adr/0003-on-device-llm-gemma.md](../adr/0003-on-device-llm-gemma.md)
- API 레퍼런스: [docs/reference/215-ai-frame-suggest.md](../reference/215-ai-frame-suggest.md)
- 변경 이력: [CHANGELOG.md](../../CHANGELOG.md)

View File

@@ -0,0 +1,193 @@
# Reference: AI 프레임 제안 (#215 + #218, v0.3.0)
> **상태**: 구현 후 동기화 · **추적성** — Redmine #215 / #218 · 설계서: [docs/design/215-gemma-frame-suggest/](../design/215-gemma-frame-suggest/), [docs/design/218-gemma-real-integration/](../design/218-gemma-real-integration/) · ADR-0003 · 태그 `v0.2.0` (placeholder) → `v0.3.0` (real Gemma 4, commit da60dd1)
>
> 본 문서는 v0.3.0 의 **실제 코드 사양**이다. v0.2.0 의 placeholder 상태는 #218 에서 실 Gemma 4 E2B + flutter_gemma 0.16.5 통합으로 대체됨. 설계 의도/대안은 설계서·ADR 을 참조.
## 1. 모듈 지도
```
lib/
data/ai/
llm_service.dart — LlmService 추상 + MockLlmService
gemma_llm_service.dart — GemmaLlmService (flutter_gemma 0.16.5 + Gemma 4 E2B 실 구현)
model_lifecycle.dart — 다운로드/검증/purge + ModelLifecycle + StorageAdapter
domain/ai/
frame_candidate.dart — FrameCandidate, FrameLevel (enum)
suggest_frame.dart — suggestFrame() 메인 함수 + L2:2+L3:1 분포
few_shot_builder.dart — buildFewShotPrompt()
parse_response.dart — parseFrameCandidates()
state/
ai_providers.dart — Riverpod providers + ModelDownloadController
ui/
screens/settings_screen.dart — AI 도움 토글 + 다운로드 진행률
widgets/frame_suggestion_dialog.dart — 후보 카드 선택
screens/habit_create_screen.dart — _AiSuggestButton (3분기)
```
## 2. 도메인 모델
### `FrameCandidate` (`lib/domain/ai/frame_candidate.dart`)
| 필드 | 타입 | 의미 |
|---|---|---|
| `level` | `FrameLevel` | `l0` / `l1` / `l2` / `l3` (출력에는 L2/L3 만 살아남음) |
| `framedText` | `String` | 모델이 생성한 한국어 문장 (≤120자) |
| `confidence` | `double` | 0.0~1.0 (모델이 반환한 score, 없으면 0.5) — UI 표시 X |
| `sourcePatternId` | `String?` | few-shot 매칭에 쓰인 `FramePattern.id` |
### Function-calling 스키마 (`kFrameCandidatesSchema`)
`suggest_frame.dart` 상단의 `const Map<String, dynamic>`. `emit_frame_candidates` 함수의 parameters. `minItems:1 / maxItems:3`, 각 `item.required = ['level','framed_text']`.
## 3. 핵심 함수
### `suggestFrame(input, {llm, framePatterns, timeout=10s}) → List<FrameCandidate>`
순수에 가까움 (`llm` + `framePatterns` 만 의존). **절대 throw 하지 않음**. 모든 실패 → `const []`.
흐름:
1. `input.rawText.trim()` 길이 검사 (1~200자). 벗어나면 빈 리스트.
2. `buildFewShotPrompt(input, framePatterns)` 로 prompt 조립.
3. `llm.generateStructured(prompt, schema).timeout(10s)` 호출. 어떤 예외든 catch → 빈 리스트.
4. `parseFrameCandidates(json)` 으로 디코드. `FormatException` catch → 빈 리스트.
5. 각 후보에 `validateFrameLevel` 적용. `reject` 인 후보만 드랍.
6. `_shapeDistribution(validated, l2Quota:2, l3Quota:1)` — L2 먼저 최대 2개 + L3 최대 1개. **부족 시 패딩 X** (graceful — 적은 카드).
### `buildFewShotPrompt(input, framePatterns, {maxFewShot=5}) → String`
순수. `_tokenize` (whitespace + 한국어 punctuation 분리) → `_scorePattern` (avoidanceKeyword 3점 + domain 1점) → 상위 N개. 매칭 0 이면 시드 앞에서 N개. patterns 비어있으면 시스템 prompt 만 emit.
마지막에 명시 지시: `"L2 2개 + L3 1개, 총 3개 후보로 변환. 순서는 L2 → L2 → L3. emit_frame_candidates 함수로 호출."`
### `parseFrameCandidates(json) → List<FrameCandidate>`
- 최상위 `candidates` 없거나 `List` 아니면 `throw FormatException`.
- 개별 item 결손 (level/framed_text 미존재, 빈 문자열, >120자) → 조용히 skip.
- `level` 은 대소문자 무시 매칭.
- `confidence` 결손 시 0.5 기본값, 범위 밖이면 `clamp(0, 1)`.
## 4. 데이터 계층
### `LlmService` (abstract)
```dart
abstract class LlmService {
bool get isLoaded;
Future<void> load();
Future<void> unload(); // idempotent
Future<Map<String, dynamic>> generateStructured(String prompt, Map schema);
}
```
계약:
- `load``isLoaded == true`.
- 미로드 상태에서 `generateStructured` 호출 → `StateError`.
- 스키마/응답 깨짐 → `FormatException`.
- timeout 은 **호출자 책임** (`suggestFrame` 가 10s 적용).
구현 2개:
- `MockLlmService``enqueueResponse(Map)` / `enqueueError(Object)`. `callCount`, `lastPrompt`, `lastSchema`, `responseDelay` 노출. **v1 런타임 기본 주입**.
- `GemmaLlmService` — flutter_gemma 0.16.5 + Gemma 4 E2B 실 구현 (#218). `load``FlutterGemma.initialize``installModel(modelType: gemma4, fileType: litertlm).fromFile(modelPath).install()``getActiveModel(maxTokens: 2048)`. `generateStructured``createChat(modelType: gemma4, supportsFunctionCalls: true, toolChoice: required, tools: [Tool(...)])` + `collectFunctionCall(stream, fnName)` 로 SDK 의 native function calling 사용. `_lazyLlmService` (main.dart) 가 ModelLifecycle 의 availability 에 따라 Gemma vs Mock 자동 분기.
### `ModelLifecycle` (`lib/data/ai/model_lifecycle.dart`)
생성자 의존성: `MetaDao meta`, `ModelConfig config`, `StorageAdapter? storage`, `http.Client? httpClient`.
| 메서드 | 시그니처 | 비고 |
|---|---|---|
| `checkAvailability` | `Future<ModelAvailability>` | `ready` / `missing` / `corrupt` / `downloading`. 옵트인 OFF → 항상 `missing`. SHA mismatch → `corrupt`. 어떤 I/O 예외든 catch → `corrupt`. |
| `download` | `Stream<DownloadProgress>` | Range 기반 resume. SHA-256 검증 후 `.tmp → final.rename()`. 모든 실패는 `state: failed` 로 emit (throw X). |
| `purge` | `Future<int>` | 해제된 byte 수. 파일 + `.tmp` + meta_kv 4키 (`ai_opt_in` 제외) 삭제. F2 hardening (#218): `File.delete()` / `temp.delete()` / `meta.remove()` 각각 try/catch 로 감쌈 → 단일 OS 플레이크가 opt-out 을 막지 않음. freed-bytes 는 성공한 삭제만 합산. |
`StorageAdapter``supportDir()` + `rangeGet(Uri, int)` 2개 메서드만 — 테스트가 `_FakeStorage` 로 주입.
### meta_kv 키 5개 (`AiMetaKeys`)
| 키 | 값 | 의미 |
|---|---|---|
| `ai_opt_in` | `'true'` / `'false'` | 사용자 옵트인 |
| `ai_model_path` | 절대경로 | 다운로드 완료 시 |
| `ai_model_sha256` | hex string | 검증 통과 시 |
| `ai_download_state` | `'idle'` / `'downloading'` / `'paused'` / `'completed'` / `'failed'` | 진행 상태 |
| `ai_download_bytes` | int as string | 재시작 시 resume 좌표 |
→ Drift schema 변경 0. `meta_kv` 테이블은 #204 에서 이미 존재.
## 5. 상태 계층 (Riverpod, `lib/state/ai_providers.dart`)
| Provider | 타입 | 책임 |
|---|---|---|
| `modelLifecycleProvider` | `Provider<ModelLifecycle>` | 실 URL: `https://huggingface.co/litert-community/gemma-4-E2B-it-litert-lm/resolve/main/gemma-4-E2B-it.litertlm`, SHA-256 `181938105e...39a63c`, 2.41GB (#218) |
| `aiSettingsProvider` | `FutureProvider<bool>` | meta_kv 읽어서 옵트인 상태 |
| `aiSettingsControllerProvider` | `Provider<AiSettingsController>` | `setOptIn(bool) → int(freed)` |
| `modelDownloadControllerProvider` | `StateNotifierProvider<ModelDownloadController, DownloadProgress?>` | start / pause / resume / cancel |
| `modelAvailabilityProvider` | `FutureProvider<ModelAvailability>` | `lifecycle.checkAvailability()` |
| `framePatternsProvider` | `FutureProvider<List<FramePatternModel>>` | Drift → 도메인 |
| `llmServiceProvider` | `Provider<LlmService>` | **반드시 override**`main.dart``MockLlmService` 주입 |
| `frameSuggestionsProvider` | `FutureProvider.autoDispose.family<List<FrameCandidate>, SuggestFrameInput>` | `llm.load` (실패 시 빈 리스트) → `suggestFrame` |
### `AiSettingsController.setOptIn(value)`
- `value=true`: `meta_kv['ai_opt_in']='true'` → invalidate(settings, availability) → `ModelDownloadController.start()` 호출 (AC2 — 다운로드 스트림 시작).
- `value=false`: `ModelDownloadController.cancel()``ModelLifecycle.purge()``meta_kv['ai_opt_in']='false'` → invalidate. 반환: 해제된 byte 수.
### `ModelDownloadController`
- `start()`: 기존 subscription cancel 후 `lifecycle.download().listen(...)`. 완료 시 `modelAvailabilityProvider` invalidate.
- `pause()`: subscription cancel + state 를 `paused` 로. `.tmp` 파일 + meta_kv 보존 → 다음 `start()` 가 Range 로 resume.
- `resume()` = `start()` alias.
- `cancel()`: subscription cancel + state = `null` (idle).
## 6. UI 계층
### `SettingsScreen` (`lib/ui/screens/settings_screen.dart`)
- `SwitchListTile` — 토글 시 옵트인은 동의 다이얼로그 (저장공간/WiFi/단말 처리 3개 bullet) → `setOptIn(true)`. 옵트아웃은 확인 다이얼로그 → `setOptIn(false)` → "공간 확보됨 X.X MB" 토스트.
- `_DownloadProgressTile` — 옵트인 ON + 진행 중일 때만 표시. 상태별 컬러 라벨 + 둥근 `LinearProgressIndicator(minHeight:6)` + `FilledButton.tonalIcon` 재개/재시도. `_friendlyError()` 가 내부 코드를 한국어로 매핑:
- `network:*` → "네트워크 연결을 확인하고 다시 시도해주세요."
- `http *` → "서버 응답이 올바르지 않습니다."
- `stream:*` → "다운로드가 중단되었어요. 다시 시도하면 이어받습니다."
- `sha mismatch` → "파일이 손상되었어요. 다시 시도하면 처음부터 받습니다."
### `_AiSuggestButton` (3분기, AC6)
| optIn | availability | 렌더 |
|---|---|---|
| false | * | `SizedBox.shrink()` (숨김) |
| true | `!= ready` | `TextButton` (disabled) + `Tooltip("AI 도움을 먼저 켜주세요")` |
| true | `ready` | `TextButton` (enabled, tap → `FrameSuggestionDialog.show`) |
### `FrameSuggestionDialog`
`AlertDialog` 안에 `frameSuggestionsProvider(input).when(loading/error/data)`. data 가 empty 면 "더 구체적으로 입력해주시면 더 좋은 제안을 드릴 수 있어요" + "다시 시도" 버튼. data 가 있으면 `_CandidateCard` 리스트 — L3 는 `scheme.primary` 배지, L2 는 `scheme.secondary` 배지. 탭 시 `Navigator.pop(c)``FrameCandidate` 반환.
## 7. 테스트 매핑
| AC | 테스트 파일 | 케이스 수 |
|---|---|---|
| AC1 | `flutter analyze` + `flutter build apk --debug/release` | CI |
| AC2 | `test/state/model_download_controller_test.dart` | 3 |
| AC3, AC8 | `test/data/ai/model_lifecycle_test.dart` | 7 |
| AC4 | `test/domain/ai/suggest_frame_test.dart` (분포 3) | 3 |
| AC5 | `test/domain/ai/suggest_frame_test.dart` (FrameLevel 사용) | 1 |
| AC6 | `test/ui/ai_suggest_button_visibility_test.dart` | 4 |
| AC7 | `test/domain/ai/parse_response_test.dart` | 8 |
| AC9 | `test/domain/ai/suggest_frame_test.dart` (graceful) | 다수 |
| AC10 | (별도 이슈 #221 — corpus 품질 평가) | — |
| #218 AC8 | `test/data/ai/gemma_llm_service_test.dart` (`collectFunctionCall` 8 + ParallelFCR 2) | 10 |
신규 합계 41 (parse 8 + few_shot 7 + suggest 9+3 + lifecycle 7 + AC6 widget 4 + AC2 state 3 + gemma_llm 10). v0.3.0-dev 시점 전체 81 통과 / analyze 0.
## 8. 알려진 제약
- **#218 AC-6 (디바이스 게이트)**: 저사양 단말 (RAM < 4GB) 에서는 SettingsScreen 의 AI 토글 disabled + "이 단말에서는 AI 도움을 사용할 수 없어요 (RAM 4GB 이상 필요)" 안내. RAM 조회 = MethodChannel `life_helper/device_caps``MainActivity.kt` 에서 `ActivityManager.MemoryInfo.totalMem`. `device_info_plus` 도 deps 에 있지만 RAM 임계 (4GB) 측정엔 미사용 (해당 패키지는 `isLowRamDevice` ≈ 1GB 만 제공). iOS 는 #218 범위 밖.
- **#218 AC-7 (실 단말 E2E)**: cold-start ≤ 8s / opt-in→ready→suggestFrame 시나리오는 실 Android 8GB+ 단말 필요. Host CI 는 검증 불가 → 별도 디바이스 랩 단계.
- **F1**: 설계서 §8.8 / §9 의 "60초 idle 시 `unload()`" 미구현. #219 별도 이슈 — 단발 호출 후 즉시 unload 가 안전한 기본값.
- **#221 AC10 corpus 품질 평가**: 30+ 한국어 라이브 입력 corpus 로 L2 ≥ 70% / L3 ≥ 50% 측정. 실 모델 통합 후 별도 이슈.
## 9. 다음 단계 / 확장 포인트
- **#215 follow-up 4 이슈** (#218 다음): **#219** 60s idle auto-unload (F1), **#220** purge hardening (F2), **#221** AC10 한국어 corpus 품질 평가 (≥70%), **#222** production keystore / Play Store 준비.
- 시나리오 #2~#6 (앵커 추출 / dose variants / if-then / lapse 구조화 / 주간 요약): 모두 `LlmService.generateStructured` 에 새 schema 추가하는 형태로 확장. 도메인 함수는 `lib/domain/ai/` 에 신규 파일.
- 멀티 모델 슬롯 (E2B + E4B): ADR-0004 후보.