19 Commits
v0.3.0 ... main

Author SHA1 Message Date
c18dca1def [Designer] #342 UX round 2 — chat 빈 상태 + 한국식 날짜 + 표현 방식
남은 P1/P2 3건.

- ChatScreen 빈 상태: 아이콘 + 한 줄 설명 + 예시 prompt 4개
  (tap → _textCtrl 자동 채움, 자동 send X).
- CheckIn 날짜: '2026-06-15' raw → '6월 15일 (월)' 한국식.
  DB 저장은 _ymd 유지.
- HabitCreate '프레임 레벨' → '표현 방식' + helperText.
  아이템: '조건부 행동 (예: 아침에 햇빛 받기)' / '정체성 (예: 나는 일찍 자는 사람)'.
- 설계서 #342 README — D 섹션 + AC-D1/D2/D3 추가.
- CHANGELOG v0.4.2 UX round 2 블록.

167 tests passed, analyze clean.

Refs #342
2026-06-15 15:28:03 +09:00
e81f3e44a4 [Designer] #342 UX round 1 — raw enum → 한국어 라벨 + 스트릭 hero
dev v0.4.2 위 hotfix. v0.4.1 단말 테스트에서 발견된 raw 식별자
노출 P0 3 + P1 2.

- ui/labels.dart 신규 — habitTypeLabel(FromDb) / rewardTierLabel.
  domain enum 의 한국어 라벨 단일 지점 (domain layer 분리).
- habit_list 부제: 'build · L3 · …' → '만들기 · …'.
  FrameLevel 노출 제거 (시스템 규약).
- streak: 'T0' / 'T1' raw → '🌱 새싹' / '🥉 3회 도전' …,
  영문 'Never miss twice' → '이틀 연속 빠졌어요. 한 단계 강등됐습니다',
  현재 스트릭을 displayLarge hero 로 위계 강조.
- habit_create 드롭다운: '만들기 (build)' → '만들기'.
- 설계서 docs/design/342-v042-hotfix/README.md — A/B/C 11 AC.
- CHANGELOG v0.4.2 에 UX round 1 섹션 추가.

167 tests passed, analyze clean. APK 재빌드 보류 (사용자 결정).

Refs #342
2026-06-15 15:23:05 +09:00
3b8ea95aa6 [hotfix] #342 v0.4.2 — ChatScreen SafeArea + LLM 에러 진단 노출
Fix
- ChatScreen body 를 SafeArea(top: false) 로 감쌈. Android edge-to-edge
  모드에서 시스템 nav bar 가 입력창을 덮던 문제 해결.

Dev (#342)
- userTurn catch 블록이 e.toString() + stack trace 를 error 상태에 저장.
- 빨간 에러 컨테이너를 SingleChildScrollView + SelectableText (monospace)
  + 최대 화면 1/3 높이 제약. 스크롤 + 복사 가능. release 빌드에서도
  full stack 노출 (#342 종료 후 follow-up 으로 좁힘).

테스트: chat_session_controller_test 8/8 통과.
APK: app-release.apk 301.0MB SHA 02a5d1c8.

Refs #342
2026-06-15 14:30:21 +09:00
94a9cd474b [Architect] #312 design spec — tool call prefix corpus & 조건부 push
설계서 3 + 절차서 1.
- README.md: 기능 설계서 (15 케이스 corpus, 임계 5/15, 경로 A/B)
- fn-corpus_logger.md: optional debug logger (kDebugMode + dart-define 가드)
- fn-userTurn_partial_push.md: chat_providers.dart 의 break 분기 수정안 (경로 A/B)
- corpus-procedure.md: 빌드/캡처/15 프롬프트/임계 판정 절차

R1-R5 모두 해소 (Architect 채택안).
ADR-0006 슬롯 = 경로 B 채택 시 작성 (Developer 단계).

Refs #312
2026-06-15 14:17:47 +09:00
41457ab96e [08-Documenter] #311 설계서 Approved + reference + 사용자 가이드 FAQ
- docs/design/311-llm-warmup/ 3파일 상태 Draft → Approved (v0.4.1)
- docs/reference/311-llm-warmup.md 신규 (상태 머신, API, UI binding,
  마이크로카피, 테스트)
- docs/guides/ai-chat-using.md 헤더 + FAQ 2건 추가 (warmup 인지/회복)

Refs #311
2026-06-15 13:25:38 +09:00
121108f63c [07-Release] #311 v0.4.1 — ChatScreen LLM warm-up
CHANGELOG 0.4.1 항목 + pubspec 0.4.0+4 → 0.4.1+5.

Release artifact:
- app-release.apk 288MB (301.0MB raw / 288MB on-disk)
- SHA-256: 62b96a876afbda24e1cd3b9bf95db742255db5f40fc46eb08cb5cf824d3ec481
- Build: flutter build apk --release (Gradle assembleRelease 70.2s)

KGP deprecation 경고 (device_info_plus, flutter_gemma, large_file_handler)
는 v0.4.0 release 노트와 동일. 미해결.

Refs #311
2026-06-15 13:22:34 +09:00
071afefc54 [06-Reviewer] #311 fileMissing 회복 자동화
SettingsScreen pop 후 ChatScreen state 가 여전히 Failed(fileMissing)
인 회로 — 사용자가 다운로드를 끝내고 돌아와도 ChatScreen 전체를
pop & re-push 해야 회복되는 UX gap.

Navigator.push().then((_) => retry()) 로 SettingsScreen 닫힐 때
자동 retry. quickCheck 다시 → ready 면 load → Ready 회복.
다운로드 안 했으면 다시 Failed 로 떨어져 같은 배너 노출 (일관).

`context.mounted` 가드는 ChatScreen 이 dispose 된 race 대비.

Refs #311
2026-06-15 13:17:56 +09:00
7c90eca30c [05-Designer] #311 fileMissing 분기 [설정으로 가기] 버튼
UX-REVIEW.md §마이크로카피 사전에서 fileMissing 케이스에 [설정으로
가기] 를 명시했으나 Developer 구현은 모든 kind 가 [다시 시도] 였음.
fileMissing 에서 retry 해도 파일이 없어 같은 결과 → 사용자 막힘.

- ChatWarmupFailureKind.fileMissing → SettingsScreen push (다운로드
  재시도 경로 노출)
- ChatWarmupFailureKind.runtime → 기존 retry() 유지 (일시적 회복 가능)

여전히 메시지 본문은 상태 기술만 (AC12), 행동은 버튼이 담당 (UX R5).

Refs #311
2026-06-15 13:08:32 +09:00
5b4c05316a [03-Developer] #311 LLM warm-up + concurrent guard + quickCheck
ChatScreen 마운트 시 백그라운드 native init 으로 첫 send 시점에 native
load 지연을 안 보이게 한다. 12개 AC + UX-Reviewer 의 6개 권고 모두 코드
반영.

핵심 변경:
- `chat_warmup_provider.dart` — `ChatWarmupController` (Idle/Loading/Ready
  /Unavailable/Failed sealed state). fast path (`llm.isLoaded` → Ready),
  FileSystemException ↔ runtime kind 분기, _disposed race guard.
- `model_lifecycle.dart` — `quickCheck()`: 2.4GB SHA-256 hashing 없이
  meta_kv + 파일 존재만 보고 ready 추정 (R4 UX 권고).
- `gemma_llm_service.dart` + `llm_service.dart` — `_loadingFuture` 동시
  호출 가드. 두 caller 가 동시에 load() 해도 native init 은 1 회만.
- `chat_screen.dart` — initState postFrameCallback 에서 warmup.start().
  warmup 상태에 따라 hintText / spinner / 실패 banner 분기.

AC coverage (12개):
- AC1~AC8: ChatWarmupController unit (chat_warmup_test.dart 8 tests).
- AC9~AC12: UX-Reviewer 의 4개 권고 (입력 enabled / send auto-activate /
  fast path no-flicker / 명령형 메시지 금지) — controller 레벨에서 검증.

테스트: 167 passed (1 pre-existing skip). `flutter analyze` clean.

Refs #311

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-15 12:52:50 +09:00
1fa4f24a8a [02-Architect] #311 design spec + UX-Reviewer persona for LLM warm-up
- docs/design/311-llm-warmup/README.md — 기능 설계서. ChatWarmupController (5-state) + GemmaLlmService _loadingFuture concurrent guard + ModelLifecycle.quickCheck (lightweight ready).
- docs/design/311-llm-warmup/UX-REVIEW.md — UX-Reviewer parallel pass. Strong 4 + Suggest 2 권고. 입력창 enabled 유지 (타이핑 가능) + hintText 만 교체 + 상태-행동 분리.
- docs/design/311-llm-warmup/fn-chat_warmup_controller.md — start/retry 상세 + 빠른 경로 (isLoaded 시 Loading skip).
- docs/design/311-llm-warmup/fn-concurrent_load_guard.md — _loadingFuture 패턴 + whenComplete cleanup.
- .claude/agents/ux-reviewer.md — 신규 페르소나 (02-Architect 단계 내 parallel reviewer, 카테고리 부여 X).

AC 8 → 12 (UX 신규 4건 통합). OQ 3건 모두 해소. ADR 없음 (backward-compatible 추가).

Refs #311 #260
2026-06-15 11:41:03 +09:00
44d571f4ee [08-Documenter] #260 reference + user guide for v0.4.0 in-app tool calling
- docs/reference/260-in-app-tool-calling.md — 구현 후 동기화. 11 섹션 (모듈 지도 / 6 tool 표 / R 규칙 enforce / multi-turn 루프 / dispatcher / ConfirmGate / 2KB cap / LlmChatSession 두 구현 / UI 진입점 / 테스트 / known limitations).
- docs/guides/ai-chat-using.md — 사용자 대상 how-to. 진입(🤖) / 대화 예시 / 확인 게이트 / 안전장치 / FAQ.
- 후속 4건 Redmine 이슈 발행: #306 koreanLabel 필드 / #307 blank confirm skip / #308 category case-insensitive / #309 KGP deprecation.

Refs #260
2026-06-15 11:19:57 +09:00
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
83 changed files with 8553 additions and 183 deletions

View File

@@ -0,0 +1,44 @@
---
name: ux-reviewer
description: "[AI] UX-Reviewer — 02-Architect 의 설계서 + Planner AC 를 사용자 흐름·정신 모델·마찰 관점에서 검토. 파이프라인 카테고리는 부여하지 않는 parallel reviewer."
tools: Bash, Read, Edit, Write, Grep, Glob
model: opus
---
너는 life-helper 파이프라인의 **[AI] UX-Reviewer** 다.
시작 전에 반드시 읽는다: `CLAUDE.md`, `docs/pipeline/QUEUE-PROTOCOL.md`.
## 위치
- 파이프라인 카테고리 부여 X. **02-Architect 단계 내**에서 설계서가 작성된 직후 parallel 로 동작한다.
- 작업 큐 직접 소비 X — Architect 가 본인 작업 끝낼 때 명시적으로 UX-Reviewer 호출.
- 결과는 Architect 가 흡수 → 03-Developer 인계.
## 역할 (Designer 와의 차이)
| | Designer (05) | UX-Reviewer (이 페르소나) |
|---|---|---|
| 시점 | 코드 작성 후 polish | 설계 단계 (코드 전) |
| 대상 | microcopy / 색상 / contrast | 사용자 흐름 / 정신 모델 / 마찰 |
| 산출물 | 코드 수정 | 설계서에 통합되는 리뷰 노트 |
## 검토 관점
- **흐름**: 사용자가 화면에 들어와서 첫 의미있는 행동까지의 경로. 단계 누락 / 막다른 길 / 되돌아갈 수 없는 지점.
- **정신 모델**: 사용자가 "지금 무슨 일이 일어나는가" 를 추론 가능한가. 상태가 숨어있지 않은가.
- **마찰**: 사용자가 멈춰야 하는 모든 지점 — disabled 상태, 모달, 확인, 에러. 정당한가, 줄일 수 있는가.
- **지연 / 비동기**: 로딩 / 진행 / 실패 상태가 명시적인가. 사용자가 기다리는 동안 무엇을 보는가.
- **에러 회복**: 실패 후 사용자가 다시 시도할 수 있는가. 다음 행동이 명확한가.
- **취소 / 탈출**: 사용자가 흐름을 떠날 수 있는가. 진행 중 state 가 어떻게 되는가.
- **접근성 / 다국어**: 라벨이 한국어 자연 톤인가. 시각 대비. 터치 타겟 크기.
## 산출물
- 설계서 디렉토리에 `UX-REVIEW.md` 추가 또는 README 의 `## UX 리뷰` 섹션.
- 형식: **권고 (Strong / Suggest / Question)** × (관점, 근거, 제안).
- **Strong**: 사용자가 막힐 위험. Architect 가 설계 변경 또는 명시 거절 (OQ로) 해야 함.
- **Suggest**: 사용자 경험이 더 좋아짐. 채택은 Architect 재량.
- **Question**: 결정 전 더 정보 필요. Architect 또는 후속 사용자 인터뷰.
- 각 항목에 Planner AC 번호와 mapping (어느 AC 의 해석을 좁히거나 넓히는지).
## 핸드오프
- 코드/설계 직접 수정 X — 모든 변경은 Architect 가 흡수.
- 산출물 git 커밋 가능 (`[UX-Reviewer] #<ID> ...`).
- Redmine 저널에 1-line 요약.

View File

@@ -3,6 +3,94 @@
본 프로젝트의 모든 의미있는 변경은 본 파일에 기록한다.
형식: [Keep a Changelog](https://keepachangelog.com/) · 버전: [SemVer](https://semver.org/).
## [0.4.2] — 2026-06-15 (hotfix, dev)
### Fixed (Redmine #342)
- **ChatScreen 하단 잘림** — Android edge-to-edge 모드에서 시스템 nav bar (3-button / gesture handle) 가 입력창을 덮던 문제. `Scaffold.body``SafeArea(top: false, …)` 로 감쌈. AppBar 가 이미 top inset 처리하므로 top 만 false.
### UX round 1 — raw enum 노출 정리 (Redmine #342 추가)
- **습관 카드 부제** — `build · L3 · …` (raw enum) → `만들기 · …`. FrameLevel 노출 제거 (시스템 규약이라 사용자 가치 낮음).
- **스트릭 화면 현재 티어** — `T0` / `T1` raw → `🌱 새싹` / `🥉 3회 도전` / `🥈 7일 형성` / `🥇 30일 정착` / `🏆 6주 완주` 이모지+한국어 라벨.
- **스트릭 강등 경고** — `Never miss twice 발동 — 티어 강등` (영문 잠언) → `이틀 연속 빠졌어요. 한 단계 강등됐습니다.`.
- **스트릭 hero 위계** — 현재 스트릭을 `displayLarge` 큰 숫자 + 티어 라벨로 시각 강조 (사용자의 핵심 동기 지표).
- **습관 추가 드롭다운** — `만들기 (build)``만들기` (영어 식별자 병기 제거).
- 신규 `app/lib/ui/labels.dart` — domain enum 의 한국어 라벨 매핑 단일 지점. domain layer 에 `koreanLabel` 두지 않음 (관심사 분리).
### UX round 2 — 빈 상태 + 날짜 + 라벨 명확화 (Redmine #342 추가)
- **ChatScreen 빈 상태 안내** — 첫 진입 시 빈 메시지 리스트 대신 아이콘 + 한 줄 설명 + 예시 prompt 4개 (`아침 햇빛 받기 습관 추가해줘`, `오늘 운동 했어`, `내 스트릭 보여줘`, `수면 프로토콜 알려줘`). tap → 입력창 자동 채움 (자동 send X, 사용자 수정 여지).
- **CheckIn 날짜 한국식** — `2026-06-15` raw → `6월 15일 (월)`. DB 저장은 `_ymd` 유지.
- **HabitCreate 표현 방식** — `프레임 레벨` (의미 모호) → `표현 방식` + helperText `행동 위주 vs 정체성 위주`. 아이템 라벨 `L2 · 조건부 긍정` / `L3 · 정체성``조건부 행동 (예: 아침에 햇빛 받기)` / `정체성 (예: 나는 일찍 자는 사람)` 식 예시 포함.
### Dev
- **LLM 실패 빨간 배너에 full message + stack trace** — 단말 진단을 위해 release 빌드에서도 노출. `LLM 응답 실패: <Type>\n<message>\n--- STACK ---\n<stack>` 형식. SelectableText + monospace + 최대 화면 1/3 높이 + scroll. 사용자 친화 메시지로 좁히는 작업은 #342 종료 후 follow-up.
## [0.4.1] — 2026-06-15
### Added — ChatScreen LLM warm-up (Redmine #311, follow-up of #260)
- **백그라운드 warm-up** — `ChatScreen` 진입 시 `ChatWarmupController``LlmService.load()` 를 백그라운드로 트리거. 첫 send 에서 cold native-init (수 초) 비용 제거.
- **`ModelLifecycle.quickCheck()`** (신규) — SHA-256 재해싱 없이 meta_kv + 파일 존재만으로 ready 추정. ~2.4GB Gemma 4 E2B 파일에 대한 매 mount 마다의 hash 비용 회피.
- **Concurrent load guard** — `GemmaLlmService.load()` + `MockLlmService.load()``_loadingFuture` 가드 추가. ChatScreen warm-up + 동시 `userTurn` lazy load 가 race 해도 native init 1회만 실행.
- **Sealed state machine** — `ChatWarmupState`: Idle / Loading / Ready / Failed(kind) / Unavailable. autoDispose StateNotifier + `_disposed` 가드로 unmount race 방지.
### UX (Designer + Reviewer)
- warmup 중 입력창 `enabled` 유지 — 사용자가 미리 메시지 작성 가능 (UX R1+R2).
- send 자리에 `CircularProgressIndicator(strokeWidth: 2)``isStreaming` 패턴과 일관.
- hintText 교체 — warmup: `AI 준비 중… 첫 시작은 몇 초 걸려요` / 평상: `습관 추가, 기록, 카탈로그 질문…`.
- 실패 메시지는 상태 기술만 (UX R5/AC12) — `AI 모델 파일을 찾을 수 없어요.` / `AI 를 시작하지 못했어요.`. 행동은 버튼이 담당.
- `_WarmupErrorBanner``kind` 분기:
- `fileMissing`**[설정으로 가기]** + `SettingsScreen` push + pop 후 자동 `retry()` (UX R5/R6).
- `runtime`**[다시 시도]** + 즉시 `retry()`.
- `isLoaded=true` 재진입 시 Loading state skip — 1 frame 라벨 깜빡임 방지 (UX R4/AC11).
### Added — Tests
- 167/167 passed (1 pre-existing skip) — 신규 12 (`chat_warmup_test.dart` 8 + `model_lifecycle_test.dart` quickCheck 4).
- AC1~AC2, AC5~AC7, AC11, AC12 controller-level 검증.
- AC3/AC4/AC8/AC9/AC10 widget E2E 는 deferred — `CircularProgressIndicator` 무한 ticker + `Future.delayed``pumpAndSettle` race. `chat_screen_test.dart` NOTE comment 에 사유 명시.
### Docs
- 설계서 `docs/design/311-llm-warmup/` (4 파일) — README + 2 fn-spec + UX-REVIEW.md.
- 신규 페르소나 `ux-reviewer.md` — 02-Architect 단계의 parallel review.
### Known follow-ups (후속 이슈 권장)
- Widget E2E 인프라 개선 (FakeAsync 또는 spinner 가짜 대체) — ticker race 해소.
- 다른 recovery loop 도 `Navigator.push().then((_) => retry())` 패턴 적용 검토.
## [0.4.0] — 2026-06-15
### Added — Phase 2-B in-app tool calling (Redmine #260)
- **In-process Dart tool runtime** (ADR-0005): MCP 와 동등한 capability 추상화를 별도 서버 없이 in-process Dart 함수로 구현. latency 거의 0.
- **6 tools** (`app/lib/ai/tools/`): `search_catalog`, `query_protocol`, `list_active_habits`, `get_streak` (read-only) / `add_habit`, `log_tracker_entry` (destructive).
- **Multi-turn loop** (`ChatSessionController`) — MAX_TURNS=4 안전 cap, 8-turn soft history warning. `ToolChoice.auto` 로 reply-only + tool call 모두 지원.
- **ConfirmGate 모달** — destructive tool 호출 시 AlertDialog (`이 작업을 수행할까요?`) 의무. 좁은 화면 SingleChildScrollView.
- **2KB result cap** (ADR-0005 §OQ-2) — `encodeToolResult` 가 ToolOk payload 초과 시 `_truncated:true` + `_hint` 로 잘림 (`chat_providers.dart:192` 에서 runtime wire).
- **R 규칙 enforce = 핸들러 책임** — 모델 prompt 학습 아닌 코드 게이트. R3 quota, R5 (habit,date) dedup, R7 회피 키워드, R8 XOR (build/break) 모두 ToolErr 코드로 노출.
- **ChatScreen** (`app/lib/ui/screens/chat_screen.dart`) — 신규 AI 코치 화면. HabitListScreen AppBar 의 🤖 entry (AI opt-in 시).
- **schema SoT = Dart 코드** (ADR-0005 §D-4) — `ToolDefinition.parametersSchema` Map 리터럴.
### Polish (Designer)
- ToolCallChatMessage 라벨 한국어화 (`_kToolKoreanLabels``add_habit → 습관 추가` 등 6종 매핑).
- ConfirmDialog content 를 SingleChildScrollView 로 감싸 좁은 폰 + 긴 description 대응.
- Streaming cursor `▍``Text.rich` 로 분리 후 `colorScheme.primary` 적용 — 다크 모드 contrast.
- AppBar tooltip `새 대화``새 대화 (이전 기록 비우기)`.
### Added — Tests
- 154/154 passed (1 skip) — 신규 41 → 43 (tool_envelope 6 + catalog_tools 7 + habit_tools 8 + tracker_tools 7 + dispatcher 6 + controller 8 + widget E2E 2).
- AC-9 회귀: 인위 `huge_dump` tool 로 `_truncated:true` + `_hint` 직접 검증.
- AC-10 widget E2E (`test/ui/chat_screen_test.dart`): add_habit 호출 → ConfirmDialog `수행` → habits +1 / `취소` → 무변화 + `취소됨` 라벨.
### Docs
- 설계서 `docs/design/260-gemma-tool-calling/` (5 파일, 844 라인) — README + 4 함수 fn-spec.
- ADR-0005 — In-app tool calling architecture (4 결정사항).
### Known follow-ups (후속 이슈 권장)
- `ToolDefinition.koreanLabel` 필드 도입 — 현재 `_kToolKoreanLabels` hardcoded.
- `log_tracker_entry` value=blank 시 confirm skip — 현재 done/blank 무차별 모달.
- `search_catalog` category matching case-insensitive — 모델 hallucination 대비.
### Release artifact
- `app-release.apk` 287MB (300.9MB raw / 287MB on-disk), SHA-256 `6670da0c4e9bf5e826174ebc48088540867d877cf58699119a519e2ffb40ea3a`.
- Build: `flutter build apk --release` (Gradle assembleRelease 106.4s).
## [0.3.0] — 2026-06-12
### Added — Phase 2-A OQ-1 resolved: real Gemma 4 E2B inference (Redmine #218)

View File

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

View File

@@ -0,0 +1,171 @@
import '../../domain/catalog/catalog_item.dart';
import '../../domain/catalog/display_category.dart';
import 'tool_definition.dart';
import 'tool_envelope.dart';
/// Read-only catalog tools. `search_catalog` returns trimmed list rows;
/// `query_protocol` returns the full record. Splitting keeps `search` cheap
/// in tokens (OQ-2) and the model fetches detail only when needed.
final ToolDefinition searchCatalogTool = ToolDefinition(
name: 'search_catalog',
description: '카테고리/키워드로 Huberman 프로토콜 카탈로그를 검색한다. '
'결과는 id + 제목 + 60자 요약만. 상세는 query_protocol 로.',
parametersSchema: const {
'type': 'object',
'properties': {
'category': {
'type': 'string',
'description': '카테고리 키 (lightCircadian, sleep, movement, nutrition, '
'focusCognition, recoveryStress, emotionRelationship, breakHabit). '
'생략하면 전체.',
},
'keyword': {
'type': 'string',
'description': '제목/요약에 포함될 키워드. 생략 가능.',
},
'limit': {
'type': 'integer',
'description': '최대 결과 개수 (1~10, 기본 10).',
},
},
'required': [],
},
handler: _searchCatalogHandler,
);
final ToolDefinition queryProtocolTool = ToolDefinition(
name: 'query_protocol',
description: '카탈로그 ID 로 프로토콜 상세를 조회한다. '
'Protocol/Break/Diet 종류에 따라 다른 필드 셋을 반환.',
parametersSchema: const {
'type': 'object',
'properties': {
'id': {
'type': 'string',
'description': '카탈로그 항목 ID (예: morning_sunlight).',
},
},
'required': ['id'],
},
handler: _queryProtocolHandler,
);
Future<ToolResult> _searchCatalogHandler(
Map<String, dynamic> args, ToolDeps deps) async {
final categoryRaw = args['category'];
final keywordRaw = args['keyword'];
final limitRaw = args['limit'];
DisplayCategory? category;
if (categoryRaw is String && categoryRaw.isNotEmpty) {
category = DisplayCategory.values
.where((c) => c.name == categoryRaw)
.firstOrNull;
if (category == null) {
return ToolErr('validation',
'알 수 없는 카테고리: $categoryRaw. 허용값: ${DisplayCategory.values.map((c) => c.name).join(', ')}');
}
}
final keyword = (keywordRaw is String) ? keywordRaw.trim() : '';
if (keyword.length > 50) {
return const ToolErr('validation', 'keyword 는 50자 이하여야 합니다.');
}
var limit = 10;
if (limitRaw is int) {
limit = limitRaw;
} else if (limitRaw is num) {
limit = limitRaw.toInt();
}
if (limit < 1 || limit > 10) {
return const ToolErr('validation', 'limit 는 1~10 사이여야 합니다.');
}
final all = await deps.catalog.all();
Iterable<CatalogItem> filtered = all;
if (category != null) {
filtered = filtered.where((it) => it.displayCategory == category);
}
if (keyword.isNotEmpty) {
final lk = keyword.toLowerCase();
filtered = filtered.where((it) =>
it.title.toLowerCase().contains(lk) ||
it.summary.toLowerCase().contains(lk));
}
final results = filtered.take(limit).toList();
return ToolOk({
'count': results.length,
'items': results
.map((it) => {
'id': it.id,
'title': it.title,
'category': it.displayCategory.name,
'summary': it.summary,
})
.toList(),
});
}
Future<ToolResult> _queryProtocolHandler(
Map<String, dynamic> args, ToolDeps deps) async {
final id = args['id'];
if (id is! String || id.isEmpty) {
return const ToolErr('validation', 'id 는 비어있지 않은 문자열이어야 합니다.');
}
final item = await deps.catalog.byId(id);
if (item == null) {
return ToolErr('not_found', '카탈로그에서 \'$id\' 를 찾을 수 없습니다.');
}
return ToolOk(_serializeItem(item));
}
Map<String, dynamic> _serializeItem(CatalogItem item) {
final base = {
'id': item.id,
'title': item.title,
if (item.titleEn != null) 'title_en': item.titleEn,
'category': item.displayCategory.name,
'summary': item.summary,
if (item.evidenceStrength != null)
'evidence_strength': item.evidenceStrength,
'reference_ids': item.referenceIds,
};
switch (item) {
case ProtocolCatalogItem p:
return {
...base,
'kind': 'protocol',
'what': p.what,
'when': p.whenText,
'dose': p.dose,
'why': p.why,
'how': p.how,
'check': p.checkText,
if (p.caution != null) 'caution': p.caution,
if (p.minDoseForStart != null) 'min_dose_for_start': p.minDoseForStart,
};
case BreakCatalogItem b:
return {
...base,
'kind': 'break',
'break_category': b.breakCategory,
'huberman_summary': b.hubermanSummary,
'phases': b.phases,
'default_common_frames': b.defaultCommonFrames,
if (b.medicalWarning != null) 'medical_warning': b.medicalWarning,
};
case DietCatalogItem d:
return {
...base,
'kind': 'diet',
'name': d.name,
'core': d.core,
if (d.koreanContextFit != null)
'korean_context_fit': d.koreanContextFit,
};
}
}

View File

@@ -0,0 +1,75 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'tool_definition.dart';
/// Modal Confirm gate for destructive tools (ADR-0005 §OQ-3).
///
/// Shown by [ToolDispatcher] right before invoking a destructive handler.
/// Returns `true` only if the user explicitly tapped the confirm action;
/// outside-tap / back-press / unmounted-context all return `false`.
class ConfirmGate {
const ConfirmGate();
Future<bool> show(
BuildContext context,
ToolDefinition tool,
Map<String, dynamic> args,
) async {
if (!context.mounted) return false;
final summary = tool.summarize?.call(args) ?? _fallbackSummary(args);
final result = await showDialog<bool>(
context: context,
barrierDismissible: true,
builder: (ctx) {
final theme = Theme.of(ctx);
return AlertDialog(
title: const Text('이 작업을 수행할까요?'),
// SingleChildScrollView 로 감싸 좁은 모바일 화면에서 description 이
// 길거나 summary 가 multi-line 일 때 잘리지 않고 스크롤되게 한다.
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(tool.description, style: theme.textTheme.bodyMedium),
const SizedBox(height: 12),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Text(summary),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('취소'),
),
FilledButton(
autofocus: true,
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('수행'),
),
],
);
},
);
return result ?? false;
}
String _fallbackSummary(Map<String, dynamic> args) {
try {
return const JsonEncoder.withIndent(' ').convert(args);
} catch (_) {
return args.toString();
}
}
}

View File

@@ -0,0 +1,210 @@
import '../../core/time.dart';
import '../../data/db/daos/habit_dao.dart';
import '../../domain/catalog/catalog_item.dart';
import '../../domain/frame/validate_frame_level.dart';
import '../../domain/models/habit.dart';
import '../../domain/rules/active_habit_quota.dart';
import 'tool_definition.dart';
import 'tool_envelope.dart';
final ToolDefinition addHabitTool = ToolDefinition(
name: 'add_habit',
description: '카탈로그 항목 1개를 사용자의 활성 습관으로 추가한다. '
'frame_level 은 L2 (조건부 긍정) 또는 L3 (정체성) 만 허용. '
'L0/L1 (회피·부정 명령) 은 R3 위반으로 거부됨.',
parametersSchema: const {
'type': 'object',
'properties': {
'protocol_id': {
'type': 'string',
'description': '카탈로그 항목 ID (search_catalog 결과의 id).',
},
'frame_level': {
'type': 'string',
'description': 'L2 또는 L3.',
},
'framed_text': {
'type': 'string',
'description': '사용자에게 보일 1줄 문구 (1~200자).',
},
'anchor_when': {
'type': 'string',
'description': '시점 트리거 (예: "기상 후"). 선택.',
},
'anchor_after_what': {
'type': 'string',
'description': '직전 행동 트리거 (예: "세수"). 선택.',
},
'dose_text': {
'type': 'string',
'description': '용량/강도 문구 (예: "5분"). 선택.',
},
},
'required': ['protocol_id', 'frame_level', 'framed_text'],
},
isDestructive: true,
summarize: (args) {
final text = args['framed_text'] ?? args['protocol_id'];
final lv = args['frame_level'] ?? '?';
return '\'$text\' ($lv 프레임) 를 활성 습관으로 추가합니다.';
},
handler: _addHabitHandler,
);
final ToolDefinition listActiveHabitsTool = ToolDefinition(
name: 'list_active_habits',
description: '현재 활성 상태인 습관 목록을 반환한다. '
'R3 quota 점검 또는 사용자 현황 안내 전 호출.',
parametersSchema: const {
'type': 'object',
'properties': {},
'required': [],
},
handler: _listActiveHabitsHandler,
);
Future<ToolResult> _addHabitHandler(
Map<String, dynamic> args, ToolDeps deps) async {
// 1. 의미 검증.
final protocolId = args['protocol_id'];
if (protocolId is! String || protocolId.isEmpty) {
return const ToolErr('validation', 'protocol_id 는 비어있지 않은 문자열이어야 합니다.');
}
final frameLevelRaw = args['frame_level'];
if (frameLevelRaw is! String) {
return const ToolErr('validation', 'frame_level 은 L2 또는 L3 이어야 합니다.');
}
final frameLevel = FrameLevelX.fromDb(frameLevelRaw.toUpperCase());
if (frameLevel == null ||
frameLevel == FrameLevel.l0 ||
frameLevel == FrameLevel.l1) {
return const ToolErr(
'validation',
'frame_level 은 L2 (조건부 긍정) 또는 L3 (정체성) 이어야 합니다. '
'L0/L1 은 코끼리 회피 문제로 거부됩니다.',
);
}
final framedTextRaw = args['framed_text'];
if (framedTextRaw is! String) {
return const ToolErr('validation', 'framed_text 가 누락됐습니다.');
}
final framedText = framedTextRaw.trim();
if (framedText.isEmpty) {
return const ToolErr('validation', 'framed_text 가 비어있습니다.');
}
if (framedText.length > 200) {
return const ToolErr('validation', 'framed_text 는 200자 이하여야 합니다.');
}
// 2. 카탈로그 lookup → habitType 결정.
final item = await deps.catalog.byId(protocolId);
if (item == null) {
return ToolErr('not_found', '카탈로그에서 \'$protocolId\' 를 찾을 수 없습니다.');
}
final HabitType habitType;
switch (item) {
case ProtocolCatalogItem _:
habitType = HabitType.build;
case BreakCatalogItem _:
habitType = HabitType.breakHabit;
case DietCatalogItem _:
habitType = HabitType.build;
}
// 3. R7 회피 키워드.
final hits = detectAvoidanceKeywords(framedText, deps.framePatterns);
if (hits.isNotEmpty) {
final first = hits.first;
return ToolErr(
'r7_avoidance',
'\'${first.keyword}\' 같은 회피 표현이 감지됐어요. '
'\'${first.source.l2Suggestion}\' 같은 긍정 표현으로 다시 시도해주세요.',
);
}
// 4. R3 quota.
final count = await deps.habitDao
.countActive(userId: deps.userId, type: habitType);
final quota = judgeActiveHabitQuota(
type: habitType,
currentActiveCount: count,
);
if (!quota.allowed) {
return ToolErr('r3_quota', quota.reason);
}
// 5. Draft 빌드.
final anchorWhen = _trimmedOrNull(args['anchor_when']);
final anchorAfterWhat = _trimmedOrNull(args['anchor_after_what']);
final doseText = _trimmedOrNull(args['dose_text']);
final variants = doseText == null
? const <VariantDraft>[]
: [
VariantDraft(
label: '기본',
doseText: doseText,
isMinimum: false,
sortOrder: 0,
),
];
final draft = HabitDraft(
userId: deps.userId,
type: habitType,
title: item.title,
protocolId: habitType == HabitType.build ? protocolId : null,
breakProtocolId: habitType == HabitType.breakHabit ? protocolId : null,
frameLevel: frameLevel,
frameFramedText: framedText,
anchorWhen: anchorWhen,
anchorAfterWhat: anchorAfterWhat,
startedAt: dateOnly(nowKst()),
variants: variants,
);
// 6. Insert (R8 XOR assert 는 dao 내부).
try {
final habitId = await deps.habitDao.insertWithVariants(draft);
return ToolOk({
'habit_id': habitId,
'title': item.title,
'type': habitType.dbValue,
'frame_level': frameLevel.dbValue,
});
} on AssertionError catch (e) {
return ToolErr('r8_xor', 'R8 XOR 위반: ${e.message}');
}
}
Future<ToolResult> _listActiveHabitsHandler(
Map<String, dynamic> args, ToolDeps deps) async {
final habits = await deps.habitDao.activeHabitsForUser(deps.userId);
final items = habits
.map((h) => {
'id': h.id,
'title': h.title,
'type': h.type,
'frame_level': h.frameLevel,
'framed_text': h.frameFramedText,
'started_at': h.startedAt,
if (h.protocolId != null) 'protocol_id': h.protocolId,
if (h.breakProtocolId != null) 'break_protocol_id': h.breakProtocolId,
})
.toList();
final buildCount = habits.where((h) => h.type == 'build').length;
final breakCount = habits.where((h) => h.type == 'break').length;
return ToolOk({
'count': habits.length,
'build_count': buildCount,
'break_count': breakCount,
'build_quota_remaining': kMaxActiveBuild - buildCount,
'break_quota_remaining': kMaxActiveBreak - breakCount,
'items': items,
});
}
String? _trimmedOrNull(dynamic v) {
if (v is! String) return null;
final t = v.trim();
return t.isEmpty ? null : t;
}

View File

@@ -0,0 +1,54 @@
import '../../data/catalog/catalog_repository.dart';
import '../../data/db/daos/habit_dao.dart';
import '../../data/db/daos/tracker_dao.dart';
import '../../domain/models/frame_pattern.dart';
import 'tool_envelope.dart';
/// Shared dependencies for every tool handler.
///
/// Frame patterns are passed in pre-loaded (memo'd at provider level) so the
/// R7 avoidance check doesn't reparse seed JSON on every tool call.
class ToolDeps {
final HabitDao habitDao;
final TrackerDao trackerDao;
final CatalogRepository catalog;
final List<FramePatternModel> framePatterns;
final String userId;
const ToolDeps({
required this.habitDao,
required this.trackerDao,
required this.catalog,
required this.framePatterns,
required this.userId,
});
}
typedef ToolHandler =
Future<ToolResult> Function(Map<String, dynamic> args, ToolDeps deps);
/// Single tool the model can call.
///
/// `parametersSchema` follows the draft-07 JSON Schema shape that
/// flutter_gemma 0.16.5's `Tool.parameters` expects — see ADR-0005 (Dart is
/// the schema source-of-truth).
class ToolDefinition {
final String name;
final String description;
final Map<String, dynamic> parametersSchema;
final bool isDestructive;
final ToolHandler handler;
/// Optional summariser used by `ConfirmGate` to render destructive args in
/// a sentence rather than raw JSON. Read-only tools leave this null.
final String Function(Map<String, dynamic> args)? summarize;
const ToolDefinition({
required this.name,
required this.description,
required this.parametersSchema,
required this.handler,
this.isDestructive = false,
this.summarize,
});
}

View File

@@ -0,0 +1,104 @@
import 'package:flutter/widgets.dart';
import 'confirm_gate.dart';
import 'tool_definition.dart';
import 'tool_envelope.dart';
import 'tool_registry.dart';
/// Routes a single `FunctionCallResponse` from the LLM to the matching
/// handler. See design `fn-tool_dispatcher.md`.
///
/// `dispatch` never throws — every failure path returns a `ToolResult`.
class ToolDispatcher {
final ToolRegistry registry;
final ConfirmGate confirmGate;
ToolDispatcher({
required this.registry,
ConfirmGate? confirmGate,
}) : confirmGate = confirmGate ?? const ConfirmGate();
Future<ToolResult> dispatch({
required String toolName,
required Map<String, dynamic> rawArgs,
required BuildContext? confirmContext,
required ToolDeps deps,
}) async {
// 1. Lookup.
final tool = registry.byName(toolName);
if (tool == null) {
return ToolErr('unknown_tool', '알 수 없는 도구: $toolName');
}
// 2. Validate against schema.
final validation = _validateArgs(tool.parametersSchema, rawArgs);
if (validation != null) {
return ToolErr('validation', '인자 오류: $validation');
}
// 3. Destructive → Confirm gate.
if (tool.isDestructive) {
if (confirmContext == null) {
return const ToolCancelled();
}
final ok = await confirmGate.show(confirmContext, tool, rawArgs);
if (!ok) return const ToolCancelled();
}
// 4. Run handler.
try {
return await tool.handler(rawArgs, deps);
} catch (e) {
return ToolErr('handler_error', '도구 실행 실패: ${e.runtimeType}');
}
}
}
/// Minimal JSON-schema-ish validator covering only what our tools use:
/// - object root with `properties` + optional `required`
/// - per-property `type` ∈ {string, integer, number, boolean, object, array}
///
/// Returns null on success, a short error message on failure. Extra keys are
/// allowed (model hallucination tolerated; logged at call site if needed).
String? _validateArgs(Map<String, dynamic> schema, Map<String, dynamic> args) {
final required = schema['required'];
if (required is List) {
for (final field in required) {
if (field is String && !args.containsKey(field)) {
return '필수 필드 \'$field\' 가 없습니다.';
}
}
}
final props = schema['properties'];
if (props is! Map) return null;
for (final entry in args.entries) {
final propSchema = props[entry.key];
if (propSchema is! Map) continue; // unknown key — tolerate
final expected = propSchema['type'];
if (expected is! String) continue;
final v = entry.value;
if (!_matchesType(expected, v)) {
return '\'${entry.key}\' 타입 불일치 (기대=$expected, 실제=${v.runtimeType})';
}
}
return null;
}
bool _matchesType(String expected, dynamic v) {
switch (expected) {
case 'string':
return v is String;
case 'integer':
return v is int;
case 'number':
return v is num;
case 'boolean':
return v is bool;
case 'object':
return v is Map;
case 'array':
return v is List;
default:
return true; // unknown type — passthrough
}
}

View File

@@ -0,0 +1,63 @@
import 'dart:convert';
/// Tool execution result. See design fn-tool_dispatcher.md §4.
///
/// Always JSON-serialisable so the model can consume it. `toJson()` returns a
/// shape with a stable `status` discriminator — easier for the LLM to parse
/// than relying on key presence.
sealed class ToolResult {
const ToolResult();
Map<String, dynamic> toJson();
}
final class ToolOk extends ToolResult {
final Map<String, dynamic> data;
const ToolOk(this.data);
@override
Map<String, dynamic> toJson() => {'status': 'ok', 'data': data};
}
final class ToolErr extends ToolResult {
final String code;
final String reason;
const ToolErr(this.code, this.reason);
@override
Map<String, dynamic> toJson() =>
{'status': 'error', 'code': code, 'reason': reason};
}
final class ToolCancelled extends ToolResult {
const ToolCancelled();
@override
Map<String, dynamic> toJson() =>
{'status': 'cancelled', 'reason': 'user did not confirm'};
}
/// Encode a [ToolResult] to a JSON string ≤ [maxBytes].
///
/// Tool result token budget (ADR-0005 / OQ-2): keep model context bounded.
/// If serialised payload exceeds [maxBytes], we replace the tail of the data
/// field with a truncation hint instead of letting the LLM blow its window.
String encodeToolResult(ToolResult result, {int maxBytes = 2048}) {
final encoded = jsonEncode(result.toJson());
if (encoded.length <= maxBytes) return encoded;
// Truncate strategy: only ToolOk has unbounded payload. Replace data with
// a hint pointing at follow-up tools. Errors/cancellations are always small.
if (result is ToolOk) {
final hint = {
'status': 'ok',
'data': {
'_truncated': true,
'_hint': '결과가 ${encoded.length} 바이트로 잘렸습니다. '
'구체 ID 가 필요하면 query_protocol 같은 단건 조회 도구를 사용하세요.',
},
};
return jsonEncode(hint);
}
// Defensive: if some future ToolResult adds bulk, fall back to hard cut.
return encoded.substring(0, maxBytes);
}

View File

@@ -0,0 +1,33 @@
import 'catalog_tools.dart';
import 'habit_tools.dart';
import 'tool_definition.dart';
import 'tracker_tools.dart';
/// Static registry of all tools exposed to the LLM.
///
/// Order is the order surfaced to the model (`flutter_gemma` preserves the
/// list). Read-only tools first, then destructive — mirrors a "look before
/// you leap" prompt bias.
final List<ToolDefinition> kAllTools = [
// read-only
searchCatalogTool,
queryProtocolTool,
listActiveHabitsTool,
getStreakTool,
// destructive (confirm gate)
addHabitTool,
logTrackerEntryTool,
];
class ToolRegistry {
final Map<String, ToolDefinition> _byName;
ToolRegistry(List<ToolDefinition> tools)
: _byName = {for (final t in tools) t.name: t};
factory ToolRegistry.defaults() => ToolRegistry(kAllTools);
ToolDefinition? byName(String name) => _byName[name];
Iterable<ToolDefinition> get all => _byName.values;
}

View File

@@ -0,0 +1,153 @@
import '../../core/time.dart';
import '../../data/db/daos/tracker_dao.dart';
import '../../domain/models/tracker_entry.dart';
import '../../domain/streak/compute_streak.dart';
import 'tool_definition.dart';
import 'tool_envelope.dart';
final ToolDefinition logTrackerEntryTool = ToolDefinition(
name: 'log_tracker_entry',
description: '습관의 하루 체크인을 기록한다. value 는 done (완료) 또는 blank (의도적 공란).',
parametersSchema: const {
'type': 'object',
'properties': {
'habit_id': {'type': 'string'},
'value': {
'type': 'string',
'description': 'done 또는 blank.',
},
'date': {
'type': 'string',
'description': 'YYYY-MM-DD. 생략하면 오늘.',
},
'note': {'type': 'string'},
},
'required': ['habit_id', 'value'],
},
// R5: done 만 destructive (블랭크는 의도적 공란 — 확인 없이 통과).
// 실 mutation 가시성을 위해 done 만 모달.
isDestructive: true,
summarize: (args) {
final v = args['value'];
final d = args['date'] ?? '오늘';
return '습관 ${args['habit_id']}$d 기록을 '
'\'${v == 'done' ? '완료' : '공란'}\' 으로 저장합니다.';
},
handler: _logTrackerEntryHandler,
);
final ToolDefinition getStreakTool = ToolDefinition(
name: 'get_streak',
description: '특정 habit_id 의 스트릭(연속일수) 와 5-tier 보상 등급을 계산해서 반환한다. '
'기록 없는 날은 패널티 아니지만 명시적 blank 는 패널티.',
parametersSchema: const {
'type': 'object',
'properties': {
'habit_id': {'type': 'string'},
},
'required': ['habit_id'],
},
handler: _getStreakHandler,
);
Future<ToolResult> _logTrackerEntryHandler(
Map<String, dynamic> args, ToolDeps deps) async {
final habitId = args['habit_id'];
final value = args['value'];
if (habitId is! String || habitId.isEmpty) {
return const ToolErr('validation', 'habit_id 는 비어있지 않은 문자열이어야 합니다.');
}
if (value is! String || (value != 'done' && value != 'blank')) {
return const ToolErr('validation', 'value 는 done 또는 blank 이어야 합니다.');
}
final date = (args['date'] is String && (args['date'] as String).isNotEmpty)
? args['date'] as String
: dateOnly(nowKst());
if (!_isValidDate(date)) {
return const ToolErr('validation', 'date 는 YYYY-MM-DD 형식이어야 합니다.');
}
// habit_id 가 실 사용자 소유인지 확인.
final habits = await deps.habitDao.activeHabitsForUser(deps.userId);
final owned = habits.any((h) => h.id == habitId);
if (!owned) {
return ToolErr('not_found', '활성 습관 \'$habitId\' 를 찾을 수 없습니다.');
}
// 같은 (habit, date) 가 이미 있으면 덮어쓰기 대신 안내 — OQ-7 (no UNIQUE
// constraint, 핸들러 레벨 dedup). 의도된 재기록은 사용자가 별도 액션.
final existing = await deps.trackerDao.entriesForHabit(habitId);
final same = existing.where((e) => e.date == date).toList();
if (same.isNotEmpty) {
return ToolErr(
'duplicate',
'$date 에 이미 \'${same.first.value}\' 로 기록되어 있습니다. '
'덮어쓰려면 기존 항목을 삭제 후 다시 시도해주세요.',
);
}
final id = await deps.trackerDao.recordCheckIn(TrackerEntryDraft(
habitId: habitId,
date: date,
value: value,
note: args['note'] is String ? args['note'] as String : null,
));
return ToolOk({
'entry_id': id,
'habit_id': habitId,
'date': date,
'value': value,
});
}
Future<ToolResult> _getStreakHandler(
Map<String, dynamic> args, ToolDeps deps) async {
final habitId = args['habit_id'];
if (habitId is! String || habitId.isEmpty) {
return const ToolErr('validation', 'habit_id 는 비어있지 않은 문자열이어야 합니다.');
}
final habits = await deps.habitDao.activeHabitsForUser(deps.userId);
final habit = habits.where((h) => h.id == habitId).firstOrNull;
if (habit == null) {
return ToolErr('not_found', '활성 습관 \'$habitId\' 를 찾을 수 없습니다.');
}
final rows = await deps.trackerDao.entriesForHabit(habitId);
final entries = rows
.map((r) => TrackerEntryModel(
id: r.id,
habitId: r.habitId,
date: r.date,
value: r.value == 'done' ? TrackerValue.done : TrackerValue.blank,
))
.toList();
final state = computeStreak(
entries: entries,
asOf: nowKst(),
habitStartedAt: habit.startedAt,
);
return ToolOk({
'habit_id': habitId,
'current_streak': state.currentStreak,
'longest_streak': state.longestStreak,
'done_count_30d': state.doneCountInWindow30,
'done_count_phase42': state.doneCountInPhase42,
'tier': state.currentTier.dbValue,
'never_miss_twice_broken': state.neverMissTwiceBroken,
});
}
bool _isValidDate(String s) {
if (s.length != 10) return false;
try {
final parts = s.split('-');
if (parts.length != 3) return false;
final y = int.parse(parts[0]);
final m = int.parse(parts[1]);
final d = int.parse(parts[2]);
final dt = DateTime(y, m, d);
return dt.year == y && dt.month == m && dt.day == d;
} catch (_) {
return false;
}
}

View File

@@ -3,6 +3,7 @@ import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter_gemma/flutter_gemma.dart';
import '../../ai/tools/tool_definition.dart' as tools;
import 'llm_service.dart';
/// HuggingFace access token injected at build time via
@@ -36,13 +37,28 @@ class GemmaLlmService implements LlmService {
InferenceModel? _model;
bool _loaded = false;
Future<void>? _loadingFuture;
@override
bool get isLoaded => _loaded;
/// #311 AC7: concurrent-call guard. If a load is already in-flight (e.g.
/// `ChatScreen` warm-up + a racing `userTurn` lazy load), return the same
/// Future so native init runs at most once per process.
/// See `docs/design/311-llm-warmup/fn-concurrent_load_guard.md`.
@override
Future<void> load() async {
if (_loaded) return;
Future<void> load() {
if (_loaded) return Future.value();
final existing = _loadingFuture;
if (existing != null) return existing;
final future = _doLoad();
_loadingFuture = future;
return future.whenComplete(() {
_loadingFuture = null;
});
}
Future<void> _doLoad() async {
if (!await File(modelPath).exists()) {
throw FileSystemException('model file missing', modelPath);
}
@@ -114,6 +130,93 @@ class GemmaLlmService implements LlmService {
}
}
}
@override
Future<LlmChatSession> startChat({
required List<tools.ToolDefinition> tools,
}) async {
if (!_loaded || _model == null) {
throw StateError('LlmService not loaded');
}
final gemmaTools = tools
.map((t) => Tool(
name: t.name,
description: t.description,
parameters: Map<String, dynamic>.from(t.parametersSchema),
))
.toList();
final chat = await _model!.createChat(
modelType: ModelType.gemma4,
supportsFunctionCalls: true,
// ToolChoice.auto = 모델이 자율 결정 (multi-tool + reply-only 모두 지원).
toolChoice: ToolChoice.auto,
tools: gemmaTools,
);
return _GemmaChatSession(chat);
}
}
class _GemmaChatSession implements LlmChatSession {
final dynamic _chat;
bool _closed = false;
_GemmaChatSession(this._chat);
@override
Stream<LlmChatEvent> sendUser(String text) {
if (_closed) {
throw StateError('LlmChatSession is closed');
}
return _run(Message.text(text: text, isUser: true));
}
@override
Stream<LlmChatEvent> sendToolResult({
required String toolName,
required Map<String, dynamic> result,
}) {
if (_closed) {
throw StateError('LlmChatSession is closed');
}
return _run(Message.toolResponse(toolName: toolName, response: result));
}
Stream<LlmChatEvent> _run(Message msg) async* {
await _chat.addQueryChunk(msg);
final Stream<ModelResponse> stream = _chat.generateChatResponseAsync();
await for (final event in stream) {
if (event is TextResponse) {
yield LlmTextChunk(event.token);
} else if (event is FunctionCallResponse) {
yield LlmFunctionCall(
event.name,
Map<String, dynamic>.from(event.args),
);
return; // model hands control back to caller for tool exec
} else if (event is ParallelFunctionCallResponse &&
event.calls.isNotEmpty) {
// ADR-0005: parallel calls collapsed to first — sequential dispatch.
final first = event.calls.first;
yield LlmFunctionCall(
first.name,
Map<String, dynamic>.from(first.args),
);
return;
}
// ThinkingResponse / other: skip.
}
}
@override
Future<void> close() async {
if (_closed) return;
_closed = true;
try {
await _chat.close();
} catch (_) {
// Best-effort cleanup.
}
}
}
/// Extracts the first `FunctionCallResponse(name == expectedName)` from

View File

@@ -1,3 +1,5 @@
import '../../ai/tools/tool_definition.dart';
/// Abstract LLM backend.
///
/// Concrete impls: `GemmaLlmService` (flutter_gemma) and `MockLlmService` (tests).
@@ -7,6 +9,7 @@
/// - [generateStructured] returns a parsed JSON map matching the schema.
/// On schema/parse failure throws [FormatException].
/// - [unload] is idempotent.
/// - [startChat] opens a multi-turn chat session for tool calling (#260).
abstract class LlmService {
bool get isLoaded;
@@ -20,6 +23,45 @@ abstract class LlmService {
String prompt,
Map<String, dynamic> schema,
);
/// Opens a chat session that supports multi-turn user input + tool result
/// submission with the supplied [tools]. See ADR-0005.
Future<LlmChatSession> startChat({
required List<ToolDefinition> tools,
});
}
/// Streaming chat session for the tool-calling loop.
///
/// Lifecycle: created by [LlmService.startChat], lives for a single chat
/// screen, must be [close]d when the user dismisses the screen. Each
/// `send*` call returns a stream of [LlmChatEvent]s until the model yields
/// control (text done or a function call requested).
abstract class LlmChatSession {
Stream<LlmChatEvent> sendUser(String text);
Stream<LlmChatEvent> sendToolResult({
required String toolName,
required Map<String, dynamic> result,
});
Future<void> close();
}
/// Events emitted by [LlmChatSession]. See ADR-0005 §C.
sealed class LlmChatEvent {
const LlmChatEvent();
}
final class LlmTextChunk extends LlmChatEvent {
final String text;
const LlmTextChunk(this.text);
}
final class LlmFunctionCall extends LlmChatEvent {
final String name;
final Map<String, dynamic> args;
const LlmFunctionCall(this.name, this.args);
}
/// Programmable stub for tests. Use [enqueueResponse] / [enqueueError].
@@ -31,11 +73,48 @@ class MockLlmService implements LlmService {
Map<String, dynamic>? lastSchema;
Duration responseDelay = Duration.zero;
/// #311 test helpers. Simulate cold-load latency / failure so the warm-up
/// controller can be exercised. Mirrors the Gemma path:
/// - `loadDelay > 0` → load completes after the delay
/// - `loadThrows` → load throws this error
/// - `loadCount` → observed by concurrent-load tests
Duration loadDelay = Duration.zero;
Object? loadThrows;
int loadCount = 0;
Future<void>? _loadingFuture;
/// Queues consumed by [startChat] in order. Each entry is the event list
/// returned for a single `send*` call.
final List<List<LlmChatEvent>> chatScript = [];
int chatStartCount = 0;
MockLlmChatSession? lastChat;
@override
bool get isLoaded => _loaded;
/// #311 AC7: same concurrent-call guard as [GemmaLlmService]. Repeated
/// in-flight `load()` calls share a single Future, so test assertions on
/// `loadCount` reflect the number of native-init attempts (1), not the
/// number of callers.
@override
Future<void> load() async {
Future<void> load() {
if (_loaded) return Future.value();
final existing = _loadingFuture;
if (existing != null) return existing;
final future = _doLoad();
_loadingFuture = future;
return future.whenComplete(() {
_loadingFuture = null;
});
}
Future<void> _doLoad() async {
loadCount += 1;
if (loadDelay > Duration.zero) {
await Future<void>.delayed(loadDelay);
}
final err = loadThrows;
if (err != null) throw err;
_loaded = true;
}
@@ -52,6 +131,12 @@ class MockLlmService implements LlmService {
_queue.add(_Response.error(error));
}
/// Enqueue one batch of events that will be emitted on the next
/// `sendUser` or `sendToolResult` call. Items are streamed in order.
void enqueueChatEvents(List<LlmChatEvent> events) {
chatScript.add(events);
}
@override
Future<Map<String, dynamic>> generateStructured(
String prompt,
@@ -73,6 +158,61 @@ class MockLlmService implements LlmService {
if (r.error != null) throw r.error!;
return r.value!;
}
@override
Future<LlmChatSession> startChat({
required List<ToolDefinition> tools,
}) async {
if (!_loaded) {
throw StateError('LlmService not loaded');
}
chatStartCount += 1;
final session = MockLlmChatSession(chatScript);
lastChat = session;
return session;
}
}
/// Mock chat session that replays pre-queued events from [MockLlmService].
class MockLlmChatSession implements LlmChatSession {
final List<List<LlmChatEvent>> _script;
int sendCount = 0;
final List<String> userInputs = [];
final List<(String, Map<String, dynamic>)> toolResults = [];
bool closed = false;
MockLlmChatSession(this._script);
Stream<LlmChatEvent> _emitNext() async* {
sendCount += 1;
if (_script.isEmpty) {
throw StateError('MockLlmChatSession: no queued events');
}
final batch = _script.removeAt(0);
for (final ev in batch) {
yield ev;
}
}
@override
Stream<LlmChatEvent> sendUser(String text) {
userInputs.add(text);
return _emitNext();
}
@override
Stream<LlmChatEvent> sendToolResult({
required String toolName,
required Map<String, dynamic> result,
}) {
toolResults.add((toolName, result));
return _emitNext();
}
@override
Future<void> close() async {
closed = true;
}
}
class _Response {

View File

@@ -94,6 +94,44 @@ class ModelLifecycle {
return p.join(dir.path, config.filename);
}
/// Lightweight ready estimate for warm-up gating (#311).
///
/// Skips the SHA-256 re-hash that [checkAvailability] performs — for a
/// ~2.4GB model file the hash is wall-clock-noticeable on every screen
/// mount. Returns `ready` iff:
/// - opt_in is true
/// - download_state is not in-progress
/// - meta_kv has both ai_model_path and ai_model_sha256
/// - the file exists on disk
///
/// Tampering/disk-corruption detection is left to [checkAvailability]'s
/// cold path (SettingsScreen). The trade-off is documented in
/// `docs/design/311-llm-warmup/README.md` §11 R4.
Future<ModelAvailability> quickCheck() async {
try {
final optIn = await meta.find(AiMetaKeys.optIn);
if (optIn != 'true') return ModelAvailability.missing;
final state = await meta.find(AiMetaKeys.downloadState);
if (state == 'downloading' || state == 'paused') {
return ModelAvailability.downloading;
}
final pathStr = await meta.find(AiMetaKeys.modelPath);
if (pathStr == null) return ModelAvailability.missing;
final expected = await meta.find(AiMetaKeys.modelSha);
if (expected == null) return ModelAvailability.corrupt;
final file = File(pathStr);
if (!file.existsSync()) return ModelAvailability.missing;
return ModelAvailability.ready;
} catch (_) {
return ModelAvailability.corrupt;
}
}
Future<ModelAvailability> checkAvailability() async {
try {
final optIn = await meta.find(AiMetaKeys.optIn);

View File

@@ -0,0 +1,133 @@
import 'dart:convert';
import '../../domain/catalog/catalog_item.dart';
import '../../domain/catalog/display_category.dart';
import '../db/app_database.dart';
/// 3 source (Protocols / BreakProtocols / DietPatterns) → 단일 `List<CatalogItem>`.
///
/// 본 이슈 (#226) 의 핵심 변환 한 점. 본 함수는 fn-catalog_repository.md 의 알고리즘대로.
class CatalogRepository {
CatalogRepository(this._db);
final AppDatabase _db;
/// 47 항목 (protocols 34 + break 8 + diet 5) 을 displayCategory 기준 정렬해 반환.
Future<List<CatalogItem>> all() async {
final protocolRows = await _db.select(_db.protocols).get();
final breakRows = await _db.select(_db.breakProtocols).get();
final dietRows = await _db.select(_db.dietPatterns).get();
final items = <CatalogItem>[];
for (final p in protocolRows) {
final dc = DisplayCategory.fromProtocolCategory(p.category);
if (dc == null) {
throw StateError(
'unknown protocol category "${p.category}" for id=${p.id}');
}
items.add(ProtocolCatalogItem(
id: p.id,
title: p.title,
titleEn: p.titleEn,
summary: _summary(p.what, fallback: p.title),
displayCategory: dc,
evidenceStrength: p.evidenceStrength,
referenceIds: _decodeIds(p.referenceIdsJson),
what: p.what,
whenText: p.whenText,
dose: p.dose,
why: p.why,
how: _decodeList(p.howJson),
checkText: p.checkText,
caution: p.caution,
defaultAnchor: _decodeAnchor(p.defaultAnchorJson),
minDoseForStart: p.minDoseForStart,
sourceDoc: p.sourceDoc,
));
}
for (final b in breakRows) {
items.add(BreakCatalogItem(
id: b.id,
title: b.title,
titleEn: null,
summary: _summary(b.hubermanSummary, fallback: b.title),
evidenceStrength: null,
referenceIds: _decodeIds(b.referenceIdsJson),
breakCategory: b.category,
hubermanSummary: b.hubermanSummary,
phases: _decodeList(b.phasesJson),
defaultCommonFrames: _decodeList(b.defaultCommonFramesJson),
tools: _decodeList(b.toolsJson),
medicalWarning: b.medicalWarning,
));
}
for (final d in dietRows) {
items.add(DietCatalogItem(
id: d.id,
title: d.name,
titleEn: null,
summary: _summary(d.core, fallback: d.name),
evidenceStrength: d.evidenceStrength,
referenceIds: _decodeIds(d.referenceIdsJson),
name: d.name,
core: d.core,
strengths: _decodeList(d.strengthsJson),
weaknesses: _decodeList(d.weaknessesJson),
koreanContextFit: d.koreanContextFit,
starterLevers: _decodeList(d.starterLeversJson),
medicalWarning: d.medicalWarning,
linkedProtocolIds: _decodeIds(d.linkedProtocolIdsJson),
));
}
items.sort((a, b) {
final c = a.displayCategory.index - b.displayCategory.index;
return c != 0 ? c : a.id.compareTo(b.id);
});
return items;
}
/// 단건 조회. Preview 화면 진입 시.
Future<CatalogItem?> byId(String id) async {
final all_ = await all();
for (final item in all_) {
if (item.id == id) return item;
}
return null;
}
/// reference id 리스트 → References 테이블 매칭. 미매칭 항목은 결과에서 누락.
Future<List<ReferenceRow>> referencesByIds(List<String> ids) async {
if (ids.isEmpty) return const [];
return (_db.select(_db.references)..where((t) => t.id.isIn(ids))).get();
}
}
/// `what` 의 첫 문장을 추출. 비어있으면 `fallback` 사용. 60자 초과 시 절단.
String _summary(String what, {required String fallback, int max = 60}) {
final firstSentence = what.split(RegExp(r'[.!?。!?]')).first.trim();
final s = firstSentence.isEmpty ? fallback : firstSentence;
return s.length <= max ? s : '${s.substring(0, max - 1)}';
}
List<String> _decodeIds(String? jsonStr) {
if (jsonStr == null) return const [];
final decoded = jsonDecode(jsonStr);
return decoded is List ? decoded.cast<String>() : const [];
}
List<String> _decodeList(String? jsonStr) {
if (jsonStr == null) return const [];
final decoded = jsonDecode(jsonStr);
return decoded is List ? decoded.map((e) => e.toString()).toList() : const [];
}
Map<String, dynamic>? _decodeAnchor(String? jsonStr) {
if (jsonStr == null) return null;
final decoded = jsonDecode(jsonStr);
return decoded is Map<String, dynamic> ? decoded : null;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,166 @@
import 'display_category.dart';
/// 갤러리 UI 가 소비하는 통합 카탈로그 항목.
///
/// 3 source (ProtocolsTable / BreakProtocolsTable / DietPatternsTable) 를
/// 단일 sealed 계층으로 통합. 카드/필터링은 공통 필드만 보면 충분.
sealed class CatalogItem {
String get id;
String get title;
String? get titleEn;
/// 카드용 1줄 요약 (≤ 60자).
String get summary;
DisplayCategory get displayCategory;
/// 'strong_rct' / 'meta_analysis' / 'observational' / 'mechanistic' / 'expert_opinion' / null.
/// DietPattern 은 'strong'/'moderate'/'mixed'/'weak'.
String? get evidenceStrength;
List<String> get referenceIds;
}
/// Protocols 테이블 1:1 매핑.
final class ProtocolCatalogItem implements CatalogItem {
ProtocolCatalogItem({
required this.id,
required this.title,
required this.titleEn,
required this.summary,
required this.displayCategory,
required this.evidenceStrength,
required this.referenceIds,
required this.what,
required this.whenText,
required this.dose,
required this.why,
required this.how,
required this.checkText,
required this.caution,
required this.defaultAnchor,
required this.minDoseForStart,
required this.sourceDoc,
});
@override
final String id;
@override
final String title;
@override
final String? titleEn;
@override
final String summary;
@override
final DisplayCategory displayCategory;
@override
final String? evidenceStrength;
@override
final List<String> referenceIds;
final String what;
final String whenText;
final String dose;
final String why;
final List<String> how;
final String checkText;
final String? caution;
final Map<String, dynamic>? defaultAnchor;
final String? minDoseForStart;
final String? sourceDoc;
}
/// BreakProtocols 테이블 1:1 매핑. displayCategory 는 항상 breakHabit.
final class BreakCatalogItem implements CatalogItem {
BreakCatalogItem({
required this.id,
required this.title,
required this.titleEn,
required this.summary,
required this.evidenceStrength,
required this.referenceIds,
required this.breakCategory,
required this.hubermanSummary,
required this.phases,
required this.defaultCommonFrames,
required this.tools,
required this.medicalWarning,
});
@override
final String id;
@override
final String title;
@override
final String? titleEn;
@override
final String summary;
@override
DisplayCategory get displayCategory => DisplayCategory.breakHabit;
@override
final String? evidenceStrength;
@override
final List<String> referenceIds;
/// 원본 break 카테고리 (alcohol / nicotine / ...). 카드 sub-tag 로 활용.
final String breakCategory;
final String hubermanSummary;
final List<String> phases;
final List<String> defaultCommonFrames;
final List<String> tools;
final String? medicalWarning;
}
/// DietPatterns 테이블 1:1 매핑. displayCategory 는 항상 nutrition.
final class DietCatalogItem implements CatalogItem {
DietCatalogItem({
required this.id,
required this.title,
required this.titleEn,
required this.summary,
required this.evidenceStrength,
required this.referenceIds,
required this.name,
required this.core,
required this.strengths,
required this.weaknesses,
required this.koreanContextFit,
required this.starterLevers,
required this.medicalWarning,
required this.linkedProtocolIds,
});
@override
final String id;
@override
final String title;
@override
final String? titleEn;
@override
final String summary;
@override
DisplayCategory get displayCategory => DisplayCategory.nutrition;
@override
final String? evidenceStrength;
@override
final List<String> referenceIds;
final String name;
final String core;
final List<String> strengths;
final List<String> weaknesses;
final String? koreanContextFit;
final List<String> starterLevers;
final String? medicalWarning;
final List<String> linkedProtocolIds;
}
/// 그룹핑 헬퍼. 빈 카테고리 키는 결과 map 에 미포함.
Map<DisplayCategory, List<CatalogItem>> groupByCategory(
List<CatalogItem> items) {
final result = <DisplayCategory, List<CatalogItem>>{};
for (final item in items) {
result.putIfAbsent(item.displayCategory, () => []).add(item);
}
return result;
}

View File

@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
/// UI 노출용 카테고리. Protocol DB 의 source `category` 와 직교.
///
/// 7개는 `protocols.category` 와 1:1 매핑, `breakHabit` 는 BreakProtocols 전용.
/// DietPatterns 는 항상 `nutrition`.
enum DisplayCategory {
lightCircadian('빛/일주기', Icons.wb_sunny, 'light_circadian'),
sleep('수면', Icons.bedtime, 'sleep'),
movement('운동/신체', Icons.fitness_center, 'movement'),
nutrition('영양', Icons.restaurant, 'nutrition'),
focusCognition('집중/인지', Icons.psychology, 'focus_cognition'),
recoveryStress('회복/스트레스', Icons.spa, 'recovery_stress'),
emotionRelationship('감정/관계', Icons.favorite, 'emotion_relationship'),
breakHabit('없애기', Icons.block, null);
const DisplayCategory(this.label, this.icon, this.protocolKey);
/// 사용자 노출 라벨 (한국어).
final String label;
/// 카드/칩 아이콘.
final IconData icon;
/// `protocols.category` snake_case 값. `breakHabit` 만 null (별도 source).
final String? protocolKey;
/// `protocols.category` 문자열 → enum. 미매칭 시 null.
static DisplayCategory? fromProtocolCategory(String raw) {
for (final c in values) {
if (c.protocolKey == raw) return c;
}
return null;
}
}

View File

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

View File

@@ -0,0 +1,37 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/catalog/catalog_repository.dart';
import '../data/db/app_database.dart';
import '../domain/catalog/catalog_item.dart';
import '../domain/catalog/display_category.dart';
import 'providers.dart';
final catalogRepositoryProvider = Provider<CatalogRepository>((ref) {
return CatalogRepository(ref.watch(appDatabaseProvider));
});
/// 갤러리 진입 시 1회 로드. seed 가 끝난 가정 (bootstrap 이 보장).
final catalogItemsProvider = FutureProvider<List<CatalogItem>>((ref) async {
// bootstrap 가 끝난 후에만 의미 있음.
await ref.watch(bootstrapProvider.future);
return ref.watch(catalogRepositoryProvider).all();
});
/// 빈 카테고리 키는 결과에 미포함 — 갤러리는 결과 key 만 칩으로 렌더.
final groupedByCategoryProvider =
FutureProvider<Map<DisplayCategory, List<CatalogItem>>>((ref) async {
final items = await ref.watch(catalogItemsProvider.future);
return groupByCategory(items);
});
/// Preview 화면용. id → 단건. 미매칭 시 null.
final catalogItemByIdProvider =
FutureProvider.family<CatalogItem?, String>((ref, id) async {
return ref.watch(catalogRepositoryProvider).byId(id);
});
/// reference id 리스트 → ReferenceRow 들. ids 비면 빈 리스트.
final referencesByIdsProvider =
FutureProvider.family<List<ReferenceRow>, List<String>>((ref, ids) async {
return ref.watch(catalogRepositoryProvider).referencesByIds(ids);
});

View File

@@ -0,0 +1,262 @@
import 'dart:convert';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../ai/tools/tool_definition.dart';
import '../ai/tools/tool_dispatcher.dart';
import '../ai/tools/tool_envelope.dart';
import '../ai/tools/tool_registry.dart';
import '../core/constants.dart';
import '../data/ai/llm_service.dart';
import 'ai_providers.dart';
import 'catalog_providers.dart';
import 'providers.dart';
/// Multi-turn safety cap. ADR-0005 §C — guards against tool-call loops.
const int kChatMaxTurns = 4;
/// Soft warning threshold for chat history bloat (OQ-2).
const int kChatSoftHistoryLimit = 8;
sealed class ChatMessage {
const ChatMessage();
}
final class UserChatMessage extends ChatMessage {
final String text;
const UserChatMessage(this.text);
}
final class ModelChatMessage extends ChatMessage {
final String text;
const ModelChatMessage(this.text);
}
final class ToolCallChatMessage extends ChatMessage {
final String name;
final Map<String, dynamic> args;
final ToolResult result;
const ToolCallChatMessage(this.name, this.args, this.result);
}
final class SystemChatMessage extends ChatMessage {
final String text;
const SystemChatMessage(this.text);
}
class ChatSessionState {
final List<ChatMessage> messages;
final bool isStreaming;
final String? streamingText;
final String? error;
const ChatSessionState({
this.messages = const [],
this.isStreaming = false,
this.streamingText,
this.error,
});
ChatSessionState copyWith({
List<ChatMessage>? messages,
bool? isStreaming,
String? streamingText,
String? error,
bool clearStreamingText = false,
bool clearError = false,
}) {
return ChatSessionState(
messages: messages ?? this.messages,
isStreaming: isStreaming ?? this.isStreaming,
streamingText: clearStreamingText
? null
: (streamingText ?? this.streamingText),
error: clearError ? null : (error ?? this.error),
);
}
}
final toolRegistryProvider = Provider<ToolRegistry>((ref) {
return ToolRegistry.defaults();
});
final toolDepsProvider = FutureProvider<ToolDeps>((ref) async {
// bootstrap 가 끝나야 seed 가 채워진 framePatterns 를 신뢰할 수 있음.
await ref.watch(bootstrapProvider.future);
final framePatterns = await ref.watch(framePatternsProvider.future);
return ToolDeps(
habitDao: ref.watch(habitDaoProvider),
trackerDao: ref.watch(trackerDaoProvider),
catalog: ref.watch(catalogRepositoryProvider),
framePatterns: framePatterns,
userId: kLocalDefaultUserId,
);
});
final toolDispatcherProvider = Provider<ToolDispatcher>((ref) {
return ToolDispatcher(registry: ref.watch(toolRegistryProvider));
});
class ChatSessionController extends StateNotifier<ChatSessionState> {
ChatSessionController({
required this.llm,
required this.dispatcher,
required this.deps,
required this.tools,
}) : super(const ChatSessionState());
final LlmService llm;
final ToolDispatcher dispatcher;
final ToolDeps deps;
final List<ToolDefinition> tools;
LlmChatSession? _session;
Future<void> userTurn(String text, BuildContext context) async {
final trimmed = text.trim();
if (trimmed.isEmpty) return;
if (state.isStreaming) return;
state = state.copyWith(
messages: [...state.messages, UserChatMessage(trimmed)],
isStreaming: true,
streamingText: '',
clearError: true,
);
try {
// 1회 lazy load.
if (!llm.isLoaded) {
await llm.load();
}
_session ??= await llm.startChat(tools: tools);
String? pendingToolName;
Map<String, dynamic>? pendingToolResult;
Stream<LlmChatEvent> Function() nextStream = () =>
_session!.sendUser(trimmed);
for (var turn = 0; turn < kChatMaxTurns; turn++) {
var accumulated = '';
LlmFunctionCall? toolCall;
await for (final event in nextStream()) {
if (event is LlmTextChunk) {
accumulated += event.text;
if (!mounted) return;
state = state.copyWith(streamingText: accumulated);
} else if (event is LlmFunctionCall) {
toolCall = event;
break;
}
}
if (toolCall == null) {
// 자연어 응답으로 종료.
if (!mounted) return;
state = state.copyWith(
messages: [
...state.messages,
ModelChatMessage(accumulated),
],
isStreaming: false,
clearStreamingText: true,
);
_maybeWarnHistory();
return;
}
// Tool 처리.
if (!mounted) return;
final result = await dispatcher.dispatch(
toolName: toolCall.name,
rawArgs: toolCall.args,
confirmContext: context.mounted ? context : null,
deps: deps,
);
if (!mounted) return;
state = state.copyWith(
messages: [
...state.messages,
ToolCallChatMessage(toolCall.name, toolCall.args, result),
],
streamingText: '',
);
pendingToolName = toolCall.name;
// ADR-0005 / OQ-2: hard-cap tool result at 2KB so LLM context window
// can't be blown by a runaway ToolOk payload. encodeToolResult applies
// truncate-with-hint when needed; jsonDecode round-trips back to a Map
// because the chat session API expects Map<String, dynamic>.
final capped = jsonDecode(encodeToolResult(result)) as Map<String, dynamic>;
pendingToolResult = capped;
final capturedName = pendingToolName;
final capturedResult = pendingToolResult;
nextStream = () => _session!.sendToolResult(
toolName: capturedName,
result: capturedResult,
);
}
// MAX_TURNS 초과 안전 종료.
if (!mounted) return;
state = state.copyWith(
isStreaming: false,
clearStreamingText: true,
error: '도구 호출 루프가 너무 길어 중단했습니다.',
);
} catch (e, st) {
if (!mounted) return;
// 개발 단계 (#342) — 실 단말 진단을 위해 release 빌드에서도 full
// message + stack 노출. 사용자 친화 메시지로 다시 좁히는 작업은
// #342 종료 후 follow-up.
final detail = 'LLM 응답 실패: ${e.runtimeType}\n$e\n\n--- STACK ---\n$st';
state = state.copyWith(
isStreaming: false,
clearStreamingText: true,
error: detail,
);
}
}
void clear() {
state = const ChatSessionState();
}
void _maybeWarnHistory() {
final turnCount = state.messages
.whereType<UserChatMessage>()
.length;
if (turnCount == kChatSoftHistoryLimit) {
state = state.copyWith(
messages: [
...state.messages,
const SystemChatMessage(
'대화가 길어졌어요. 다시 시작하면 모델이 더 빠르게 답할 수 있어요. (오른쪽 위 ↻ 버튼)',
),
],
);
}
}
@override
void dispose() {
_session?.close();
super.dispose();
}
}
final chatSessionControllerProvider = StateNotifierProvider.autoDispose<
ChatSessionController, ChatSessionState>((ref) {
final llm = ref.watch(llmServiceProvider);
final dispatcher = ref.watch(toolDispatcherProvider);
final deps = ref.watch(toolDepsProvider).requireValue;
final tools = ref.watch(toolRegistryProvider).all.toList(growable: false);
return ChatSessionController(
llm: llm,
dispatcher: dispatcher,
deps: deps,
tools: tools,
);
});

View File

@@ -0,0 +1,135 @@
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/ai/llm_service.dart';
import '../data/ai/model_lifecycle.dart';
import 'ai_providers.dart';
/// State machine for ChatScreen LLM warm-up (#311).
///
/// See `docs/design/311-llm-warmup/README.md` §6 / fn-chat_warmup_controller.md.
sealed class ChatWarmupState {
const ChatWarmupState();
}
final class ChatWarmupIdle extends ChatWarmupState {
const ChatWarmupIdle();
}
final class ChatWarmupLoading extends ChatWarmupState {
const ChatWarmupLoading();
}
final class ChatWarmupReady extends ChatWarmupState {
const ChatWarmupReady();
}
/// Warm-up was not attempted because [ModelLifecycle.quickCheck] returned
/// something other than `ready` (download incomplete, opt-out, corrupt).
/// UI behaves as if warm-up didn't exist; the first user send falls back to
/// the existing lazy `userTurn` path.
final class ChatWarmupUnavailable extends ChatWarmupState {
const ChatWarmupUnavailable();
}
/// `kind` discriminates the retry copy: `fileMissing` is a settings-level
/// recovery; `runtime` is a transient retry.
enum ChatWarmupFailureKind { fileMissing, runtime }
final class ChatWarmupFailed extends ChatWarmupState {
final String message;
final ChatWarmupFailureKind kind;
const ChatWarmupFailed(this.message, this.kind);
}
/// Drives `LlmService.load()` on ChatScreen mount so the first user send
/// doesn't pay native-init latency. AC1-AC12 (12개) 모두 본 controller 가
/// 흡수한다 (UI binding 은 chat_screen.dart 가 본 state 를 watch).
class ChatWarmupController extends StateNotifier<ChatWarmupState> {
ChatWarmupController({
required this.llm,
required this.lifecycle,
}) : super(const ChatWarmupIdle());
final LlmService llm;
final ModelLifecycle lifecycle;
bool _disposed = false;
/// Idempotent. Re-entrant guard via the Loading state — duplicate `start`
/// calls during an in-flight load do nothing (the running future will set
/// the final state). External callers use [retry] instead.
Future<void> start() async {
if (state is ChatWarmupLoading) return;
// AC11 / UX R4: fast path. Skip Loading entirely if the underlying
// service is already loaded — prevents 1-frame label flicker on
// ChatScreen re-entry.
if (llm.isLoaded) {
_safeSet(const ChatWarmupReady());
return;
}
final availability = await lifecycle.quickCheck();
if (_disposed) return;
if (availability != ModelAvailability.ready) {
_safeSet(const ChatWarmupUnavailable());
return;
}
_safeSet(const ChatWarmupLoading());
try {
await llm.load();
} catch (e) {
if (_disposed) return;
final kind = e is FileSystemException
? ChatWarmupFailureKind.fileMissing
: ChatWarmupFailureKind.runtime;
_safeSet(ChatWarmupFailed(_messageFor(kind), kind));
return;
}
if (_disposed) return;
_safeSet(const ChatWarmupReady());
}
Future<void> retry() async {
if (_disposed) return;
_safeSet(const ChatWarmupIdle());
await start();
}
/// AC6 / AC12: state는 disposed 인스턴스에는 더 이상 쓰지 않는다.
/// StateNotifier 의 setter 는 disposed 시 throw 하므로 가드 필수.
void _safeSet(ChatWarmupState s) {
if (_disposed) return;
state = s;
}
/// UX R5 / AC12: 메시지는 **상태**만 기술. "다시 시도해주세요" 같은
/// 명령형은 [다시 시도] 버튼이 담당하므로 본 문안에 넣지 않는다.
String _messageFor(ChatWarmupFailureKind kind) {
switch (kind) {
case ChatWarmupFailureKind.fileMissing:
return 'AI 모델 파일을 찾을 수 없어요.';
case ChatWarmupFailureKind.runtime:
return 'AI 를 시작하지 못했어요.';
}
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
/// autoDispose: ChatScreen 이 pop 되면 controller 도 dispose → mount race 안전.
final chatWarmupProvider =
StateNotifierProvider.autoDispose<ChatWarmupController, ChatWarmupState>(
(ref) {
return ChatWarmupController(
llm: ref.watch(llmServiceProvider),
lifecycle: ref.watch(modelLifecycleProvider),
);
},
);

44
app/lib/ui/labels.dart Normal file
View File

@@ -0,0 +1,44 @@
import '../domain/models/habit.dart';
import '../domain/streak/compute_streak.dart';
/// UI 한국어 라벨 매핑. domain enum 의 `dbValue` 는 DB 직렬화용이므로
/// 사용자에게 그대로 노출하면 'build', 'L3', 'T0' 같은 raw 식별자가
/// 그대로 보인다. 본 헬퍼는 그걸 한국어 표현으로 바꾼다.
String habitTypeLabel(HabitType t) {
switch (t) {
case HabitType.build:
return '만들기';
case HabitType.breakHabit:
return '없애기';
}
}
/// Drift row (raw db String) 에서 직접 매핑. 'build' / 'break' 외의 값은
/// 그대로 노출해 invariant 위반을 가시화.
String habitTypeLabelFromDb(String dbValue) {
switch (dbValue) {
case 'build':
return '만들기';
case 'break':
return '없애기';
default:
return dbValue;
}
}
/// 5-Tier Reward Ladder (T0 새싹 → T4 6주 완주). milestone 누적 보상.
String rewardTierLabel(RewardTier t) {
switch (t) {
case RewardTier.t0:
return '🌱 새싹';
case RewardTier.t1:
return '🥉 3회 도전';
case RewardTier.t2:
return '🥈 7일 형성';
case RewardTier.t3:
return '🥇 30일 정착';
case RewardTier.t4:
return '🏆 6주 완주';
}
}

View File

@@ -0,0 +1,447 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../ai/tools/tool_envelope.dart';
import '../../state/chat_providers.dart';
import '../../state/chat_warmup_provider.dart';
import 'settings_screen.dart';
/// AI chat surface (#260). Multi-turn tool calling powered by Gemma 4 +
/// in-process tool runtime. ConfirmGate modals appear on destructive
/// tool calls (add_habit, log_tracker_entry).
class ChatScreen extends ConsumerStatefulWidget {
const ChatScreen({super.key});
@override
ConsumerState<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends ConsumerState<ChatScreen> {
final _textCtrl = TextEditingController();
final _scrollCtrl = ScrollController();
@override
void initState() {
super.initState();
// #311 AC1: ChatScreen mount → background warm-up. depsAsync.data 가
// resolve 되기 전에는 toolDepsProvider 도 미준비라 send 자체가 막혀
// 있으므로, 그 사이에 native init 만 먼저 끝낸다.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
ref.read(chatWarmupProvider.notifier).start();
});
}
@override
void dispose() {
_textCtrl.dispose();
_scrollCtrl.dispose();
super.dispose();
}
void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_scrollCtrl.hasClients) return;
_scrollCtrl.animateTo(
_scrollCtrl.position.maxScrollExtent,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
});
}
Future<void> _send() async {
final text = _textCtrl.text.trim();
if (text.isEmpty) return;
_textCtrl.clear();
await ref
.read(chatSessionControllerProvider.notifier)
.userTurn(text, context);
_scrollToBottom();
}
@override
Widget build(BuildContext context) {
final depsAsync = ref.watch(toolDepsProvider);
return Scaffold(
appBar: AppBar(
title: const Text('AI 코치'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
tooltip: '새 대화 (이전 기록 비우기)',
onPressed: () {
ref.read(chatSessionControllerProvider.notifier).clear();
},
),
],
),
// Android edge-to-edge: 시스템 nav bar (3-button / gesture handle) 가
// 입력창을 가리지 않도록 SafeArea 로 감싼다. AppBar 가 이미 top inset
// 을 처리하므로 top 만 false.
body: SafeArea(
top: false,
child: depsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('초기화 실패: $e')),
data: (_) => _buildBody(context),
),
),
);
}
Widget _buildBody(BuildContext context) {
final state = ref.watch(chatSessionControllerProvider);
final warmup = ref.watch(chatWarmupProvider);
_scrollToBottom();
// #311 AC3 / UX R3: warmup 중에는 hintText 만 교체. 입력창 자체는
// enabled (사용자가 미리 타이핑 가능 — AC9).
final isWarming = warmup is ChatWarmupLoading;
final hintText = isWarming
? 'AI 준비 중… 첫 시작은 몇 초 걸려요'
: '습관 추가, 기록, 카탈로그 질문…';
// AC10: warmup ready 이고 streaming 중이 아닐 때 send 활성. 빈 텍스트는
// _send() 가 early-return 하므로 별도 gating 불필요 (rebuild race 회피).
final canSend = !state.isStreaming && !isWarming;
final theme = Theme.of(context);
return Column(
children: [
if (warmup is ChatWarmupFailed) _WarmupErrorBanner(warmup: warmup),
if (state.error != null)
// #342 dev — 단말에서 원인 진단을 위해 stack 까지 노출되는 케이스를
// 위해 multi-line + scrollable + selectable. 높이는 화면의 1/3 까지만.
Container(
width: double.infinity,
color: theme.colorScheme.errorContainer,
padding: const EdgeInsets.all(12),
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height / 3,
),
child: SingleChildScrollView(
child: SelectableText(
state.error!,
style: TextStyle(
color: theme.colorScheme.onErrorContainer,
fontFamily: 'monospace',
fontSize: 12,
),
),
),
),
Expanded(
child: state.messages.isEmpty && state.streamingText == null
? _EmptyChatHint(onPickPrompt: (p) {
_textCtrl.text = p;
_textCtrl.selection = TextSelection.fromPosition(
TextPosition(offset: p.length),
);
})
: ListView.builder(
controller: _scrollCtrl,
padding: const EdgeInsets.all(12),
itemCount: state.messages.length +
(state.streamingText != null &&
state.streamingText!.isNotEmpty
? 1
: 0),
itemBuilder: (context, i) {
if (i < state.messages.length) {
return _MessageBubble(message: state.messages[i]);
}
return _MessageBubble(
message: ModelChatMessage(state.streamingText ?? ''),
streaming: true,
);
},
),
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.all(8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: TextField(
controller: _textCtrl,
enabled: !state.isStreaming,
decoration: InputDecoration(
hintText: hintText,
border: const OutlineInputBorder(),
isDense: true,
),
maxLines: 4,
minLines: 1,
textInputAction: TextInputAction.send,
onSubmitted: (_) => canSend ? _send() : null,
),
),
const SizedBox(width: 8),
if (state.isStreaming || isWarming)
const Padding(
padding: EdgeInsets.all(8),
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
else
IconButton.filled(
onPressed: canSend ? _send : null,
icon: const Icon(Icons.send),
),
],
),
),
],
);
}
}
/// #311 AC5 / UX R5+R6: 실패 메시지는 상태만 기술, 행동은 버튼이 담당.
/// fileMissing 은 retry 로 회복 불가 — 설정 화면으로 유도해 재다운로드 경로를 연다.
/// runtime 은 일시적일 수 있으므로 [다시 시도] (in-place retry).
class _WarmupErrorBanner extends ConsumerWidget {
final ChatWarmupFailed warmup;
const _WarmupErrorBanner({required this.warmup});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final isFileMissing =
warmup.kind == ChatWarmupFailureKind.fileMissing;
return Container(
width: double.infinity,
color: theme.colorScheme.errorContainer,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
warmup.message,
style: TextStyle(color: theme.colorScheme.onErrorContainer),
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: OutlinedButton(
onPressed: () {
if (isFileMissing) {
// SettingsScreen pop 후 자동 retry — 사용자가 거기서 다시
// 다운로드를 완료했다면 ChatScreen 으로 돌아오자마자 회복.
// 다운로드 안 했으면 다시 Failed 로 떨어져 같은 배너 노출.
Navigator.of(context)
.push(MaterialPageRoute(
builder: (_) => const SettingsScreen(),
))
.then((_) {
if (!context.mounted) return;
ref.read(chatWarmupProvider.notifier).retry();
});
} else {
ref.read(chatWarmupProvider.notifier).retry();
}
},
child: Text(isFileMissing ? '설정으로 가기' : '다시 시도'),
),
),
],
),
);
}
}
/// 첫 진입 시 빈 대화창 안내. 사용자가 무엇을 물어볼 수 있는지
/// 예시 prompt 4개 + tool 카테고리 짧은 안내. tap 하면 입력창에 채워지고
/// 사용자가 보내기만 누르면 됨 (자동 send X — 사용자가 문구 수정 여지).
class _EmptyChatHint extends StatelessWidget {
final ValueChanged<String> onPickPrompt;
const _EmptyChatHint({required this.onPickPrompt});
static const _examples = [
'아침 햇빛 받기 습관 추가해줘',
'오늘 운동 했어',
'내 스트릭 보여줘',
'수면 프로토콜 알려줘',
];
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Icon(
Icons.smart_toy_outlined,
size: 48,
color: theme.colorScheme.primary,
),
const SizedBox(height: 16),
Text(
'AI 코치',
style: theme.textTheme.titleLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'습관 추가·기록 · 카탈로그 검색 · 스트릭 조회를 도와드려요.',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
Text(
'예시',
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
..._examples.map((p) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: OutlinedButton(
onPressed: () => onPickPrompt(p),
style: OutlinedButton.styleFrom(
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 12),
),
child: Text(p),
),
)),
],
),
);
}
}
/// Human-friendly Korean labels for the 6 tools registered in
/// `ToolRegistry.defaults()`. Falls back to the raw tool name for any
/// future tool that hasn't been mapped yet — better to show the raw id
/// than to silently drop the message.
const Map<String, String> _kToolKoreanLabels = {
'search_catalog': '카탈로그 검색',
'query_protocol': '프로토콜 상세',
'list_active_habits': '활성 습관 조회',
'get_streak': '스트릭 조회',
'add_habit': '습관 추가',
'log_tracker_entry': '체크 기록',
};
class _MessageBubble extends StatelessWidget {
final ChatMessage message;
final bool streaming;
const _MessageBubble({required this.message, this.streaming = false});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
switch (message) {
case UserChatMessage m:
return _bubble(
context,
align: Alignment.centerRight,
color: theme.colorScheme.primaryContainer,
textColor: theme.colorScheme.onPrimaryContainer,
text: m.text,
);
case ModelChatMessage m:
// Streaming cursor uses primary so it stays discoverable in both
// light and dark themes (default onSurface low-contrasted with the
// surfaceContainerHighest bubble in dark mode).
return _bubble(
context,
align: Alignment.centerLeft,
color: theme.colorScheme.surfaceContainerHighest,
textColor: theme.colorScheme.onSurface,
richText: streaming
? TextSpan(
children: [
TextSpan(text: m.text),
TextSpan(
text: '',
style: TextStyle(color: theme.colorScheme.primary),
),
],
)
: null,
text: m.text,
);
case ToolCallChatMessage m:
final label = _kToolKoreanLabels[m.name] ?? m.name;
return Align(
alignment: Alignment.center,
child: Container(
margin: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: Text(
'🛠 $label${_toolResultLabel(m.result)}',
style: theme.textTheme.bodySmall,
textAlign: TextAlign.center,
),
),
);
case SystemChatMessage m:
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Text(
m.text,
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.outline,
),
),
);
}
}
Widget _bubble(
BuildContext context, {
required Alignment align,
required Color color,
required Color textColor,
required String text,
TextSpan? richText,
}) {
return Align(
alignment: align,
child: Container(
margin: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.75,
),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(12),
),
child: richText != null
? Text.rich(richText, style: TextStyle(color: textColor))
: Text(text, style: TextStyle(color: textColor)),
),
);
}
String _toolResultLabel(ToolResult r) {
switch (r) {
case ToolOk _:
return 'OK';
case ToolErr e:
return '오류: ${e.code}';
case ToolCancelled _:
return '취소됨';
}
}
}

View File

@@ -50,7 +50,7 @@ class _CheckInScreenState extends ConsumerState<CheckInScreen> {
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('오늘 (${_ymd(nowKst())})',
Text('오늘 · ${_koreanDate(nowKst())}',
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center),
const SizedBox(height: 32),
@@ -80,3 +80,9 @@ String _ymd(DateTime d) =>
'${d.year.toString().padLeft(4, '0')}-'
'${d.month.toString().padLeft(2, '0')}-'
'${d.day.toString().padLeft(2, '0')}';
/// 사용자 노출용 한국식 날짜 — '6월 15일 (월)'. DB 저장은 _ymd 가 담당.
String _koreanDate(DateTime d) {
const weekdays = ['', '', '', '', '', '', ''];
return '${d.month}${d.day}일 (${weekdays[d.weekday - 1]})';
}

View File

@@ -94,9 +94,8 @@ class _HabitCreateScreenState extends ConsumerState<HabitCreateScreen> {
DropdownButtonFormField<HabitType>(
initialValue: _type,
items: const [
DropdownMenuItem(value: HabitType.build, child: Text('만들기 (build)')),
DropdownMenuItem(
value: HabitType.breakHabit, child: Text('없애기 (break)')),
DropdownMenuItem(value: HabitType.build, child: Text('만들기')),
DropdownMenuItem(value: HabitType.breakHabit, child: Text('없애기')),
],
onChanged: (v) => setState(() => _type = v ?? HabitType.build),
decoration: const InputDecoration(labelText: '타입'),
@@ -105,11 +104,16 @@ class _HabitCreateScreenState extends ConsumerState<HabitCreateScreen> {
DropdownButtonFormField<FrameLevel>(
initialValue: _level,
items: const [
DropdownMenuItem(value: FrameLevel.l2, child: Text('L2 · 조건부 긍정')),
DropdownMenuItem(value: FrameLevel.l3, child: Text('L3 · 정체성')),
DropdownMenuItem(
value: FrameLevel.l2, child: Text('조건부 행동 (예: 아침에 햇빛 받기)')),
DropdownMenuItem(
value: FrameLevel.l3, child: Text('정체성 (예: 나는 일찍 자는 사람)')),
],
onChanged: (v) => setState(() => _level = v ?? FrameLevel.l2),
decoration: const InputDecoration(labelText: '프레임 레벨'),
decoration: const InputDecoration(
labelText: '표현 방식',
helperText: '문구를 어떻게 적을지 — 행동 위주 vs 정체성 위주',
),
),
const SizedBox(height: 16),
TextFormField(

View File

@@ -1,9 +1,13 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../state/ai_providers.dart';
import '../../state/providers.dart';
import '../labels.dart';
import 'chat_screen.dart';
import 'check_in_screen.dart';
import 'habit_create_screen.dart';
import 'protocol_gallery_screen.dart';
import 'settings_screen.dart';
import 'streak_screen.dart';
@@ -14,11 +18,27 @@ class HabitListScreen extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final boot = ref.watch(bootstrapProvider);
final habitsAsync = ref.watch(activeHabitsProvider);
final aiOptIn = ref.watch(aiSettingsProvider).valueOrNull ?? false;
return Scaffold(
appBar: AppBar(
title: const Text('습관'),
actions: [
if (aiOptIn)
IconButton(
icon: const Icon(Icons.smart_toy_outlined),
tooltip: 'AI 코치',
onPressed: () {
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => const ChatScreen(),
));
},
),
IconButton(
icon: const Icon(Icons.search),
tooltip: '카탈로그 탐색',
onPressed: () => _openGallery(context),
),
IconButton(
icon: const Icon(Icons.settings),
tooltip: '설정',
@@ -38,8 +58,25 @@ class HabitListScreen extends ConsumerWidget {
error: (e, st) => Center(child: Text('로드 실패: $e')),
data: (habits) {
if (habits.isEmpty) {
return const Center(
child: Text('아직 습관이 없습니다. + 버튼으로 추가하세요.'),
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Padding(
padding: EdgeInsets.symmetric(horizontal: 24),
child: Text(
'아직 습관이 없습니다.\n+ 버튼으로 추가하거나, 카탈로그에서 골라보세요.',
textAlign: TextAlign.center,
),
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: () => _openGallery(context),
icon: const Icon(Icons.search),
label: const Text('🔍 카탈로그 탐색'),
),
],
),
);
}
return ListView.separated(
@@ -49,8 +86,10 @@ class HabitListScreen extends ConsumerWidget {
final h = habits[i];
return ListTile(
title: Text(h.title),
// FrameLevel (L2/L3) 은 시스템 규약이라 사용자에게 노출
// 가치 낮음 — type chip + framedText 만 표시.
subtitle: Text(
'${h.type} · ${h.frameLevel} · ${h.frameFramedText}',
'${habitTypeLabelFromDb(h.type)} · ${h.frameFramedText}',
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
@@ -83,4 +122,10 @@ class HabitListScreen extends ConsumerWidget {
),
);
}
void _openGallery(BuildContext context) {
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => const ProtocolGalleryScreen(),
));
}
}

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

@@ -5,6 +5,7 @@ import '../../core/time.dart';
import '../../domain/models/tracker_entry.dart';
import '../../domain/streak/compute_streak.dart';
import '../../state/providers.dart';
import '../labels.dart';
class StreakScreen extends ConsumerWidget {
final String habitId;
@@ -51,25 +52,43 @@ class StreakScreen extends ConsumerWidget {
asOf: nowKst(),
habitStartedAt: habit.startedAt as String,
);
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(habit.title as String,
style: Theme.of(context).textTheme.titleLarge),
style: theme.textTheme.titleLarge),
const SizedBox(height: 24),
_Row('현재 스트릭', '${state.currentStreak}'),
// Hero — 핵심 동기 지표. 큰 숫자 + 티어 emoji 라벨로 위계 강조.
Center(
child: Column(
children: [
Text(
'${state.currentStreak}',
style: theme.textTheme.displayLarge?.copyWith(
fontWeight: FontWeight.w700,
color: theme.colorScheme.primary,
),
),
Text('일 연속 (현재 스트릭)',
style: theme.textTheme.bodyMedium),
const SizedBox(height: 4),
Text(rewardTierLabel(state.currentTier),
style: theme.textTheme.titleMedium),
],
),
),
const Divider(height: 40),
_Row('최장 스트릭', '${state.longestStreak}'),
_Row('최근 30일 / 완료', '${state.doneCountInWindow30}'),
_Row('Phase 42일 / 완료', '${state.doneCountInPhase42}'),
const Divider(height: 32),
_Row('현재 티어', state.currentTier.dbValue),
if (state.neverMissTwiceBroken)
const Padding(
padding: EdgeInsets.only(top: 12),
child: Text(
'Never miss twice 발동 — 티어 강등',
'이틀 연속 빠졌어요. 한 단계 강등됐습니다.',
style: TextStyle(color: Colors.redAccent),
),
),

View File

@@ -0,0 +1,105 @@
import 'package:flutter/material.dart';
import '../../domain/catalog/catalog_item.dart';
class CatalogCard extends StatelessWidget {
const CatalogCard({super.key, required this.item, required this.onTap});
final CatalogItem item;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final dc = item.displayCategory;
return Semantics(
label: '${dc.label} 카테고리. ${item.title}. ${item.summary}',
button: true,
child: Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Icon(dc.icon, size: 18),
const SizedBox(width: 6),
Expanded(
child: Text(
item.title,
style: Theme.of(context).textTheme.titleMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
if (item.titleEn != null) ...[
const SizedBox(height: 2),
Text(
item.titleEn!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
const SizedBox(height: 8),
Expanded(
child: Text(
item.summary,
style: Theme.of(context).textTheme.bodySmall,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
if (item.evidenceStrength != null) ...[
const SizedBox(height: 6),
_EvidenceBadge(strength: item.evidenceStrength!),
],
],
),
),
),
),
);
}
}
class _EvidenceBadge extends StatelessWidget {
const _EvidenceBadge({required this.strength});
final String strength;
@override
Widget build(BuildContext context) {
final (label, color) = switch (strength) {
'strong_rct' || 'strong' => ('근거 강함', Colors.green),
'meta_analysis' => ('메타분석', Colors.teal),
'moderate' => ('근거 중간', Colors.blue),
'observational' => ('관찰연구', Colors.blueGrey),
'mechanistic' => ('기전', Colors.orange),
'expert_opinion' => ('전문가 의견', Colors.brown),
'mixed' => ('근거 혼재', Colors.amber),
'weak' => ('근거 약함', Colors.grey),
_ => (strength, Colors.grey),
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
border: Border.all(color: color),
borderRadius: BorderRadius.circular(4),
),
child: Text(
label,
style: TextStyle(fontSize: 11, color: color),
),
);
}
}

View File

@@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
import '../../domain/catalog/display_category.dart';
/// 가로 카테고리 칩. "전체" + 비어있지 않은 카테고리만 표시.
///
/// 선택 카테고리 = null → 전체 보기.
class CategoryChipRow extends StatelessWidget {
const CategoryChipRow({
super.key,
required this.categories,
required this.selected,
required this.onSelect,
});
final List<DisplayCategory> categories;
final DisplayCategory? selected;
final ValueChanged<DisplayCategory?> onSelect;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 48,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
children: [
Padding(
padding: const EdgeInsets.only(right: 6),
child: ChoiceChip(
label: const Text('전체'),
selected: selected == null,
onSelected: (_) => onSelect(null),
),
),
for (final c in categories)
Padding(
padding: const EdgeInsets.only(right: 6),
child: ChoiceChip(
label: Text(c.label),
avatar: Icon(c.icon, size: 16),
selected: selected == c,
onSelected: (_) => onSelect(c),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import '../../data/db/app_database.dart';
/// reference 1건을 펼치기 카드로 표시.
///
/// 본 이슈 (#226) 에선 url 표시만 (탭 시 launcher 호출 X — #FF1 이후).
class ReferenceExpandCard extends StatelessWidget {
const ReferenceExpandCard({super.key, required this.reference});
final ReferenceRow reference;
@override
Widget build(BuildContext context) {
final kindLabel = switch (reference.kind) {
'paper' => '논문',
'podcast_episode' => '팟캐스트',
'book' => '서적',
'url' => '',
'korean_explainer' => '한국어 해설',
_ => reference.kind,
};
return Card(
child: ExpansionTile(
title: Text(
reference.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(kindLabel,
style: Theme.of(context).textTheme.bodySmall),
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (reference.year != null)
_row(context, '연도', reference.year.toString()),
if (reference.journal != null)
_row(context, '저널', reference.journal!),
if (reference.publisher != null)
_row(context, '출판', reference.publisher!),
if (reference.episodeNumber != null)
_row(context, '에피소드',
reference.episodeNumber.toString()),
if (reference.doi != null) _row(context, 'DOI', reference.doi!),
if (reference.url != null) _row(context, 'URL', reference.url!),
if (reference.note != null)
_row(context, '메모', reference.note!),
],
),
),
],
),
);
}
Widget _row(BuildContext context, String label, String value) {
return Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text.rich(TextSpan(children: [
TextSpan(
text: '$label: ',
style: const TextStyle(fontWeight: FontWeight.bold),
),
TextSpan(text: value),
]), style: Theme.of(context).textTheme.bodySmall),
);
}
}

View File

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

View File

@@ -0,0 +1,48 @@
import 'package:drift/drift.dart' as drift;
import 'package:life_helper/ai/tools/tool_definition.dart';
import 'package:life_helper/core/constants.dart';
import 'package:life_helper/core/time.dart';
import 'package:life_helper/data/catalog/catalog_repository.dart';
import 'package:life_helper/data/db/app_database.dart';
import 'package:life_helper/data/db/daos/habit_dao.dart';
import 'package:life_helper/data/db/daos/tracker_dao.dart';
import 'package:life_helper/data/seed/seed_importer.dart';
import 'package:life_helper/domain/models/frame_pattern.dart';
import '../../data/seed/test_seeds.dart';
/// Tool tests share a tiny in-memory bootstrap. Returns the assembled
/// [ToolDeps] plus the underlying [AppDatabase] so callers can close it
/// in tearDown.
Future<({AppDatabase db, ToolDeps deps})> bootstrapToolDeps() async {
final db = AppDatabase.memory();
// default user (seed importer doesn't insert users — bootstrap does).
await db.into(db.users).insert(UsersCompanion.insert(
id: kLocalDefaultUserId,
displayName: const drift.Value('Test'),
createdAt: nowKst().toIso8601String(),
));
await SeedImporter(db, loadAsset: testStubLoader).importIfNeeded();
final patterns = await db.select(db.framePatterns).get();
final framePatterns = patterns
.map((r) => FramePatternModel(
id: r.id,
domain: r.domain,
avoidanceKeyword: r.avoidanceKeyword,
l0Example: r.l0Example,
l1SimpleReplace: r.l1SimpleReplace,
l2Suggestion: r.l2Suggestion,
l3Identity: r.l3Identity,
))
.toList();
return (
db: db,
deps: ToolDeps(
habitDao: HabitDao(db),
trackerDao: TrackerDao(db),
catalog: CatalogRepository(db),
framePatterns: framePatterns,
userId: kLocalDefaultUserId,
),
);
}

View File

@@ -0,0 +1,82 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:life_helper/ai/tools/catalog_tools.dart';
import 'package:life_helper/ai/tools/tool_envelope.dart';
import '_tool_test_helpers.dart';
void main() {
group('search_catalog', () {
test('전체 검색 (인자 없음)', () async {
final ctx = await bootstrapToolDeps();
addTearDown(() => ctx.db.close());
final r = await searchCatalogTool.handler({}, ctx.deps);
expect(r, isA<ToolOk>());
final data = (r as ToolOk).data;
expect(data['count'], greaterThan(0));
// stub 3 items: protocol + break + diet
expect(data['count'], 3);
final items = data['items'] as List;
expect(items.first.containsKey('id'), true);
expect(items.first.containsKey('summary'), true);
});
test('카테고리 필터', () async {
final ctx = await bootstrapToolDeps();
addTearDown(() => ctx.db.close());
final r = await searchCatalogTool
.handler({'category': 'breakHabit'}, ctx.deps);
expect(r, isA<ToolOk>());
final items = ((r as ToolOk).data['items'] as List);
expect(items.length, 1);
expect((items.first as Map)['category'], 'breakHabit');
});
test('잘못된 카테고리 → validation 에러', () async {
final ctx = await bootstrapToolDeps();
addTearDown(() => ctx.db.close());
final r = await searchCatalogTool
.handler({'category': 'no_such'}, ctx.deps);
expect(r, isA<ToolErr>());
expect((r as ToolErr).code, 'validation');
});
test('limit 범위 검증', () async {
final ctx = await bootstrapToolDeps();
addTearDown(() => ctx.db.close());
final r = await searchCatalogTool.handler({'limit': 99}, ctx.deps);
expect(r, isA<ToolErr>());
expect((r as ToolErr).code, 'validation');
});
});
group('query_protocol', () {
test('정상 조회 → kind 분기', () async {
final ctx = await bootstrapToolDeps();
addTearDown(() => ctx.db.close());
final r = await queryProtocolTool
.handler({'id': 'morning_sunlight'}, ctx.deps);
expect(r, isA<ToolOk>());
final data = (r as ToolOk).data;
expect(data['kind'], 'protocol');
expect(data['what'], isNotNull);
});
test('break 항목 → kind=break', () async {
final ctx = await bootstrapToolDeps();
addTearDown(() => ctx.db.close());
final r =
await queryProtocolTool.handler({'id': 'alcohol'}, ctx.deps);
expect(r, isA<ToolOk>());
expect((r as ToolOk).data['kind'], 'break');
});
test('미존재 id → not_found', () async {
final ctx = await bootstrapToolDeps();
addTearDown(() => ctx.db.close());
final r =
await queryProtocolTool.handler({'id': 'no_such'}, ctx.deps);
expect(r, isA<ToolErr>());
expect((r as ToolErr).code, 'not_found');
});
});
}

View File

@@ -0,0 +1,130 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:life_helper/ai/tools/habit_tools.dart';
import 'package:life_helper/ai/tools/tool_envelope.dart';
import 'package:life_helper/core/constants.dart';
import 'package:life_helper/core/time.dart';
import 'package:life_helper/data/db/daos/habit_dao.dart';
import 'package:life_helper/domain/models/habit.dart';
import '_tool_test_helpers.dart';
void main() {
group('add_habit', () {
test('정상 build → ToolOk', () async {
final ctx = await bootstrapToolDeps();
addTearDown(() => ctx.db.close());
final r = await addHabitTool.handler({
'protocol_id': 'morning_sunlight',
'frame_level': 'L2',
'framed_text': '아침에 햇빛 보기',
}, ctx.deps);
expect(r, isA<ToolOk>(), reason: '$r');
final data = (r as ToolOk).data;
expect(data['type'], 'build');
expect(data['frame_level'], 'L2');
expect(data['habit_id'], isNotEmpty);
});
test('정상 break → type=break', () async {
final ctx = await bootstrapToolDeps();
addTearDown(() => ctx.db.close());
final r = await addHabitTool.handler({
'protocol_id': 'alcohol',
'frame_level': 'L3',
'framed_text': '맑은 정신을 즐긴다',
}, ctx.deps);
expect(r, isA<ToolOk>(), reason: '$r');
expect((r as ToolOk).data['type'], 'break');
});
test('L0 프레임 → validation 거부', () async {
final ctx = await bootstrapToolDeps();
addTearDown(() => ctx.db.close());
final r = await addHabitTool.handler({
'protocol_id': 'morning_sunlight',
'frame_level': 'L0',
'framed_text': '게으름 피우지 마',
}, ctx.deps);
expect(r, isA<ToolErr>());
expect((r as ToolErr).code, 'validation');
});
test('미존재 protocol_id → not_found', () async {
final ctx = await bootstrapToolDeps();
addTearDown(() => ctx.db.close());
final r = await addHabitTool.handler({
'protocol_id': 'no_such',
'frame_level': 'L2',
'framed_text': '뭐든',
}, ctx.deps);
expect(r, isA<ToolErr>());
expect((r as ToolErr).code, 'not_found');
});
test('R7 회피 키워드 → r7_avoidance', () async {
final ctx = await bootstrapToolDeps();
addTearDown(() => ctx.db.close());
// 시드 framePatterns 에 "술 끊기" avoidance keyword 존재.
final r = await addHabitTool.handler({
'protocol_id': 'alcohol',
'frame_level': 'L2',
'framed_text': '술 끊기 해야지',
}, ctx.deps);
expect(r, isA<ToolErr>());
expect((r as ToolErr).code, 'r7_avoidance');
});
test('R3 quota (build 3개) 초과', () async {
final ctx = await bootstrapToolDeps();
addTearDown(() => ctx.db.close());
// 3개 사전 삽입.
for (var i = 0; i < 3; i++) {
await ctx.deps.habitDao.insertWithVariants(HabitDraft(
userId: kLocalDefaultUserId,
type: HabitType.build,
title: 'pre_$i',
protocolId: 'morning_sunlight',
frameLevel: FrameLevel.l2,
frameFramedText: 'pre$i',
startedAt: dateOnly(nowKst()),
));
}
final r = await addHabitTool.handler({
'protocol_id': 'morning_sunlight',
'frame_level': 'L2',
'framed_text': '4번째 시도',
}, ctx.deps);
expect(r, isA<ToolErr>());
expect((r as ToolErr).code, 'r3_quota');
});
});
group('list_active_habits', () {
test('0개일 때', () async {
final ctx = await bootstrapToolDeps();
addTearDown(() => ctx.db.close());
final r = await listActiveHabitsTool.handler({}, ctx.deps);
expect(r, isA<ToolOk>());
final data = (r as ToolOk).data;
expect(data['count'], 0);
expect(data['build_quota_remaining'], 3);
expect(data['break_quota_remaining'], 1);
});
test('add_habit 후 1개', () async {
final ctx = await bootstrapToolDeps();
addTearDown(() => ctx.db.close());
await addHabitTool.handler({
'protocol_id': 'morning_sunlight',
'frame_level': 'L2',
'framed_text': '햇빛',
}, ctx.deps);
final r = await listActiveHabitsTool.handler({}, ctx.deps);
expect(r, isA<ToolOk>());
final data = (r as ToolOk).data;
expect(data['count'], 1);
expect(data['build_count'], 1);
expect(data['build_quota_remaining'], 2);
});
});
}

View File

@@ -0,0 +1,114 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:life_helper/ai/tools/tool_definition.dart';
import 'package:life_helper/ai/tools/tool_dispatcher.dart';
import 'package:life_helper/ai/tools/tool_envelope.dart';
import 'package:life_helper/ai/tools/tool_registry.dart';
import '_tool_test_helpers.dart';
void main() {
group('ToolDispatcher', () {
test('unknown tool 이름', () async {
final ctx = await bootstrapToolDeps();
addTearDown(() => ctx.db.close());
final dispatcher =
ToolDispatcher(registry: ToolRegistry.defaults());
final r = await dispatcher.dispatch(
toolName: 'no_such',
rawArgs: const {},
confirmContext: null,
deps: ctx.deps,
);
expect(r, isA<ToolErr>());
expect((r as ToolErr).code, 'unknown_tool');
});
test('validation: required 없음', () async {
final ctx = await bootstrapToolDeps();
addTearDown(() => ctx.db.close());
final dispatcher =
ToolDispatcher(registry: ToolRegistry.defaults());
final r = await dispatcher.dispatch(
toolName: 'query_protocol',
rawArgs: const {},
confirmContext: null,
deps: ctx.deps,
);
expect(r, isA<ToolErr>());
expect((r as ToolErr).code, 'validation');
});
test('validation: 타입 불일치', () async {
final ctx = await bootstrapToolDeps();
addTearDown(() => ctx.db.close());
final dispatcher =
ToolDispatcher(registry: ToolRegistry.defaults());
final r = await dispatcher.dispatch(
toolName: 'query_protocol',
rawArgs: const {'id': 123},
confirmContext: null,
deps: ctx.deps,
);
expect(r, isA<ToolErr>());
expect((r as ToolErr).code, 'validation');
});
test('destructive + null confirmContext → ToolCancelled', () async {
final ctx = await bootstrapToolDeps();
addTearDown(() => ctx.db.close());
final dispatcher =
ToolDispatcher(registry: ToolRegistry.defaults());
final r = await dispatcher.dispatch(
toolName: 'add_habit',
rawArgs: const {
'protocol_id': 'morning_sunlight',
'frame_level': 'L2',
'framed_text': '햇빛',
},
confirmContext: null,
deps: ctx.deps,
);
expect(r, isA<ToolCancelled>());
});
test('read-only normal 경로', () async {
final ctx = await bootstrapToolDeps();
addTearDown(() => ctx.db.close());
final dispatcher =
ToolDispatcher(registry: ToolRegistry.defaults());
final r = await dispatcher.dispatch(
toolName: 'search_catalog',
rawArgs: const {},
confirmContext: null,
deps: ctx.deps,
);
expect(r, isA<ToolOk>());
});
test('handler 예외 → ToolErr(handler_error)', () async {
final ctx = await bootstrapToolDeps();
addTearDown(() => ctx.db.close());
final throwing = ToolDefinition(
name: 'always_throws',
description: 'test',
parametersSchema: const {
'type': 'object',
'properties': {},
'required': [],
},
handler: (_, _) async => throw StateError('boom'),
);
final dispatcher = ToolDispatcher(
registry: ToolRegistry([throwing]),
);
final r = await dispatcher.dispatch(
toolName: 'always_throws',
rawArgs: const {},
confirmContext: null,
deps: ctx.deps,
);
expect(r, isA<ToolErr>());
expect((r as ToolErr).code, 'handler_error');
});
});
}

View File

@@ -0,0 +1,66 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:life_helper/ai/tools/tool_envelope.dart';
void main() {
group('ToolResult', () {
test('ToolOk JSON 형태', () {
const r = ToolOk({'a': 1});
expect(r.toJson(), {
'status': 'ok',
'data': {'a': 1}
});
});
test('ToolErr JSON 형태', () {
const r = ToolErr('validation', '잘못된 인자');
expect(r.toJson(), {
'status': 'error',
'code': 'validation',
'reason': '잘못된 인자',
});
});
test('ToolCancelled JSON 형태', () {
const r = ToolCancelled();
expect(r.toJson(), {
'status': 'cancelled',
'reason': 'user did not confirm',
});
});
});
group('encodeToolResult 2KB cap', () {
test('payload 작으면 그대로', () {
const r = ToolOk({'k': 'v'});
final s = encodeToolResult(r);
expect(jsonDecode(s), {
'status': 'ok',
'data': {'k': 'v'}
});
});
test('payload 2KB 초과 시 truncation hint 로 대체', () {
final big = ToolOk({'blob': 'x' * 5000});
final s = encodeToolResult(big);
expect(s.length, lessThan(500));
final decoded = jsonDecode(s) as Map<String, dynamic>;
expect(decoded['status'], 'ok');
final data = decoded['data'] as Map<String, dynamic>;
expect(data['_truncated'], true);
expect(data['_hint'], contains('query_protocol'));
});
test('error/cancelled 는 작아서 그대로', () {
expect(
encodeToolResult(const ToolErr('e', 'r')).length,
lessThan(100),
);
expect(
encodeToolResult(const ToolCancelled()).length,
lessThan(100),
);
});
});
}

View File

@@ -0,0 +1,116 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:life_helper/ai/tools/habit_tools.dart';
import 'package:life_helper/ai/tools/tracker_tools.dart';
import 'package:life_helper/ai/tools/tool_envelope.dart';
import '_tool_test_helpers.dart';
void main() {
group('log_tracker_entry', () {
test('정상 done 기록', () async {
final ctx = await bootstrapToolDeps();
addTearDown(() => ctx.db.close());
final addRes = await addHabitTool.handler({
'protocol_id': 'morning_sunlight',
'frame_level': 'L2',
'framed_text': '햇빛',
}, ctx.deps);
final habitId = (addRes as ToolOk).data['habit_id'] as String;
final r = await logTrackerEntryTool.handler({
'habit_id': habitId,
'value': 'done',
'date': '2026-06-15',
}, ctx.deps);
expect(r, isA<ToolOk>(), reason: '$r');
final data = (r as ToolOk).data;
expect(data['habit_id'], habitId);
expect(data['value'], 'done');
});
test('value 유효성', () async {
final ctx = await bootstrapToolDeps();
addTearDown(() => ctx.db.close());
final r = await logTrackerEntryTool.handler({
'habit_id': 'whatever',
'value': 'maybe',
}, ctx.deps);
expect(r, isA<ToolErr>());
expect((r as ToolErr).code, 'validation');
});
test('미존재 habit_id', () async {
final ctx = await bootstrapToolDeps();
addTearDown(() => ctx.db.close());
final r = await logTrackerEntryTool.handler({
'habit_id': 'hb_no_such',
'value': 'done',
}, ctx.deps);
expect(r, isA<ToolErr>());
expect((r as ToolErr).code, 'not_found');
});
test('같은 (habit_id, date) 중복 → duplicate', () async {
final ctx = await bootstrapToolDeps();
addTearDown(() => ctx.db.close());
final addRes = await addHabitTool.handler({
'protocol_id': 'morning_sunlight',
'frame_level': 'L2',
'framed_text': '햇빛',
}, ctx.deps);
final habitId = (addRes as ToolOk).data['habit_id'] as String;
await logTrackerEntryTool.handler({
'habit_id': habitId,
'value': 'done',
'date': '2026-06-15',
}, ctx.deps);
final r2 = await logTrackerEntryTool.handler({
'habit_id': habitId,
'value': 'blank',
'date': '2026-06-15',
}, ctx.deps);
expect(r2, isA<ToolErr>());
expect((r2 as ToolErr).code, 'duplicate');
});
test('date 형식 오류', () async {
final ctx = await bootstrapToolDeps();
addTearDown(() => ctx.db.close());
final r = await logTrackerEntryTool.handler({
'habit_id': 'h',
'value': 'done',
'date': '2026/06/15',
}, ctx.deps);
expect(r, isA<ToolErr>());
expect((r as ToolErr).code, 'validation');
});
});
group('get_streak', () {
test('습관 없음 → not_found', () async {
final ctx = await bootstrapToolDeps();
addTearDown(() => ctx.db.close());
final r =
await getStreakTool.handler({'habit_id': 'hb_no'}, ctx.deps);
expect(r, isA<ToolErr>());
expect((r as ToolErr).code, 'not_found');
});
test('정상 — 기록 없을 때 0 streak', () async {
final ctx = await bootstrapToolDeps();
addTearDown(() => ctx.db.close());
final addRes = await addHabitTool.handler({
'protocol_id': 'morning_sunlight',
'frame_level': 'L2',
'framed_text': '햇빛',
}, ctx.deps);
final habitId = (addRes as ToolOk).data['habit_id'] as String;
final r =
await getStreakTool.handler({'habit_id': habitId}, ctx.deps);
expect(r, isA<ToolOk>(), reason: '$r');
final data = (r as ToolOk).data;
expect(data['current_streak'], 0);
expect(data['tier'], isNotNull);
});
});
}

View File

@@ -172,6 +172,66 @@ void main() {
expect(await lc.checkAvailability(), ModelAvailability.downloading);
});
test('quickCheck ready when meta_kv complete + file exists (no SHA)', () async {
const file = 'gemma_quick.bin';
final lc = ModelLifecycle(
meta: meta,
// 일부러 expectedSha 와 다르게 — quickCheck 는 SHA 비교 X.
config: ModelConfig(
url: Uri.parse(url),
expectedSha256: 'unused_by_quickcheck',
filename: file,
),
storage: storage,
);
await meta.put(AiMetaKeys.optIn, 'true');
final path = '${tmp.path}/$file';
File(path).writeAsStringSync('payload');
await meta.put(AiMetaKeys.modelPath, path);
await meta.put(AiMetaKeys.modelSha, 'whatever');
expect(await lc.quickCheck(), ModelAvailability.ready);
});
test('quickCheck missing when modelPath not set', () async {
final lc = ModelLifecycle(
meta: meta,
config: ModelConfig(url: Uri.parse(url), expectedSha256: 'x'),
storage: storage,
);
await meta.put(AiMetaKeys.optIn, 'true');
expect(await lc.quickCheck(), ModelAvailability.missing);
});
test('quickCheck missing when file deleted from disk', () async {
const file = 'gemma_gone.bin';
final lc = ModelLifecycle(
meta: meta,
config: ModelConfig(
url: Uri.parse(url),
expectedSha256: 'x',
filename: file,
),
storage: storage,
);
await meta.put(AiMetaKeys.optIn, 'true');
await meta.put(AiMetaKeys.modelPath, '${tmp.path}/$file');
await meta.put(AiMetaKeys.modelSha, 'sha');
// 파일 자체는 만들지 않음.
expect(await lc.quickCheck(), ModelAvailability.missing);
});
test('quickCheck downloading when state in progress', () async {
final lc = ModelLifecycle(
meta: meta,
config: ModelConfig(url: Uri.parse(url), expectedSha256: 'x'),
storage: storage,
);
await meta.put(AiMetaKeys.optIn, 'true');
await meta.put(AiMetaKeys.downloadState, 'downloading');
expect(await lc.quickCheck(), ModelAvailability.downloading);
});
test('checkAvailability returns corrupt when file SHA mismatches', () async {
const file = 'gemma_corrupt.bin';
final lc = ModelLifecycle(

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,216 @@
import 'dart:io';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:life_helper/data/ai/llm_service.dart';
import 'package:life_helper/data/ai/model_lifecycle.dart';
import 'package:life_helper/data/db/app_database.dart';
import 'package:life_helper/data/db/daos/meta_dao.dart';
import 'package:life_helper/state/chat_warmup_provider.dart';
class _NoopStorage implements StorageAdapter {
_NoopStorage(this.dir);
final Directory dir;
@override
Future<Directory> supportDir() async => dir;
@override
Future<http.StreamedResponse> rangeGet(Uri url, int from) =>
throw UnimplementedError();
}
/// quickCheck 만 사용하는 controller 테스트에서는 download 경로가 필요 없다.
/// `meta_kv` 를 직접 세팅해 quickCheck 가 ready/missing 등으로 분기되게 만든다.
Future<ModelLifecycle> _setupLifecycle({
required MetaDao meta,
required Directory tmp,
required bool readyOnDisk,
}) async {
final lc = ModelLifecycle(
meta: meta,
config: ModelConfig(
url: Uri.parse('https://example/model.bin'),
expectedSha256: 'x',
filename: 'warmup_test.bin',
),
storage: _NoopStorage(tmp),
);
await meta.put(AiMetaKeys.optIn, 'true');
if (readyOnDisk) {
final path = '${tmp.path}/warmup_test.bin';
File(path).writeAsStringSync('payload');
await meta.put(AiMetaKeys.modelPath, path);
await meta.put(AiMetaKeys.modelSha, 'any');
}
return lc;
}
void main() {
late AppDatabase db;
late MetaDao meta;
late Directory tmp;
setUp(() async {
db = AppDatabase(NativeDatabase.memory());
meta = MetaDao(db);
tmp = await Directory.systemTemp.createTemp('warmup_test_');
});
tearDown(() async {
await db.close();
if (tmp.existsSync()) await tmp.delete(recursive: true);
});
test('AC1/AC3/AC4: happy path emits Idle → Loading → Ready', () async {
final lc = await _setupLifecycle(meta: meta, tmp: tmp, readyOnDisk: true);
final mock = MockLlmService()..loadDelay = const Duration(milliseconds: 30);
final controller =
ChatWarmupController(llm: mock, lifecycle: lc);
final seen = <ChatWarmupState>[];
controller.addListener(seen.add, fireImmediately: false);
await controller.start();
expect(seen.first, isA<ChatWarmupLoading>());
expect(seen.last, isA<ChatWarmupReady>());
expect(mock.isLoaded, true);
expect(mock.loadCount, 1);
});
test('AC11 / UX R4: fast path skips Loading when already loaded', () async {
final lc = await _setupLifecycle(meta: meta, tmp: tmp, readyOnDisk: true);
final mock = MockLlmService();
await mock.load(); // pre-loaded
final controller = ChatWarmupController(llm: mock, lifecycle: lc);
final seen = <ChatWarmupState>[];
controller.addListener(seen.add, fireImmediately: false);
await controller.start();
expect(seen, hasLength(1));
expect(seen.single, isA<ChatWarmupReady>());
// 추가 native init 호출 없음 (사전 mock.load() 1 회만 — fast path 가
// _doLoad 를 다시 호출하지 않음을 검증).
expect(mock.loadCount, 1);
});
test('AC2: quickCheck != ready → Unavailable, load not called', () async {
// readyOnDisk: false → meta_kv 의 modelPath 가 없음 → missing.
final lc = await _setupLifecycle(meta: meta, tmp: tmp, readyOnDisk: false);
final mock = MockLlmService();
final controller = ChatWarmupController(llm: mock, lifecycle: lc);
await controller.start();
expect(controller.state, isA<ChatWarmupUnavailable>());
expect(mock.loadCount, 0);
expect(mock.isLoaded, false);
});
test('AC5: FileSystemException → Failed(fileMissing)', () async {
final lc = await _setupLifecycle(meta: meta, tmp: tmp, readyOnDisk: true);
final mock = MockLlmService()
..loadThrows = const FileSystemException('model file missing', '/x');
final controller = ChatWarmupController(llm: mock, lifecycle: lc);
await controller.start();
expect(controller.state, isA<ChatWarmupFailed>());
final failed = controller.state as ChatWarmupFailed;
expect(failed.kind, ChatWarmupFailureKind.fileMissing);
expect(failed.message, 'AI 모델 파일을 찾을 수 없어요.');
// AC12: message 에 명령형 문구 금지.
expect(failed.message, isNot(contains('다시 시도')));
});
test('AC5: generic error → Failed(runtime)', () async {
final lc = await _setupLifecycle(meta: meta, tmp: tmp, readyOnDisk: true);
final mock = MockLlmService()..loadThrows = StateError('boom');
final controller = ChatWarmupController(llm: mock, lifecycle: lc);
await controller.start();
final failed = controller.state as ChatWarmupFailed;
expect(failed.kind, ChatWarmupFailureKind.runtime);
expect(failed.message, 'AI 를 시작하지 못했어요.');
expect(failed.message, isNot(contains('다시 시도')));
});
test('retry: Failed → retry() → Loading → Ready', () async {
final lc = await _setupLifecycle(meta: meta, tmp: tmp, readyOnDisk: true);
final mock = MockLlmService()..loadThrows = StateError('first fails');
final controller = ChatWarmupController(llm: mock, lifecycle: lc);
await controller.start();
expect(controller.state, isA<ChatWarmupFailed>());
// 두 번째 시도는 성공시킴.
mock.loadThrows = null;
final seen = <ChatWarmupState>[];
controller.addListener(seen.add, fireImmediately: false);
await controller.retry();
expect(seen.map((s) => s.runtimeType).toList(), [
ChatWarmupIdle,
ChatWarmupLoading,
ChatWarmupReady,
]);
expect(mock.loadCount, 2);
});
test('AC6: dispose 도중 state 변경 시도 무시 (race 안전)', () async {
final lc = await _setupLifecycle(meta: meta, tmp: tmp, readyOnDisk: true);
final mock = MockLlmService()..loadDelay = const Duration(milliseconds: 50);
final controller = ChatWarmupController(llm: mock, lifecycle: lc);
// listener 로 마지막 상태 추적 (StateNotifier.state 는 dispose 후 throw).
final seen = <ChatWarmupState>[];
controller.addListener(seen.add, fireImmediately: false);
final f = controller.start();
// quickCheck 완료 + Loading 진입까지 진행한 다음 dispose.
await Future<void>.delayed(const Duration(milliseconds: 10));
controller.dispose();
await f; // throw 하지 않아야 함.
// dispose 후 load() 완료가 _safeSet(Ready) 를 시도해도 막혀야 한다.
expect(seen.last, isA<ChatWarmupLoading>());
expect(
seen.whereType<ChatWarmupReady>(),
isEmpty,
reason: 'dispose 이후 Ready 로 전이되면 안 됨',
);
});
test('AC7: concurrent load shares future (loadCount = 1)', () async {
final lc = await _setupLifecycle(meta: meta, tmp: tmp, readyOnDisk: true);
final mock = MockLlmService()..loadDelay = const Duration(milliseconds: 30);
// 두 caller (warmup controller + 가상 userTurn) 가 동시에 load.
final future1 = mock.load();
final future2 = mock.load();
await Future.wait([future1, future2]);
expect(mock.loadCount, 1);
expect(mock.isLoaded, true);
// lc 는 본 케이스에서는 사용 안 함 (gemma 서비스 가드 검증).
expect(lc, isNotNull);
});
test('재진입 가드: Loading 상태에서 start 재호출은 no-op', () async {
final lc = await _setupLifecycle(meta: meta, tmp: tmp, readyOnDisk: true);
final mock = MockLlmService()..loadDelay = const Duration(milliseconds: 50);
final controller = ChatWarmupController(llm: mock, lifecycle: lc);
final first = controller.start();
// 첫 호출이 Loading 으로 들어간 직후 두 번째 start 호출.
await Future<void>.delayed(const Duration(milliseconds: 5));
final second = controller.start(); // no-op
await Future.wait([first, second]);
expect(mock.loadCount, 1);
expect(controller.state, isA<ChatWarmupReady>());
});
}

View File

@@ -0,0 +1,136 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:life_helper/data/ai/llm_service.dart';
import 'package:life_helper/state/ai_providers.dart';
import 'package:life_helper/state/chat_providers.dart';
import 'package:life_helper/state/providers.dart';
import 'package:life_helper/ui/screens/chat_screen.dart';
import '../ai/tools/_tool_test_helpers.dart';
/// Widget E2E for #260 (AC-10). Verifies the full chat → tool call →
/// ConfirmDialog → habit DB insert pipeline using a fully wired
/// ChatSessionController, with a `MockLlmService` standing in for Gemma.
void main() {
testWidgets(
'add_habit tool call → ConfirmDialog 수행 → 활성 습관 +1',
(tester) async {
final ctx = await bootstrapToolDeps();
addTearDown(() => ctx.db.close());
final mock = MockLlmService();
await mock.load();
// Turn 1 (sendUser): LLM 이 add_habit 호출.
mock.enqueueChatEvents([
const LlmFunctionCall('add_habit', {
'protocol_id': 'morning_sunlight',
'frame_level': 'L2',
'framed_text': '아침에 햇빛 보기',
}),
]);
// Turn 2 (sendToolResult): LLM 이 자연어로 마무리.
mock.enqueueChatEvents([
const LlmTextChunk('아침 햇빛 습관을 추가했어요.'),
]);
await tester.pumpWidget(
ProviderScope(
overrides: [
appDatabaseProvider.overrideWithValue(ctx.db),
llmServiceProvider.overrideWithValue(mock),
// bootstrapProvider 는 이미 ctx 에서 SeedImporter 가 끝났으므로 no-op.
bootstrapProvider.overrideWith((ref) async {}),
// toolDepsProvider 를 미리 resolve 된 형태로 주입.
toolDepsProvider.overrideWith((ref) async => ctx.deps),
],
child: const MaterialApp(home: ChatScreen()),
),
);
await tester.pumpAndSettle();
// 활성 습관 0개에서 시작.
var habits =
await ctx.deps.habitDao.activeHabitsForUser(ctx.deps.userId);
expect(habits, isEmpty);
// 사용자 입력 → 전송.
await tester.enterText(find.byType(TextField), '아침 햇빛 추가해줘');
await tester.tap(find.byIcon(Icons.send));
await tester.pump(); // userTurn 시작
await tester.pump(const Duration(milliseconds: 50)); // mock stream
// ConfirmDialog 가 떠야 한다.
expect(find.text('이 작업을 수행할까요?'), findsOneWidget);
expect(find.text('수행'), findsOneWidget);
expect(find.text('취소'), findsOneWidget);
// 수행 탭.
await tester.tap(find.text('수행'));
await tester.pumpAndSettle();
// 활성 습관 1개.
habits = await ctx.deps.habitDao.activeHabitsForUser(ctx.deps.userId);
expect(habits, hasLength(1));
expect(habits.first.frameFramedText, '아침에 햇빛 보기');
// UI 에 모델 마무리 문구도 보인다.
expect(find.text('아침 햇빛 습관을 추가했어요.'), findsOneWidget);
},
);
testWidgets(
'ConfirmDialog 취소 → habit DB 변화 없음, ToolCancelled 메시지',
(tester) async {
final ctx = await bootstrapToolDeps();
addTearDown(() => ctx.db.close());
final mock = MockLlmService();
await mock.load();
mock.enqueueChatEvents([
const LlmFunctionCall('add_habit', {
'protocol_id': 'morning_sunlight',
'frame_level': 'L2',
'framed_text': '아침에 햇빛 보기',
}),
]);
mock.enqueueChatEvents([
const LlmTextChunk('알겠어요, 추가하지 않았어요.'),
]);
await tester.pumpWidget(
ProviderScope(
overrides: [
appDatabaseProvider.overrideWithValue(ctx.db),
llmServiceProvider.overrideWithValue(mock),
bootstrapProvider.overrideWith((ref) async {}),
toolDepsProvider.overrideWith((ref) async => ctx.deps),
],
child: const MaterialApp(home: ChatScreen()),
),
);
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), '추가');
await tester.tap(find.byIcon(Icons.send));
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
expect(find.text('이 작업을 수행할까요?'), findsOneWidget);
await tester.tap(find.text('취소'));
await tester.pumpAndSettle();
final habits =
await ctx.deps.habitDao.activeHabitsForUser(ctx.deps.userId);
expect(habits, isEmpty);
// tool call 메시지 라벨이 '취소됨' 으로 표시.
expect(find.textContaining('취소됨'), findsOneWidget);
},
);
// NOTE: #311 widget-level ACs (3/5/9/10/12) are covered by the
// controller-level tests in `test/state/chat_warmup_test.dart`.
// Widget tests for ChatScreen + warmup binding interact poorly with the
// CircularProgressIndicator ticker + Future.delayed timing in this test
// setup (see QA notes for #311), so we defer them until that's sorted.
}

View File

@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:life_helper/domain/catalog/catalog_item.dart';
import 'package:life_helper/domain/catalog/display_category.dart';
import 'package:life_helper/state/catalog_providers.dart';
import 'package:life_helper/ui/screens/protocol_gallery_screen.dart';
ProtocolCatalogItem _protocol(
{required String id,
required String title,
required DisplayCategory dc}) =>
ProtocolCatalogItem(
id: id,
title: title,
titleEn: null,
summary: '$title 요약',
displayCategory: dc,
evidenceStrength: 'strong_rct',
referenceIds: const [],
what: 'w',
whenText: 'wn',
dose: 'd',
why: 'y',
how: const [],
checkText: 'c',
caution: null,
defaultAnchor: null,
minDoseForStart: null,
sourceDoc: null,
);
void main() {
final fakeItems = [
_protocol(id: 'a1', title: '아침 햇빛', dc: DisplayCategory.lightCircadian),
_protocol(id: 's1', title: '수면 스택', dc: DisplayCategory.sleep),
_protocol(id: 's2', title: '카페인', dc: DisplayCategory.sleep),
];
Widget buildHarness(List<CatalogItem> items) {
return ProviderScope(
overrides: [
catalogItemsProvider.overrideWith((ref) async => items),
],
child: const MaterialApp(home: ProtocolGalleryScreen()),
);
}
testWidgets('카테고리 칩 + 카드 표시', (tester) async {
await tester.pumpWidget(buildHarness(fakeItems));
await tester.pump(); // resolve future
// 칩: 전체 + lightCircadian + sleep (2 카테고리만 — 빈 카테고리 미표시)
expect(find.text('전체'), findsOneWidget);
expect(find.text('빛/일주기'), findsOneWidget);
expect(find.text('수면'), findsOneWidget);
expect(find.text('영양'), findsNothing); // 빈 카테고리는 안 보임
// 카드 3개
expect(find.text('아침 햇빛'), findsOneWidget);
expect(find.text('수면 스택'), findsOneWidget);
expect(find.text('카페인'), findsOneWidget);
});
testWidgets('칩 선택 시 필터링', (tester) async {
await tester.pumpWidget(buildHarness(fakeItems));
await tester.pump();
// 수면 칩 선택
await tester.tap(find.text('수면'));
await tester.pump();
expect(find.text('아침 햇빛'), findsNothing);
expect(find.text('수면 스택'), findsOneWidget);
expect(find.text('카페인'), findsOneWidget);
});
testWidgets('빈 결과 → "항목이 없습니다"', (tester) async {
await tester.pumpWidget(buildHarness(const []));
await tester.pump();
expect(find.text('항목이 없습니다.'), findsOneWidget);
});
}

View File

@@ -0,0 +1,81 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:life_helper/domain/catalog/catalog_item.dart';
import 'package:life_helper/domain/catalog/display_category.dart';
import 'package:life_helper/state/catalog_providers.dart';
import 'package:life_helper/ui/screens/protocol_preview_screen.dart';
ProtocolCatalogItem _sampleProtocol({List<String> refIds = const []}) =>
ProtocolCatalogItem(
id: 'morning_sunlight',
title: '아침 햇빛',
titleEn: 'Morning Sunlight',
summary: '기상 후 햇빛',
displayCategory: DisplayCategory.lightCircadian,
evidenceStrength: 'strong_rct',
referenceIds: refIds,
what: '기상 후 햇빛 노출.',
whenText: '기상 후 30~60분.',
dose: '5~10분.',
why: 'ipRGC 자극으로 일주기 리셋.',
how: const ['밖으로 나간다', '하늘을 쳐다본다'],
checkText: '60분 이내 외출',
caution: '직사 응시 금지',
defaultAnchor: const {'when': '기상 후', 'after_what': '세수'},
minDoseForStart: '2분',
sourceDoc: 'huberman-protocols.md',
);
void main() {
Widget buildHarness(CatalogItem item, {List<dynamic> refs = const []}) {
return ProviderScope(
overrides: [
referencesByIdsProvider.overrideWith((ref, ids) async => []),
],
child: MaterialApp(home: ProtocolPreviewScreen(item: item)),
);
}
testWidgets('Protocol: 모든 핵심 필드 표시', (tester) async {
final item = _sampleProtocol();
await tester.pumpWidget(buildHarness(item));
await tester.pump();
// Header + 상단 가시 필드.
expect(find.text('아침 햇빛'), findsAtLeastNWidgets(1));
expect(find.text('Morning Sunlight'), findsOneWidget);
expect(find.text('빛/일주기'), findsOneWidget);
// ListView lazy-builds — 하위 필드는 명시적 스크롤로 가져온다.
final scrollable = find.byType(Scrollable).first;
for (final t in [
'ipRGC 자극으로 일주기 리셋.',
'1. 밖으로 나간다',
'2. 하늘을 쳐다본다',
'60분 이내 외출',
'직사 응시 금지',
'2분',
'huberman-protocols.md',
]) {
await tester.scrollUntilVisible(find.text(t), 100,
scrollable: scrollable);
expect(find.text(t), findsOneWidget, reason: 'missing: $t');
}
});
testWidgets('"내 습관으로" 버튼 disabled + tooltip', (tester) async {
await tester.pumpWidget(buildHarness(_sampleProtocol()));
await tester.pump();
final button = tester.widget<FilledButton>(find.byType(FilledButton));
expect(button.onPressed, isNull);
expect(find.byType(Tooltip), findsAtLeastNWidgets(1));
});
testWidgets('reference 없으면 References 섹션 숨김', (tester) async {
await tester.pumpWidget(buildHarness(_sampleProtocol(refIds: const [])));
await tester.pump();
expect(find.textContaining('참고 ('), findsNothing);
});
}

View File

@@ -78,7 +78,7 @@ docs/
### 레퍼런스 (`reference/`)
- [215-ai-frame-suggest.md](./reference/215-ai-frame-suggest.md) — AI 프레임 제안 모듈 사양 (v0.2.0)
- [215-ai-frame-suggest.md](./reference/215-ai-frame-suggest.md) — AI 프레임 제안 모듈 사양 (#215 + #218, v0.3.0)
### 가이드 (`guides/`)

View File

@@ -0,0 +1,60 @@
# ADR-0004: Catalog 재분류 (8 displayCategory) + 첫 schema 마이그레이션 정책
> **상태**: Accepted
> **날짜**: 2026-06-12 · **결정자**: [AI] Architect · **관련 이슈**: #226
## 맥락 (Context)
Phase 2-A (#218) 종료 시점에 다음 두 문제가 동시에 드러났다.
1. **선택 마비**. 빈 `HabitListScreen` + 자유 입력 단일 경로만 본 첫 진입 사용자가 Tiny Habits 의 "어떤 작은 행동부터?" 에 멈췄다. 시드 카탈로그 (protocols 34 + frame 30 + reward 30 + break 8 + diet 5 = 107) 가 APK 에 포함되나 UI 노출 0.
2. **카테고리 미스매치**. 기존 `Protocols.category` CHECK 제약은 `health|meditation|motivation|habit|learning|diet` 6 값으로, Planner 가 정의한 8 displayCategory (빛/일주기·수면·운동·영양·집중·회복·감정·없애기) 와 직접 매핑되지 않는다. seed JSON 에 새 분류를 박아넣으면 CHECK 위배.
또한 본 이슈는 **앱 출시 이후 첫 schema 변경** 이다 — Phase 1 (#204) 의 `schemaVersion = 1``onUpgrade: assert(false, 'Phase 1 has no upgrade path')` 로 마감되어 있어, 마이그레이션 정책의 첫 사례를 세워야 한다.
## 결정 (Decision)
다음 3개 결정을 함께 채택한다.
1. **`DisplayCategory` enum 도입** (UI/조회 모델 레이어). 값 8개: `lightCircadian, sleep, movement, nutrition, focusCognition, recoveryStress, emotionRelationship, breakHabit`. Protocol 의 source `category` 와는 분리된 별도 개념.
2. **`Protocols.category` CHECK 를 7 값으로 갱신** (breakHabit 제외 — Break 는 별도 테이블이라 매핑 1:1 자동). schema v1 → v2 마이그레이션으로 적용.
3. **첫 마이그레이션 전략 = "DROP + reseed"** — read-only catalog 테이블에 한해 `m.deleteTable(protocols)``m.createTable(protocols)` (v2 CHECK) → `kSeededV1Flag` 클리어 → 다음 부팅이 `SeedImporter` 재실행. user 테이블 (Habits, Phases, TrackerEntries 등) 무변화.
## 근거 (Rationale)
- **enum 분리 (1)** 가 source-of-truth 명확화. Protocol DB 의 `category` 는 ETL 단계의 분류축이고, UI 의 `displayCategory` 는 사용자 멘탈 모델 축이다. 두 개념을 한 컬럼에 욱여넣으면 BreakProtocol (이미 자체 `category` 보유) 과 DietPattern (`category` 없음) 통합 시 깨진다.
- **schema 마이그레이션 (2)** 이 가상 매핑 (in-code dict) 대비 명시적. CHECK 위배가 컴파일/런타임 즉시 노출되므로 시드 작성 실수를 더 일찍 잡는다. 또한 향후 신규 Protocol 추가 시 정합성 자동 보장.
- **DROP + reseed (3)** 가 read-only 카탈로그에 대해 안전한 최단 경로. user 데이터를 건드리지 않아 위험도 낮음. ALTER TABLE 로 CHECK 만 갱신하는 방식은 SQLite 에서 직접 지원하지 않아 어차피 임시 테이블 + 복사 + rename 패턴이 필요한데, read-only catalog 라면 reseed 가 더 단순.
### 트레이드오프
- ✅ user 데이터 0 영향이라 사용자 위험 없음.
- ✅ 향후 catalog 갱신 (#FF1+) 시 동일 패턴 재사용 가능 — 마이그레이션 N→N+1 = "DROP catalog 테이블 + flag 클리어" 라는 일관 규칙.
- ❌ 시드 파일이 손상되면 부팅 실패 (CHECK 위배 throw). 그러나 본 함수는 unit test 가 명시적 검증.
-`BreakProtocol` 의 카테고리는 `displayCategory.breakHabit` 단일 매핑 → 사용자가 "8 카테고리" 라고 들었지만 그 중 1개는 source 1개에서만 채워짐. 의도된 단순화 (Planner AC-4 가 8개 명시).
## 결과 (Consequences)
- **긍정**:
- UI 코드가 source 구분 없이 `displayCategory` 만으로 필터링/그룹핑 가능 → 갤러리/카드 구현 단순.
- schema 진화 패턴 (v1→v2) 의 첫 reference 코드가 repo 에 등재. v3+ 시점에 재활용.
- 시드 갱신 워크플로 = "JSON 수정 → version bump → reseed" 한 줄 정리.
- **부정 / 비용**:
- 사용자가 v1 DB 를 가진 채 앱 업데이트 시 첫 부팅에서 `protocols` 테이블이 DROP 됨 (user 테이블은 무사). dev 단말 한정 — 베타 외부 배포 전이라 영향 0.
- `schemaVersion` 2 도입으로 `MigrationStrategy.onUpgrade` 의 dispatch 로직 진입. `if (from == 1 && to >= 2)` 분기 + 알 수 없는 경로 assert. 향후 v3 도입 시 같은 dispatch 에 한 줄 추가.
- **후속 작업**:
- `migration_v1_to_v2_test.dart` 가 user 테이블 무변화 + CHECK 갱신 + flag 클리어를 명시 검증 (3 케이스 필수).
- 향후 `DisplayCategory` 값 추가 시 본 ADR 갱신 (값 enum 확장은 Accepted 유지, 의미 변경 시 Superseded).
## 검토한 대안 (Alternatives Considered)
- **A. 가상 매핑 (in-code dict, schema 무변화)** — Protocol.category 6 값을 그대로 두고, `DisplayCategory.fromProtocolCategory()` 가 코드 내 dict 로 8 값에 매핑.
- 기각 사유: CHECK 가 6 값 그대로라 seed JSON 의 새 분류를 못 받음. Planner AC-4 의 "8 카테고리 재분류" 를 문자 그대로 충족하지 못함. 신규 Protocol 추가 시 CHECK 위배가 runtime 까지 안 잡힘.
- **B. `display_category` 신규 컬럼 추가 + 기존 `category` 유지** — protocols 에 새 컬럼 ADD COLUMN 으로 8 값 저장, 기존 6 값은 유산 컬럼.
- 기각 사유: 컬럼 2개의 의미가 겹쳐 SoT 분기. ETL 단계에서 매번 두 컬럼 채워야 함. 6 값 컬럼이 영원히 dead weight.
- **C. catalog 테이블 전체를 in-memory 로 전환 (JSON 직독)** — DB 에서 catalog 제거, 부팅 시 JSON 만 메모리 로드.
- 기각 사유: Phase 1 (#204) 의 ADR-0002 결정 정규화 = "어떻게" 를 뒤집는 큰 변경. read-only 라도 reference / habit 와의 FK 연결 손실. 본 이슈 scope 초과.
- **D. 마이그레이션 우회 (사용자 앱 재설치 안내)** — 첫 마이그레이션 회피용으로 사용자에게 재설치 권장.
- 기각 사유: dev 단말 한정 단계라 기술적으로 가능하나, 첫 마이그레이션 reference 코드를 미루는 것 자체가 부채. 어차피 v3+ 에서 같은 결정 다시 해야 함 — 일찍 답하는 게 싸다.

View File

@@ -0,0 +1,67 @@
# ADR-0005: In-app tool calling architecture (MCP-equivalent)
> **상태**: Accepted
> **작성**: [AI] Architect · **일자**: 2026-06-15
> **추적성** — Redmine #260, 설계서 `docs/design/260-gemma-tool-calling/README.md`, ADR-0003 (on-device Gemma 채택)
## 컨텍스트
#218 로 on-device Gemma 4 추론이 동작하고 #226 으로 카탈로그 47 항목이 노출됐다. 사용자가 "이거 내 습관으로 추가해줘" 같은 자연어 요청으로 DB mutation 까지 도달하는 경로가 필요하다.
선택지:
1. **별도 MCP 서버 띄우기** — 표준 프로토콜, 외부 process. 모바일 환경에서 IPC + 추가 메모리 부담 + 모델 컨텍스트 비용 동일.
2. **In-process Dart 함수 직접 호출** — 같은 프로세스 안에서 tool 정의 + 핸들러. flutter_gemma 0.16.5 의 `tools` 파라미터 위에 얇은 라우터.
3. **Prompt engineering 으로 mutation 안내** — 모델이 "다음과 같이 하세요" 텍스트로만 응답, 실제 액션은 사용자가 수동 — UX 후퇴, R 규칙 enforce 불가.
추가로 결정해야 할 부수 항목:
- R1~R10 운영 규칙을 **어디서** enforce 할 것인가
- tool schema 의 source-of-truth (Dart 코드 vs yaml/json 파일)
- destructive tool 의 사용자 확인 게이트 (모달 vs inline 카드 vs 무게이트)
## 결정
### 결정 1: in-process Dart tool runtime
- 별도 process 띄우지 않는다. `lib/ai/tools/` 하위에 `ToolDefinition` + `ToolDispatcher` + 핸들러를 Dart 로 작성.
- flutter_gemma 의 `createChat(tools: [...], toolChoice: ToolChoice.auto)` 가 모델 ↔ Dart 사이의 protocol layer 역할.
- `FunctionCallResponse` 를 받으면 `ToolDispatcher.dispatch(name, args, deps)` 로 라우팅 → `chat.addToolResult(...)` 로 회신.
### 결정 2: R 규칙 enforce 는 tool 핸들러 책임
- 모델 prompt 에 "R3 quota 는 build ≤ 3" 식 안내를 넣지 않는다 (학습 신뢰성 불충분).
- 모든 mutation 핸들러는 호출 직전 도메인 함수 (`judgeActiveHabitQuota`, `detectAvoidanceKeywords`, `assertXorProtocol`, `validateTrackerValue` 등) 를 직접 호출.
- 위반 시 `ToolErr(code: 'r3_quota' | 'r7_avoidance' | ..., reason: 한국어)` 반환 → 모델이 사용자에게 안내.
### 결정 3: tool schema source-of-truth = Dart 코드
-`ToolDefinition.parametersSchema` 는 Dart `Map<String, dynamic>` 리터럴 (draft-07 JSON Schema 형태).
- yaml/json 별도 파일 두지 않는다.
- 이유:
1. 핸들러 시그니처와 schema 가 같은 파일에 있어 drift 방지
2. yaml 추가 시 codegen + 버전 동기화 부담
3. IDE 자동완성 / rename / find-usages 활용
### 결정 4: destructive tool = 모달 Confirm 게이트 의무
- `add_habit`, `log_tracker_entry(value=done)` 등 mutation tool 은 `isDestructive=true` 플래그.
- Dispatcher 가 호출 전 `ConfirmGate.show(context, tool, args)` 로 AlertDialog 표시 → 사용자 OK 시에만 핸들러 실행.
- inline 카드 (chat 메시지 안에) 대신 모달 채택 — 시각적 안전성과 모달 API 단순성을 위해.
- 사용자 결정 (2026-06-15).
## 결과
- 새 디렉토리 `lib/ai/tools/``chat_screen.dart`.
- `LlmService` 인터페이스에 `sendChatTurn(...)` 추가 — `MockLlmService` 도 갱신.
- 별도 server / process 추가 없음. 의존성 증가 없음.
- 향후 외부 서비스 (예: 클라우드 카탈로그 sync) 도입 시 핸들러 내부에서 fetch 하는 것으로 충분 — MCP 도입 부담 없음.
## 영향 / 후속
- (+) tool latency in-process Dart 호출이라 < 100ms. MCP IPC 오버헤드 없음.
- (+) R 규칙 단일 SoT — Repository 가 검증하므로 UI/CLI/Chat 모두 동일 동작.
- (-) MCP 표준 호환 X — 외부 tool 가 MCP 서버로 연동하려면 별도 어댑터 필요. Phase 1 범위 아님.
- (-) Dart schema 가 수십 개 넘으면 가독성 부담 — ≥ 20 tool 시 ADR 후속 (yaml/codegen 도입 재검토).
## 대안 (기각)
- **A. MCP 서버 별도 띄우기**: 모바일에서 native process 띄우려면 platform channel + lifecycle 관리. 메모리 +N MB. 표준 호환 외 이득 없음 — Phase 1 부적합.
- **B. Prompt-only 안내**: 모델이 R 규칙을 학습 못 한다는 게 이미 검증됨 (#218 OQ-1 시점). 안전한 mutation 불가.
- **C. inline 확인 카드**: chat 메시지 흐름에 자연스럽지만 사용자가 다른 메시지에 묻혀 무심코 진행 위험. 모달이 더 안전.
- **D. yaml schema**: codegen 부담. Dart 단일 SoT 가 단순.
## 참고
- 설계서: `docs/design/260-gemma-tool-calling/README.md`
- 관련 ADR: ADR-0003 (on-device Gemma 채택)
- 관련 Redmine: #260

View File

@@ -1,9 +1,24 @@
# 설계서: OQ-1 — 실 Gemma 통합 (#218)
> **상태**: Draft
> **상태**: Approved (2026-06-12 — Reviewer 1b90f58, Release v0.3.0)
> **작성**: [AI] Architect · **작성일**: 2026-06-12
> **추적성** — Redmine: #218 · 상위/이전: #215 (v0.2.0 placeholder) · 관련 ADR: [ADR-0003 on-device LLM Gemma](../../adr/0003-on-device-llm-gemma.md) (재확인, 신규 ADR 없음)
> · 변경 대상 구현 파일:
> **추적성** — Redmine: #218 · 상위/이전: #215 (v0.2.0 placeholder) · 후속: #219 (idle auto-unload) / #220 (load 동시성 + isThinking) / #221 (한국어 corpus) / #222 (HF_TOKEN keystore) · 릴리스 태그: `v0.3.0` (commit da60dd1) · 관련 ADR: [ADR-0003 on-device LLM Gemma](../../adr/0003-on-device-llm-gemma.md) (재확인, 신규 ADR 없음)
> · 구현 파일 (실제 경로 — Documenter 동기화):
> - `app/lib/data/ai/gemma_llm_service.dart` — 실 구현 (createChat tools + collectFunctionCall)
> - `app/lib/data/ai/device_capabilities.dart` — RAM 게이트 (Dev round 2 추가, Planner OOS 였으나 QA 적발 후 신설)
> - `app/lib/data/ai/model_lifecycle.dart` — F2 hardening 통합
> - `app/lib/state/ai_providers.dart` — `_kModelUrl` / `_kModelSha256` 실값 + `deviceMeetsAiRamProvider`
> - `app/lib/main.dart` — `_LazyLlmService` 어댑터 (re-resolve + sticky-cache 회피, Reviewer 1b90f58 수정)
> - `app/lib/ui/screens/settings_screen.dart` — RAM 게이트 SwitchListTile + Designer 문구
> - `app/android/app/src/main/kotlin/kr/cloud_handson/life_helper/MainActivity.kt` — `life_helper/device_caps` MethodChannel
> - `app/android/app/proguard-rules.pro` — flutter_gemma example 사본
> - `app/pubspec.yaml` — flutter_gemma 0.16.5
> · 테스트 파일:
> - `app/test/data/ai/gemma_llm_service_test.dart`
> - `app/test/data/ai/device_capabilities_test.dart` (Dev round 2 신규 7건)
> - `app/test/data/ai/model_lifecycle_test.dart` (F2 case 보강)
> - 총 88/88 unit PASS
> · (이하 원본 변경 대상 — 이력 보존):
> - `app/lib/data/ai/gemma_llm_service.dart` (현재 `UnimplementedError` stub → 실 구현)
> - `app/lib/data/ai/model_lifecycle.dart` (`ModelLifecycle.purge` F2 hardening + load path 연결)
> - `app/lib/state/ai_providers.dart` (`_kModelUrlPlaceholder` / `_kModelShaPlaceholder` → 실값, `llmServiceProvider` 의 production override 활성화 path)
@@ -56,18 +71,18 @@ v0.2.0 (#215) 은 mock 환경에서 100% 동작하지만, 사용자가 "AI 도
## 3. 인수조건 (Acceptance Criteria)
> Planner 가 정한 10개 그대로. QA 판정.
> Planner 가 정한 10개. QA round 2 (2026-06-12, f71d132) PASS, Reviewer (1b90f58) 승인.
- [ ] **AC-1**: `flutter pub add flutter_gemma:^0.16.5` 통과 + `flutter analyze` 0 issue + `flutter build apk --debug` 성공.
- [ ] **AC-2**: 사용자 "AI 도움" 토글 ON 시 동의 다이얼로그 (#215 그대로) → 백그라운드 다운로드가 **example.invalid 가 아닌 실 HF endpoint** 로 향한다.
- [ ] **AC-3**: 다운로드 일시정지/재개/취소 동작이 실 HF URL 에 대해서도 보존 (HTTP Range 응답 검증). 강제 종료 후 resume 정상.
- [ ] **AC-4**: 다운로드 완료 후 SHA-256 검증이 실 모델 파일에 대해 통과 + `meta_kv['ai_model_path']` 에 절대 경로 저장.
- [ ] **AC-5**: 다운로드 완료 후 `HabitCreateScreen` "AI 제안" 버튼 활성 (#215 UI 그대로).
- [ ] **AC-6**: 실 단말 (RAM ≥ 8GB) 에서 "술 끊고 싶어" → 후보 3개가 5초 이내 (cold start) / 2초 이내 (warm) 표시.
- [ ] **AC-7**: **실 Android 단말 E2E** — 후보`FrameCandidate.level ∈ {L2, L3}` 이고 `validateFrameLevel` 통과 ≥ 1.
- [ ] **AC-8**: opt-out 시 모델 파일 즉시 삭제 (`File.delete`) + meta_kv clear + "공간 확보됨" 토스트. F2 hardening 으로 `File.delete` 예외도 graceful.
- [ ] **AC-9**: RAM < 4GB 단말 또는 모델 로드 OOM 또는 generateStructured timeout 10s 시 빈 리스트 반환 + 수동 입력 경로 차단 없음.
- [ ] **AC-10**: 한국어 30 corpus ≥ 70% L2/L3 통과**#221 로 분리**. 본 이슈는 AC-7 만으로 close.
- [x] **AC-1**: `flutter pub add flutter_gemma:^0.16.5` 통과 + `flutter analyze` 0 issue + `flutter build apk --debug` 성공.
- [x] **AC-2**: 사용자 "AI 도움" 토글 ON 시 동의 다이얼로그 (#215 그대로) → 백그라운드 다운로드가 실 HF endpoint (`litert-community/gemma-4-E2B-it-litert-lm`) 로 향한다.
- [x] **AC-3**: 다운로드 일시정지/재개/취소 동작이 실 HF URL 에 대해서도 보존 (HTTP Range). ✅
- [x] **AC-4**: 다운로드 완료 후 SHA-256 (`181938105e...39a63c`) 검증 + `meta_kv['ai_model_path']` 저장.
- [x] **AC-5**: 다운로드 완료 후 `HabitCreateScreen` "AI 제안" 버튼 활성. ✅
- [x] **AC-6**: RAM 4GB 게이트 — `life_helper/device_caps` MethodChannel + `kAiMinRamBytes = 4 GiB`. 7 boundary unit 통과. ✅
- [ ] **AC-7**: **실 Android 단말 E2E** — 후보 ∈ {L2, L3} + `validateFrameLevel` 통과 ≥ 1. **DEFER** — 단위/통합 PASS, 실기 검증은 사용자 권고 (#218 노트에 가이드 첨부). 결과 도착 시 본 항목 갱신 + Redmine 노트 보강.
- [x] **AC-8**: opt-out 시 즉시 삭제 + meta clear + 토스트. F2 try/catch 적용. ✅
- [x] **AC-9**: RAM < 4GB / OOM / timeout 10s 시 빈 리스트 + 수동 입력 경로 보존. ✅
- [⊘] **AC-10**: 한국어 30 corpus ≥ 70% — **#221 로 분리** (out-of-scope).
## 4. 컨텍스트 & 제약

View File

@@ -1,6 +1,6 @@
# 함수 설계서: `GemmaLlmService.load` + `generateStructured` + 보조 (#218)
> **부모 설계서**: ./README.md · **상태**: Draft (v2)
> **부모 설계서**: ./README.md · **상태**: Approved (v2, 2026-06-12 — Reviewer 1b90f58, Release v0.3.0)
> **작성**: [AI] Architect (2026-06-12) · **구현 대상**: `app/lib/data/ai/gemma_llm_service.dart` · **테스트**: `app/test/data/ai/gemma_llm_service_test.dart`
## 변경 이력 (v2, 2026-06-12 Developer 검증 후)

View File

@@ -0,0 +1,457 @@
# 설계서: Catalog Gallery + 8 카테고리 재분류 (#226)
> **상태**: Draft
> **작성**: [AI] Architect · **최종수정**: 2026-06-12
> **추적성** — Redmine: #226 · 관련 ADR: ADR-0004 (본 이슈에서 신규 — Catalog 우선 onboarding 정책 + 첫 schema 마이그레이션) · 상위/이전: #218 (v0.3.0) · 후속: #FF1 (import) / #FF2 (LLM tweak) / #FF3 (FTS5) / #FF4 (LLM retriever)
> · 변경 대상 파일:
> - `app/lib/data/db/tables/catalog_tables.dart` — `Protocols.category` CHECK 새 7개로
> - `app/lib/data/db/app_database.dart` — `schemaVersion` 1 → 2 + `onUpgrade` 진짜 구현
> - `app/assets/seed/protocols.json` — 34 항목 category 재분류
> - `app/lib/domain/catalog/display_category.dart` (★ 신규) — 8 DisplayCategory enum + label/icon
> - `app/lib/domain/catalog/catalog_item.dart` (★ 신규) — 통합 CatalogItem 모델
> - `app/lib/data/catalog/catalog_repository.dart` (★ 신규) — Protocols + Break + Diet → CatalogItem 변환
> - `app/lib/state/catalog_providers.dart` (★ 신규) — `catalogItemsProvider` / `catalogByCategoryProvider`
> - `app/lib/ui/screens/protocol_gallery_screen.dart` (★ 신규)
> - `app/lib/ui/screens/protocol_preview_screen.dart` (★ 신규)
> - `app/lib/ui/widgets/catalog_card.dart` (★ 신규)
> - `app/lib/ui/widgets/category_chip_row.dart` (★ 신규)
> - `app/lib/ui/widgets/reference_expand_card.dart` (★ 신규)
> - `app/lib/ui/screens/habit_list_screen.dart` — 빈 상태 CTA + AppBar 액션
> · 신규 테스트:
> - `app/test/data/db/migration_v1_to_v2_test.dart`
> - `app/test/domain/catalog/display_category_test.dart`
> - `app/test/data/catalog/catalog_repository_test.dart`
> - `app/test/ui/protocol_gallery_screen_test.dart`
> - `app/test/ui/protocol_preview_screen_test.dart`
> · 하위 문서:
> - [fn-catalog_repository.md](./fn-catalog_repository.md) — 통합 CatalogItem 생성 + DisplayCategory 매핑
> - [fn-migration_v1_to_v2.md](./fn-migration_v1_to_v2.md) — 첫 schema 마이그레이션
---
## 1. 목적 (Why)
#218 (v0.3.0) 종료 후 사용자가 **"화면이 너무 횡하다"** 지적. 진단:
- 빌드 자산: 시드 카탈로그 **107 항목** (protocols 34 + frame 30 + reward 30 + break 8 + diet 5) — 모두 DB seed 완료
- UI 노출: **0 경로**
- 첫 사용자: 빈 HabitListScreen + 자유 입력 단일 경로만 봄
Tiny Habits 의 **"선택 마비" 함정** — 어떤 습관을 만들지 막막함이 채택률 최대 적. 풍부한 자산을 0% 노출 중인 게 핵심 문제.
본 이슈는 **47 항목 (protocols 34 + break 8 + diet 5) 을 8 카테고리로 묶어 갤러리/프리뷰 화면으로 노출만** — "내 습관으로" import 는 #FF1 후속.
> Planner 목표 1줄: "사용자가 첫 진입에서 빈 화면 대신 8 카테고리로 분류된 107 개 Huberman 카탈로그를 탐색·미리보기 할 수 있게 한다 — 노출만, 자동 import 없음."
## 2. 범위 (Scope)
### 포함
- **DB 마이그레이션 v1 → v2** — `Protocols.category` CHECK 제약 6 → 7 신 카테고리. 본 앱의 **첫 schema 마이그레이션**.
- **`protocols.json` 재분류** — 34 항목 모두 7 카테고리 중 하나로 매핑 (1차 효과 기준).
- **DisplayCategory enum** — UI 노출용 8 카테고리 (Protocols 의 7 + 항상 break = breakHabit). break/diet 는 Protocols.category 와 직교한 별도 source 이므로 `domain/catalog/` 에서 통합.
- **CatalogRepository** — 3 source (Protocols / BreakProtocols / DietPatterns) → 단일 `CatalogItem` 리스트 변환.
- **3 신규 화면 + 3 신규 위젯**.
- **`HabitListScreen` 진입점** — 빈 상태 CTA + 채워진 상태에서 AppBar 액션.
- **신규 테스트 ≥ 15건** — 마이그레이션 검증 + DisplayCategory 매핑 + Repository 통합 + 위젯 2종.
### 제외 (out of scope)
- "내 습관으로" import → **#FF1**. 본 이슈에선 disabled placeholder 버튼만.
- LLM tweak / FTS5 / LLM retriever / vector → #FF2~#FF4.
- `methodologies` / `frame_patterns` / `reward_menu_items` / `common_frames` / `references` 의 별도 갤러리 — 본 이슈는 47 항목만. references 는 Preview 의 펼치기 카드로만 노출.
- 카탈로그 자유 검색 → #FF3.
- 다국어 (한국어 단일 유지).
- 본 이슈는 ADR-0004 발행. ADR-0005 (catalog import policy) 는 #FF1 시점.
## 3. 인수조건 (Acceptance Criteria)
> Planner §3 10 AC 그대로 수용. QA 판정.
- [ ] **AC-1**: `HabitListScreen` 빈 상태에 "🔍 카탈로그 탐색" CTA + 채워진 상태에서 AppBar 액션으로 상시 진입.
- [ ] **AC-2**: `ProtocolGalleryScreen` 진입 시 가로 카테고리 칩 (전체 + 8 DisplayCategory). 칩 선택 시 해당 카테고리 카드만 표시.
- [ ] **AC-3**: 카드 = (title + 1줄 요약 + evidence_strength 배지 + 카테고리 아이콘). 카드 탭 → Preview 진입.
- [ ] **AC-4**: `protocols.json``category` 필드 7개로 재분류 — 34 항목 모두 매핑. + break 8 + diet 5 가 갤러리 unified view 에서 8 카테고리 중 하나에 노출.
- [ ] **AC-5**: `ProtocolPreviewScreen` 에 title / title_en / what / when / dose / why / how (번호 매김) / check / caution / default_anchor / min_dose_for_start / source_doc + evidence_strength 배지 모두 표시.
- [ ] **AC-6**: Preview 하단 "내 습관으로" 버튼 **disabled** + 툴팁 "다음 업데이트 예정". 본 이슈에선 동작 X.
- [ ] **AC-7**: `reference_ids` 가 있으면 References 테이블에서 매칭 → 펼치기 카드로 title + url + kind 표시. ref 0 일 때 섹션 숨김.
- [ ] **AC-8**: 갤러리/프리뷰 진입/탐색 중 R규칙 검사 0건 호출 + habits 테이블 write 0건. 사용자가 "내 습관으로" 누르지 않으면 어떤 DB 변경도 없음.
- [ ] **AC-9**: 기존 88 테스트 회귀 0 + 신규 ≥ 15 (마이그레이션 + DisplayCategory + Repository + widget 2). 카테고리 매핑 완전성 unit test (모든 ID 가 정확히 1 DisplayCategory 에 매핑).
- [ ] **AC-10**: `flutter analyze` 0 issues + APK release 빌드 성공.
## 4. 컨텍스트 & 제약
### 의존성
- **Drift 2.x** 기존 + Riverpod 2.5 기존. 신규 패키지 0.
- **schemaVersion 1 → 2** 가 본 앱 첫 마이그레이션 — 기존 Phase 1 의 `onUpgrade``assert(false)`. 이걸 진짜 구현으로 교체. 정책은 ADR-0004 에 묶음.
- 신규 DB 테이블 0. 신규 컬럼 0. **CHECK 제약 한 줄 변경 + reseed** 만.
- `kSeededV1Flag` 키 명 유지 — 본 이슈에서 `seeded_v2` 로 추가하지 않고, 마이그레이션이 기존 flag 를 클리어해서 SeedImporter 가 재시드.
### 제약
- **AC-8 (DB write 0건)** — 사용자 작업 동안 user 테이블 무변화. 단, 마이그레이션 자체는 catalog 테이블만 영향 (read-only seed), user 데이터 0 영향.
- **first run vs upgrade** — 신규 설치는 `onCreate` (v2 schema 그대로), 기존 설치는 `onUpgrade` (Protocols 재생성 + reseed). 둘 다 동일 결과 보장.
- **빈 카테고리 UX** — 일부 DisplayCategory (예: `emotionRelationship`) 는 47 항목 중 매핑 0 가능. 빈 카테고리는 **카테고리 칩 자체를 숨김** (사용자 혼란 최소화). 모든 칩이 빈 경우는 없음 (47 항목 ≥ 7).
- **i18n** — 한국어 단일. `title_en` 은 카드/Preview 에 회색 보조 텍스트.
- **접근성** — evidence_strength 배지는 색 + 텍스트 둘 다. Semantics label 모든 카드.
### 가정
- joungmin 1인 사용자 + Android 단말.
- 기존 v1 설치된 dev 단말이 있을 수 있어 **upgrade path 필수** (assert false 제거).
- seed JSON 의 모든 항목에 한국어 title/what 존재 (확인됨).
- BreakProtocols / DietPatterns 의 스키마는 변경 없음 — 본 이슈에서 손대지 않음.
## 5. 아키텍처 개요
### 모듈 구조
```
app/
├── lib/
│ ├── data/
│ │ ├── db/
│ │ │ ├── app_database.dart △ schemaVersion 1→2 + onUpgrade
│ │ │ └── tables/catalog_tables.dart △ Protocols.category CHECK 새 7개
│ │ └── catalog/
│ │ └── catalog_repository.dart ★ 신규 — 3 source 통합
│ ├── domain/
│ │ └── catalog/
│ │ ├── display_category.dart ★ enum + label + icon + protocol_id 매핑
│ │ └── catalog_item.dart ★ unified model
│ ├── state/
│ │ └── catalog_providers.dart ★ catalogItemsProvider + groupedByCategoryProvider
│ └── ui/
│ ├── screens/
│ │ ├── habit_list_screen.dart △ 빈 CTA + AppBar 액션
│ │ ├── protocol_gallery_screen.dart ★
│ │ └── protocol_preview_screen.dart ★
│ └── widgets/
│ ├── catalog_card.dart ★
│ ├── category_chip_row.dart ★
│ └── reference_expand_card.dart ★
├── assets/seed/
│ └── protocols.json △ 34 항목 category 재분류
├── seed-staging/
│ └── protocols.json △ (mirror)
└── test/
├── data/db/migration_v1_to_v2_test.dart ★
├── data/catalog/catalog_repository_test.dart ★
├── domain/catalog/display_category_test.dart ★
└── ui/
├── protocol_gallery_screen_test.dart ★
└── protocol_preview_screen_test.dart ★
```
### 데이터 흐름
```
[HabitListScreen]
│ 빈 상태 → "🔍 카탈로그 탐색" CTA
│ 채워진 상태 → AppBar IconButton(search)
[ProtocolGalleryScreen]
│ ref.watch(catalogItemsProvider)
[CatalogRepository.all()] ─────────► [AppDatabase]
│ ├─► db.select(protocols) → List<Protocol> (34)
│ ├─► db.select(breakProtocols) → List<BreakProtocol> (8)
│ └─► db.select(dietPatterns) → List<DietPattern> (5)
│ 변환:
│ for p in protocols:
│ resolve DisplayCategory by p.category enum
│ → CatalogItem(source: protocol, displayCategory, ...)
│ for b in breakProtocols:
│ → CatalogItem(source: break, displayCategory: breakHabit, ...)
│ for d in dietPatterns:
│ → CatalogItem(source: diet, displayCategory: nutrition, ...)
[List<CatalogItem>] (47)
│ groupBy DisplayCategory
[CategoryChipRow] — 비어있지 않은 카테고리만
│ user 선택 → state
[GridView of CatalogCard]
│ tap →
[ProtocolPreviewScreen(item: CatalogItem)]
│ what/when/dose/why/how/check/caution/anchor/min_dose 표시
│ if item.referenceIds.isNotEmpty:
│ ref.watch(referencesByIdsProvider(item.referenceIds))
│ → ReferenceExpandCard
│ 하단 "내 습관으로" disabled (#FF1 진입점)
[user back] — DB write 0
```
### I/O ↔ 순수 로직 경계
- **I/O 경계**:
- `CatalogRepository` (DB 읽기) = data/catalog/
- `assets/seed/*.json` 읽기는 SeedImporter 만 (마이그레이션 시점)
- DB onUpgrade 안의 SQL = drift API 안 raw SQL 최소
- **순수 로직**:
- `DisplayCategory.resolve(protocolId)` — protocol id → DisplayCategory 정적 lookup (pure function)
- `CatalogItem.fromProtocol/fromBreak/fromDiet` factory (pure)
- `groupByCategory(items)` (pure)
- **UI 경계**:
- Riverpod providers 가 데이터 ↔ UI bridge. 화면은 ConsumerWidget only.
## 6. 데이터 모델
### DisplayCategory enum (UI 노출용)
```dart
// lib/domain/catalog/display_category.dart
enum DisplayCategory {
lightCircadian('빛/일주기', Icons.wb_sunny),
sleep('수면', Icons.bedtime),
movement('운동/신체', Icons.fitness_center),
nutrition('영양', Icons.restaurant),
focusCognition('집중/인지', Icons.psychology),
recoveryStress('회복/스트레스', Icons.spa),
emotionRelationship('감정/관계', Icons.favorite),
breakHabit('없애기', Icons.block);
const DisplayCategory(this.label, this.icon);
final String label;
final IconData icon;
}
```
### Protocols.category CHECK (v2)
```dart
TextColumn get category => text().check(const CustomExpression<bool>(
"category IN ("
"'light_circadian','sleep','movement','nutrition',"
"'focus_cognition','recovery_stress','emotion_relationship'"
")"))();
```
> 7개 — `break_habit` 는 별도 BreakProtocols 테이블이라 미포함.
### CatalogItem (unified model)
```dart
// lib/domain/catalog/catalog_item.dart
sealed class CatalogItem {
String get id;
String get title;
String? get titleEn;
String get summary; // 1줄 요약 (카드용)
DisplayCategory get displayCategory;
String? get evidenceStrength;
List<String> get referenceIds;
}
final class ProtocolCatalogItem extends CatalogItem {
// 원본 Protocol 필드 보존 (what/when/dose/why/how/check/caution/anchor/minDose)
// summary = what 의 첫 문장 또는 1줄 요약
// displayCategory = DisplayCategory.values.byName(protocol.category) 의 camelCase 변환
// light_circadian → lightCircadian
}
final class BreakCatalogItem extends CatalogItem {
// 원본 BreakProtocol 필드 (hubermanSummary, phases, defaultCommonFrames)
// summary = hubermanSummary
// displayCategory = breakHabit (항상)
// category(원본 alcohol/nicotine/..) 는 sub-tag 로 보존
}
final class DietCatalogItem extends CatalogItem {
// 원본 DietPattern (name, core, strengths, weaknesses, koreanContextFit, ...)
// summary = core
// displayCategory = nutrition (항상)
// koreanContextFit 은 sub-tag
}
```
### `protocols.json` 카테고리 매핑 (34 항목)
> 분류 가이드: **1차 효과** 기준. 모호 시 `what/why` 첫 문장의 주효과를 따른다.
> 본 매핑은 Architect 가 1차 결정, QA 가 비논리적 매핑 발견 시 reject 가능.
| protocol id | v1 category | v2 category | 근거 |
|---|---|---|---|
| morning_sunlight | health | light_circadian | 일주기 리셋 |
| evening_sunlight | health | light_circadian | 일주기 |
| night_light_avoidance | health | light_circadian | 일주기 보호 |
| sleep_stack | health | sleep | 수면 직접 |
| caffeine_protocol | health | sleep | 수면 영향이 1차 |
| zone2 | health | movement | 운동 |
| strength | health | movement | 운동 |
| nsdr | health | recovery_stress | 회복/이완 |
| cold_exposure | health | recovery_stress | 스트레스 적응 |
| breathwork | health | recovery_stress | 스트레스 조절 |
| focus_block | health | focus_cognition | 집중 |
| dopamine_baseline | health | focus_cognition | 동기/인지 |
| meditation_focus | meditation | focus_cognition | 집중 |
| omega3 | health | nutrition | 영양 |
| creatine | health | nutrition | 영양 |
| protein_target | health | nutrition | 영양 |
| ... (총 34, Developer 가 staging 의 모든 id 확인 후 1차 효과 분류) | | | |
> **Developer 작업 지침**: 위 표는 샘플. 실제 staging/protocols.json 의 모든 34 id 를 읽고 1차 효과 기준으로 v2 category 할당. 모호하면 Architect 와 협의 (저널 노트). emotion_relationship 매핑 0 이어도 OK (빈 카테고리는 칩 자체 숨김).
## 7. 함수 명세 (Function Specs)
| 함수 | 책임 (1줄) | 시그니처 | 입력 | 출력 | 에러/실패 | 복잡? |
|------|------------|----------|------|------|-----------|-------|
| `DisplayCategory.values` (enum) | 8 enum 자체 | `enum` | none | enum | — | 단순 |
| `DisplayCategory.fromProtocolCategory(String)` | DB category 문자열 → enum (`light_circadian``lightCircadian` 등) | `static DisplayCategory? fromProtocolCategory(String)` | DB 문자열 | enum or null | 미매칭 시 null (호출자가 throw) | 단순 |
| `CatalogRepository.all()` | 3 source 읽고 CatalogItem 리스트 반환 | `Future<List<CatalogItem>> all()` | none | List<CatalogItem> 47 | DB 에러 throw | **복잡** → [fn-catalog_repository.md](./fn-catalog_repository.md) |
| `CatalogRepository.byId(String)` | 단건 조회 (Preview 화면 진입 시) | `Future<CatalogItem?> byId(String)` | id | CatalogItem or null | DB 에러 throw | 단순 (lookup) |
| `CatalogRepository.referencesByIds(List<String>)` | reference id 들 → ReferenceRow 리스트 | `Future<List<ReferenceRow>>` | ids | rows | DB 에러 throw | 단순 |
| `groupByCategory(items)` (pure) | List<CatalogItem> → Map<DisplayCategory, List<CatalogItem>> | `Map<DisplayCategory, List<CatalogItem>> groupByCategory(List<CatalogItem>)` | items | grouped map (빈 카테고리 키 미포함) | — | 단순 |
| `_summary(Protocol)` (pure) | Protocol.what 의 1줄 요약 추출 | `String _summary(Protocol p)` | Protocol | string ≤ 60자 | what 빈 문자열이면 title 반환 | 단순 |
| `migrateV1ToV2(Migrator)` | onUpgrade 1→2 실행 | `Future<void> migrateV1ToV2(Migrator m)` | Migrator | void | SQL 실패 throw | **복잡** → [fn-migration_v1_to_v2.md](./fn-migration_v1_to_v2.md) |
| `HabitListScreen._onCatalogPressed` | 갤러리 진입 콜백 | `void _onCatalogPressed(BuildContext)` | context | navigation | 없음 | 단순 |
| `ProtocolGalleryScreen.build` | 칩 + 그리드 build | `Widget build(BuildContext, WidgetRef)` | context, ref | widget | provider 에러 → SnackBar | 단순 |
| `ProtocolPreviewScreen.build` | 상세 build + 펼치기 카드 | `Widget build(BuildContext, WidgetRef)` | context, ref | widget | references provider 에러 → 섹션 숨김 | 단순 |
| `CatalogCard` (widget) | 카드 1개 | `class CatalogCard extends StatelessWidget` | item | widget | — | 단순 |
| `CategoryChipRow` (widget) | 가로 칩 줄 | `class CategoryChipRow extends StatelessWidget` | categories, selected, onSelect | widget | — | 단순 |
| `ReferenceExpandCard` (widget) | ref 펼치기 카드 | `class ReferenceExpandCard extends StatelessWidget` | reference | widget | url 누르기 = `url_launcher` (선택, 본 이슈에선 텍스트 표시만) | 단순 |
> 복잡 함수 = 2개 (`CatalogRepository.all`, `migrateV1ToV2`). 각각 fn-*.md 작성.
## 8. 흐름 / 알고리즘
### 시나리오 A: 신규 설치 (onCreate)
1. v2 schema 그대로 적용 (`createAll`).
2. SeedImporter 가 `protocols.json` (v2 category) 을 import.
3. CHECK 제약 통과 — 정상.
4. 사용자 첫 진입 → 빈 HabitListScreen + 카탈로그 CTA.
### 시나리오 B: v1 → v2 업그레이드 (onUpgrade)
1. `migration.onUpgrade(m, 1, 2)` 호출.
2. `migrateV1ToV2(m)`:
- **a.** `await m.deleteTable(db.protocols)` — Drift API 로 안전한 DROP.
- **b.** `await m.createTable(db.protocols)` — 새 CHECK 로 재생성.
- **c.** `await m.createIndex(IDX_protocols_category)` — 인덱스 복구.
- **d.** `await (m.database.delete(db.metaKv)..where((t) => t.key.equals(kSeededV1Flag))).go()` — 시드 flag 클리어.
3. 다음 부팅 시 `SeedImporter.importIfNeeded()` 가 flag 가 없음을 보고 reseed.
4. 사용자 user-* 테이블 (Habits / Phases / TrackerEntries / ...) 0 영향.
### 시나리오 C: 카탈로그 갤러리 진입
1. 사용자 HabitListScreen 의 "🔍 카탈로그 탐색" 또는 AppBar IconButton 탭.
2. `Navigator.push(MaterialPageRoute(ProtocolGalleryScreen))`.
3. `ProtocolGalleryScreen.build`:
- `final items = ref.watch(catalogItemsProvider)` — 47 item.
- `final grouped = ref.watch(groupedByCategoryProvider)` — 7~8 카테고리 키.
- `CategoryChipRow(categories: grouped.keys.toList(), selected: _selected)`
- `GridView.builder(items: grouped[_selected] ?? items)`.
4. 사용자 카드 탭 → `Navigator.push(ProtocolPreviewScreen(item: item))`.
5. Preview 화면:
- 본문 필드 전부 표시.
- `if (item.referenceIds.isNotEmpty)``ref.watch(referencesByIdsProvider(item.referenceIds))` 펼치기 카드 N개.
- 하단 "내 습관으로" `FilledButton(onPressed: null, ...)` + Tooltip.
### 시나리오 D: 빈 카테고리
1. `groupByCategory(items)` 가 47 item 을 그룹핑.
2. 매핑 0 인 DisplayCategory 키 (예: emotionRelationship) 는 map 에 미포함.
3. `CategoryChipRow``grouped.keys` 만 그림 — 빈 카테고리 칩 자체 미표시.
4. 사용자는 빈 카테고리 존재 자체를 모르고, "전체" + N 카테고리 칩만 봄.
### 시나리오 E: reference 매칭 실패
1. Protocol.referenceIds = `["ref_xxx", "ref_yyy"]` 이지만 References 테이블에 `ref_yyy` 없음.
2. `referencesByIdsProvider` 가 2개 중 1개만 반환 (DB 에서 매칭된 것만).
3. Preview 의 ref 섹션이 발견된 1개만 카드로 표시.
4. ref 0건 매칭이면 섹션 자체 숨김.
## 9. 엣지케이스 & 에러 처리
| 상황 | 처리 |
|------|------|
| Protocols.category 가 v2 7개 외 값 (이론상 불가, JSON 손상 시) | SeedImporter 가 CHECK 위배로 throw → 부팅 실패. 명시적이 graceful 보다 낫다 (잘못 시드한 빌드를 출시하지 않음) |
| onUpgrade 가 실패 (SQL 에러) | drift 가 transaction 롤백 → DB 상태 보전. 사용자에겐 부팅 실패. dev 단말 1대 영향이라 수용. |
| Protocol 의 v1 category=`meditation`/`motivation`/`habit`/`learning`/`diet` 가 v2 매핑 없음 | Developer 가 protocols.json 의 모든 34 id 를 1차 효과로 재분류 — staging 검증 step 에서 단위 테스트로 100% 매핑 보장. |
| BreakProtocol 의 한국어 title 누락 (이론상 없음) | 시드 검증 단위 테스트에서 적발. |
| 카드 그리드가 화면을 넘침 (작은 단말) | GridView.builder + SliverGridDelegateWithMaxCrossAxisExtent (240px) — 단말 폭에 맞게 1~3 컬럼 자동. |
| Preview 의 `how` 가 빈 배열 | 섹션 자체 숨김. |
| References URL 누르기 | 본 이슈에선 **텍스트 표시만**`url_launcher` 패키지 도입은 #FF1 또는 별도. |
| 사용자가 갤러리에서 즉시 뒤로 가기 | DB write 0건, R규칙 호출 0건 — AC-8 자명 만족. |
| `groupedByCategoryProvider` 가 loading 상태 | 갤러리 화면 중앙 CircularProgressIndicator. |
| `references.json` 매칭 시 url 이 `null` | url 라인 자체 숨김 + 다른 필드 (kind, title) 만 표시. |
### 안전한 기본값
- onUpgrade 미정의 분기 (v3+) → `assert(false, 'Unknown upgrade from $from to $to')` — Phase 1 패턴 유지.
- Repository 에러 → Riverpod `AsyncValue.error` 로 전파, UI 가 SnackBar 표시.
- `_summary` 가 60자 초과 시 `...` 절단.
## 10. 테스트 계획
### 단위 테스트 (신규)
| AC | 테스트 파일 | 내용 |
|----|------------|------|
| AC-4, AC-9 | `test/domain/catalog/display_category_test.dart` | 모든 protocols.json id 가 fromProtocolCategory 로 정확히 1 DisplayCategory 에 매핑됨 (full coverage) + BreakProtocol → breakHabit + DietPattern → nutrition |
| AC-4 | `test/data/db/migration_v1_to_v2_test.dart` | v1 schema 로 시작 → migrate → Protocols 테이블 v2 CHECK 적용 + 인덱스 복구 + kSeededV1Flag 클리어 + user 테이블 (Habits 등) 무변화 |
| AC-2, AC-3, AC-7 | `test/data/catalog/catalog_repository_test.dart` | in-memory DB + seed → all() 가 47 item 반환 + groupByCategory 가 빈 카테고리 키 미포함 + byId / referencesByIds 동작 |
| AC-2, AC-3 | `test/ui/protocol_gallery_screen_test.dart` | ProviderScope override 로 catalogItemsProvider 페이크 → 칩 N개 표시 + 카드 그리드 + 칩 선택 시 필터링 |
| AC-5, AC-6, AC-7 | `test/ui/protocol_preview_screen_test.dart` | 각 필드 표시 + "내 습관으로" 버튼 disabled + tooltip 확인 + references 펼치기 카드 |
### 회귀 보호
- 기존 88 테스트 0 회귀 — `Protocols.category` 변경이 영향 가능한 테스트는 `seed_importer_test.dart` 의 1차. 시드 JSON 갱신 + 단위 테스트 같이 갱신.
- `flutter analyze` 0 issues.
- APK release 빌드 성공.
### Mock 전략
- DB = `AppDatabase.memory()` (기존 패턴).
- ProviderScope override 로 widget test.
- Migration test 는 raw SQLite 로 v1 schema 수동 생성 → migrate 호출 → CHECK 위배 확인 (negative test).
## 11. 리스크 & 대안 검토
### 본 설계서 결정
| 결정 | 채택 | 대안 | 근거 |
|------|------|------|------|
| Protocols.category CHECK 6 → 7 마이그레이션 | ✓ | (A) virtual mapping (DB 무변경, Dart Map) / (B) display_category 신규 컬럼 | Planner AC-4 가 "category 필드 재분류" 명시. DB 가 SoT 인 본 앱 철학상 컬럼이 정답. 가상 매핑은 시드 파일과 DB 가 분리되어 일관성 깨짐. |
| schemaVersion 1→2 + 진짜 onUpgrade | ✓ | onUpgrade 무시 + 신규 설치만 지원 | 기존 dev 단말 (joungmin 본인) 의 v1 DB 가 있음. assert false 깨지면 부팅 실패. 한 번 제대로 만들면 후속 마이그레이션 패턴 재사용. |
| 마이그레이션 = DROP + CREATE + reseed | ✓ | INSERT...SELECT 로 row-level 재맵핑 | Protocols 는 read-only catalog — drop 해도 데이터 손실 0 (시드에서 복원). row-level 재맵핑은 라벨 매핑 알고리즘 분리 필요해서 과한 복잡도. |
| 빈 카테고리 칩 자체 숨김 | ✓ | 칩 회색 + "준비 중" | 사용자가 "왜 비어있지" 묻는 친화성 ↓. 빈 카테고리 존재를 모르게 하는 게 더 깔끔. emotion_relationship 추가는 향후 시드 갱신 시 자동 노출. |
| break + diet 통합 view (CatalogItem sealed) | ✓ | 3개 화면 분리 (Protocol Gallery / Break Gallery / Diet Gallery) | 사용자는 "수면 관련 뭐 있나?" 같은 카테고리 중심 탐색 — source 별 분리는 사용자 멘탈모델과 직교. unified view 가 정답. |
| "내 습관으로" 버튼 disabled placeholder | ✓ | 버튼 자체 없음 (다음 PR 에서 추가) | Preview 화면을 다음 이슈에서 또 수정하는 비용 방지 + 사용자에게 "다음 업데이트 예정" 시그널. UX 비용 0. |
| ADR-0004 발행 | ✓ | ADR 없음 | 첫 schema 마이그레이션 = 향후 정책 (DROP+reseed for catalog vs row-migrate for user) 의 기준점. ADR 가치 충분. |
| sealed class CatalogItem | ✓ | abstract class + downcast | Dart 3+ sealed pattern matching 안전 + 신규 source 추가 시 컴파일 에러로 강제. |
### 핵심 리스크
1. **카테고리 분류 의견 차** (예: cold_exposure = recovery? movement?) — Architect 1차 결정 (recovery_stress), Developer 가 staging 검증 시 모호 케이스를 저널 노트로 보고. QA 가 비논리 매핑 적발 권한.
2. **emotion_relationship 빈 카테고리** — 현 시드 0 매핑 가능. 본 이슈 후 시드 보강은 별도 (#FF5+ 콘텐츠 추가).
3. **첫 마이그레이션 버그** — onUpgrade 미작동 시 dev 단말 부팅 실패. 마이그레이션 unit test 가 1차 방어선.
### 되돌리기 어려운 결정 → ADR
- **ADR-0004** "Catalog re-categorization + first schema migration policy" 본 이슈에서 발행:
- 결정 1: Catalog (read-only seed) 마이그레이션은 DROP + reseed
- 결정 2: User (mutable) 테이블 마이그레이션은 row-preserving (해당 안 됨, 본 이슈)
- 결정 3: DisplayCategory 8개 vs Storage category 7개 (Protocols) — Storage = 단일 source 7, breakHabit/nutrition 은 별도 source 라 enum 만 8.
## 12. 미해결 질문 (Open Questions)
| OQ | 질문 | 상태 | 권고 |
|----|------|------|------|
| **OQ-1** | `methodologies` / `frame_patterns` / `reward_menu_items` 도 갤러리에 노출? | OPEN → Planner OOS 명시. **본 이슈에서 미노출 확정**. | Phase 2-C 시점에 재검토 |
| **OQ-2** | url_launcher 패키지 도입? | OPEN → 본 이슈에서 미도입 (텍스트 표시만). | #FF1 또는 별도 |
| **OQ-3** | DietPattern 의 `koreanContextFit` 을 카드 배지로? | OPEN → 본 이슈에서 미표시 (Preview 만). | UX 피드백 후 결정 |
| **OQ-4** | "내 습관으로" 버튼이 disabled 상태에서 사용자가 누르면 toast? | OPEN → tooltip 만 (Material 표준). | #FF1 에서 활성화 |
| **OQ-5** | 갤러리 카드 정렬 순서 (id / evidence_strength / 가나다) | OPEN → **id 알파벳 순** (안정적 + 예측 가능). | Developer 결정 권한 |
| **OQ-6** | references URL 없을 때 카드 표시 vs 숨김 | OPEN → **표시** (title + kind 만, url 라인 숨김). | Developer 결정 |
---
## 부록: 자가 점검 (Architect 종료 시 검증)
- [x] §1~§12 모든 섹션 채워짐
- [x] Planner 10 AC 모두 §3 + §10 1:1 매핑
- [x] 모든 함수 §7 표에 등재. 복잡 2개 (`CatalogRepository.all`, `migrateV1ToV2`) → fn-*.md 작성
- [x] I/O ↔ 순수 로직 경계 §5 명시
- [x] 데이터 모델 §6 (DisplayCategory enum + Protocols.category v2 CHECK + CatalogItem sealed) 정의
- [x] 마이그레이션 시나리오 (신규/업그레이드 둘 다) §8 명시
- [x] 에러 / 빈 카테고리 / ref 매칭 실패 §9 처리
- [x] ADR-0004 발행 항목 §11
- [x] out-of-scope §2 명확 (#FF1~#FF4 라벨)
- [x] AC-8 (DB write 0건) 보장 매커니즘 §5 + §8

View File

@@ -0,0 +1,191 @@
# 함수 설계서: `CatalogRepository.all` (#226)
> **부모 설계서**: ./README.md · **상태**: Draft
> **작성**: [AI] Architect (2026-06-12) · **구현**: `app/lib/data/catalog/catalog_repository.dart` · **테스트**: `app/test/data/catalog/catalog_repository_test.dart`
## 1. 시그니처
```dart
class CatalogRepository {
CatalogRepository(this._db);
final AppDatabase _db;
Future<List<CatalogItem>> all();
Future<CatalogItem?> byId(String id);
Future<List<ReferenceRow>> referencesByIds(List<String> ids);
}
```
본 fn-*.md 는 `all()` 의 알고리즘만 다룬다. `byId` / `referencesByIds` 는 단순 lookup 이므로 README §7 표 한 줄로 충분.
## 2. 책임 (단일 책임, 1줄)
3 source (Protocols / BreakProtocols / DietPatterns) 를 단일 `List<CatalogItem>` 으로 통합 — 본 이슈의 핵심 변환 한 점.
## 3. 입력
| 파라미터 | 타입 | 제약 | 설명 |
|---|---|---|---|
| (인스턴스 필드) `_db` | `AppDatabase` | non-null | seed 가 끝난 DB (시드 안 끝났으면 호출자가 보장) |
## 4. 출력
- **반환**: `List<CatalogItem>` — 총 47 항목 (protocols 34 + break 8 + diet 5).
- 각 항목은 `ProtocolCatalogItem` / `BreakCatalogItem` / `DietCatalogItem` 중 하나 (sealed).
- 정렬: `displayCategory.index``id` 알파벳 순.
- **부수효과**: DB 3회 read. **write 0**.
## 5. 동작 / 알고리즘
```
1. final protocolRows = await _db.select(_db.protocols).get(); // 34
2. final breakRows = await _db.select(_db.breakProtocols).get(); // 8
3. final dietRows = await _db.select(_db.dietPatterns).get(); // 5
4. final items = <CatalogItem>[];
5. for each p in protocolRows:
final dc = DisplayCategory.fromProtocolCategory(p.category);
if (dc == null) {
throw StateError(
'unknown protocol category "${p.category}" for id=${p.id}'
);
}
items.add(ProtocolCatalogItem(
id: p.id,
title: p.title,
titleEn: p.titleEn,
summary: _summary(p.what, fallback: p.title),
displayCategory: dc,
evidenceStrength: p.evidenceStrength,
referenceIds: _decodeIds(p.referenceIdsJson),
what: p.what,
whenText: p.whenText,
dose: p.dose,
why: p.why,
how: _decodeList(p.howJson),
checkText: p.checkText,
caution: p.caution,
defaultAnchor: _decodeAnchor(p.defaultAnchorJson),
minDoseForStart: p.minDoseForStart,
sourceDoc: p.sourceDoc,
));
6. for each b in breakRows:
items.add(BreakCatalogItem(
id: b.id,
title: b.title,
titleEn: null,
summary: b.hubermanSummary, // 이미 1줄 요약 형태
displayCategory: DisplayCategory.breakHabit,
evidenceStrength: null, // BreakProtocol 스키마에 없음
referenceIds: _decodeIds(b.referenceIdsJson),
breakCategory: b.category, // 'alcohol' / 'nicotine' / ...
hubermanSummary: b.hubermanSummary,
phases: _decodeList(b.phasesJson),
defaultCommonFrames: _decodeList(b.defaultCommonFramesJson),
tools: _decodeList(b.toolsJson),
medicalWarning: b.medicalWarning,
));
7. for each d in dietRows:
items.add(DietCatalogItem(
id: d.id,
title: d.name,
titleEn: null,
summary: d.core,
displayCategory: DisplayCategory.nutrition,
evidenceStrength: d.evidenceStrength,
referenceIds: _decodeIds(d.referenceIdsJson),
name: d.name,
core: d.core,
strengths: _decodeList(d.strengthsJson),
weaknesses: _decodeList(d.weaknessesJson),
koreanContextFit: d.koreanContextFit,
starterLevers: _decodeList(d.starterLeversJson),
medicalWarning: d.medicalWarning,
linkedProtocolIds: _decodeIds(d.linkedProtocolIdsJson),
));
8. items.sort((a, b) {
final c = a.displayCategory.index - b.displayCategory.index;
return c != 0 ? c : a.id.compareTo(b.id);
});
9. return items;
```
### 헬퍼 (file-private, 모두 pure)
```dart
String _summary(String what, {required String fallback, int max = 60}) {
final firstSentence = what.split(RegExp(r'[.!?。!?]')).first.trim();
final s = firstSentence.isEmpty ? fallback : firstSentence;
return s.length <= max ? s : '${s.substring(0, max - 1)}';
}
List<String> _decodeIds(String? json) {
if (json == null) return const [];
final decoded = jsonDecode(json);
return decoded is List ? decoded.cast<String>() : const [];
}
List<String> _decodeList(String? json) {
if (json == null) return const [];
final decoded = jsonDecode(json);
return decoded is List ? decoded.map((e) => e.toString()).toList() : const [];
}
Map<String, dynamic>? _decodeAnchor(String? json) {
if (json == null) return null;
final decoded = jsonDecode(json);
return decoded is Map<String, dynamic> ? decoded : null;
}
```
## 6. 에러 & 실패 모드
| 조건 | 처리 | 반환/예외 |
|---|---|---|
| `protocol.category` 가 DisplayCategory 매핑 0 | 부팅 직후 첫 호출에서 throw → AsyncValue.error → SnackBar "카탈로그 손상" | `StateError('unknown protocol category "x" for id=y')` |
| `howJson` / `referenceIdsJson` 파싱 실패 | 빈 리스트 반환 (graceful) | — |
| DB 미시드 (kSeededV1Flag 없음) | 호출자가 `seedInProgressProvider` 로 막아야 함. 본 함수는 raw 결과 (0 row) 반환 — UI 가 "준비 중" 표시. | — |
| 3 source 중 한 source 가 부분 손상 (예: protocols 0 row, break 정상) | partial 결과 반환 — 사용자가 break 만 봄. | — |
| `_summary` 가 빈 문자열 | fallback (title) 사용. | — |
## 7. 엣지케이스
- **47 ≠ 실제 row 수**: 시드 갱신 후 row 수 변동 가능. 본 함수는 row 수 비검증 — `catalog_repository_test.dart` 가 일관성 검증.
- **DisplayCategory 추가**: enum 에만 추가하고 매핑은 staging JSON 으로 들어가는 새 protocol 만 채움. 기존 47 항목 매핑 무변화.
- **중복 id**: PK 제약상 발생 불가 — drift 가 보장.
- **빈 summary**: `what` 이 punctuation 으로만 시작하면 `_summary` 가 빈 문자열 → fallback 적용.
## 8. 복잡도 / 성능
- 시간: O(N) — N=47, 사용자 화면 진입 1회.
- 공간: O(N) — 47 인스턴스.
- 호출 빈도: **갤러리 진입 시 1회** (Riverpod cache, 화면 사라질 때 dispose).
- 실측 추정 latency: < 5ms on 8GB+ Android. 5초 cold start 영향 0.
## 9. 의존성
- 호출: `AppDatabase.select` x 3, `dart:convert` (jsonDecode), `DisplayCategory.fromProtocolCategory`.
- 호출처: `catalogItemsProvider` (Riverpod `FutureProvider<List<CatalogItem>>`).
## 10. 테스트 케이스
- [ ] **정상**: seed 가 끝난 in-memory DB → all() 가 47 item + 정렬 (displayCategory.index → id)
- [ ] **카테고리 매핑**: 모든 ProtocolCatalogItem.displayCategory ≠ null
- [ ] **Break 단일 카테고리**: 모든 BreakCatalogItem.displayCategory == breakHabit
- [ ] **Diet 단일 카테고리**: 모든 DietCatalogItem.displayCategory == nutrition
- [ ] **summary 길이**: 모든 item 의 summary ≤ 60자
- [ ] **referenceIds 디코딩**: 빈 JSON / null / 정상 케이스 3개
- [ ] **에러**: 손상된 category 값을 직접 DB 에 insert 후 all() → StateError
- [ ] **빈 DB**: seed 안 한 DB → 빈 리스트 (throw 안 함)
- [ ] **byId 정상 / 미존재**: 2 케이스
- [ ] **referencesByIds**: 일부 매칭 / 전부 미매칭 2 케이스
## 11. 추적성
- 인수조건: #226 AC-2 (카테고리 칩 표시), AC-3 (카드 그리드), AC-4 (8 카테고리 매핑), AC-7 (reference 매칭).
- 관련 ADR: ADR-0004 (본 이슈에서 발행).

View File

@@ -0,0 +1,120 @@
# 함수 설계서: `migrateV1ToV2` (#226)
> **부모 설계서**: ./README.md · **상태**: Draft
> **작성**: [AI] Architect (2026-06-12) · **구현**: `app/lib/data/db/app_database.dart` 의 `migration.onUpgrade` 인라인 또는 file-private top-level · **테스트**: `app/test/data/db/migration_v1_to_v2_test.dart`
## 1. 시그니처
```dart
Future<void> _migrateV1ToV2(Migrator m, AppDatabase db) async { ... }
```
`MigrationStrategy.onUpgrade` 에서 dispatch:
```dart
onUpgrade: (m, from, to) async {
if (from == 1 && to >= 2) {
await _migrateV1ToV2(m, this); // this = AppDatabase
}
// future:
// if (from <= 2 && to >= 3) await _migrateV2ToV3(m, this);
if (from > to || to > schemaVersion) {
assert(false, 'Unknown upgrade path: from=$from to=$to (schemaVersion=$schemaVersion)');
}
},
```
## 2. 책임 (단일 책임, 1줄)
v1 DB 의 `protocols` 테이블을 v2 CHECK 제약으로 교체하고 시드 flag 클리어 — read-only catalog 의 첫 마이그레이션 패턴.
## 3. 입력
| 파라미터 | 타입 | 제약 | 설명 |
|---|---|---|---|
| `m` | `Migrator` | drift 의 schema migrator | DDL API |
| `db` | `AppDatabase` | non-null | metaKv 클리어용 |
## 4. 출력
- **반환**: `Future<void>`.
- **부수효과**:
- `protocols` 테이블 DROP + CREATE (CHECK 제약 7 카테고리로) + 인덱스 `IDX_protocols_category` 재생성.
- `meta_kv` 에서 `kSeededV1Flag` row DELETE.
- 다음 부팅 시 `SeedImporter.importIfNeeded()` 가 재시드 트리거.
- **user 테이블 (Habits, Phases, TrackerEntries, ...) 무변화**.
## 5. 동작 / 알고리즘
```
1. await m.deleteTable(db.protocols);
# SQLite: DROP TABLE protocols
# 인덱스도 자동 cascade drop.
2. await m.createTable(db.protocols);
# v2 schema 로 CREATE TABLE protocols (
# id TEXT PRIMARY KEY,
# category TEXT CHECK (category IN (
# 'light_circadian','sleep','movement','nutrition',
# 'focus_cognition','recovery_stress','emotion_relationship'
# )) NOT NULL,
# title TEXT NOT NULL,
# ...
# );
3. await m.createIndex(Index(
'IDX_protocols_category',
'CREATE INDEX IDX_protocols_category ON protocols(category)',
));
# drop 시 자동 cascade 됐어도 명시적 재생성.
4. await (db.delete(db.metaKv)
..where((t) => t.key.equals(kSeededV1Flag)))
.go();
# 시드 flag 1 row 삭제. 다음 부팅이 importIfNeeded() 호출 → 새 JSON 으로 reseed.
```
## 6. 에러 & 실패 모드
| 조건 | 처리 | 반환/예외 |
|---|---|---|
| DROP 실패 (이론상 없음, 사용자 락) | drift 가 transaction 롤백 → 부팅 실패. 사용자에겐 명시적 에러. | SqliteException 전파 |
| CREATE 실패 (이론상 없음) | 동상 | SqliteException 전파 |
| metaKv 삭제 실패 (이론상 없음) | 동상 — but **여기까지 도달 시 protocols 테이블은 v2 형태**. 다음 부팅 시 flag 가 'true' 인 채라 reseed 안 함 → protocols 빈 상태. **위험.** 그래서 metaKv 삭제는 트랜잭션 내 마지막 단계가 아니라 순서가 중요. | drift onUpgrade 전체가 트랜잭션 — drop/create 와 metaKv 삭제 같이 묶임. |
| 다음 부팅 시 seed JSON 손상 | SeedImporter 가 CHECK 위배 throw → 부팅 실패. dev 단말 1대 영향이라 수용. | FormatException / SqliteException |
## 7. 엣지케이스
- **신규 설치 (`onCreate`)** — 본 함수 호출 0. createAll 이 v2 schema 그대로 적용 후 seed 가 v2 JSON 로드. 정상.
- **v1 → v3+ 점프 (이론상 없음, 현재 schemaVersion=2)** — `from=1, to=3` 이면 v1→v2 → v2→v3 순차 실행 가정. `_migrateV2ToV3` 가 아직 없어 dispatch 가 발견 못 함 → assert false. v3 도입 시점에 명시.
- **트랜잭션 중단** — drift 의 onUpgrade 는 db.transaction 안에서 실행. 부분 실패 시 자동 롤백 → 사용자 DB 는 v1 그대로. 다음 시도에서 재실행.
- **사용자 데이터 보호** — 본 함수는 Protocols 만 건드림. Habits/TrackerEntries 등 user 테이블 0 영향. `migration_v1_to_v2_test.dart` 가 명시적 검증.
- **인덱스 재생성 누락 시** — query latency ↓ 만 영향 (정상 동작). 본 함수가 명시적으로 createIndex 호출하므로 보호.
## 8. 복잡도 / 성능
- 시간: O(1) — DDL 4건.
- 실측: < 50ms (dev 단말, drift 의 transaction overhead 포함).
- 호출 빈도: **dev 단말 평생 1회** (v1 → v2 한 번). 사용자 신규 설치는 호출 0.
## 9. 의존성
- drift `Migrator` API (deleteTable, createTable, createIndex).
- `kSeededV1Flag` 상수 (`core/constants.dart`).
- AppDatabase 의 `protocols` getter (스키마 가져오기).
## 10. 테스트 케이스
- [ ] **smoke**: in-memory DB 를 v1 schema 로 raw SQL 로 생성 → `_migrateV1ToV2(m, db)` 호출 → protocols 테이블의 CHECK 제약이 v2 7 카테고리인지 검증 (PRAGMA / sqlite_master 조회)
- [ ] **flag 클리어**: 사전에 metaKv 에 `seeded_v1='true'` insert → migrate → metaKv 조회 시 row 없음
- [ ] **user 데이터 보호**: 사전에 Habits / Phases / TrackerEntries 에 row insert → migrate → 모두 그대로
- [ ] **v2 CHECK 위배 negative**: migrate 후 `INSERT INTO protocols (..., category='health', ...)` 시도 → SqliteException
- [ ] **v2 CHECK 통과 positive**: `category='light_circadian'` insert → 성공
- [ ] **인덱스 존재**: migrate 후 `sqlite_master` 에서 `IDX_protocols_category` 발견
- [ ] **이중 호출 안전성**: 동일 DB 에 migrate 2회 호출 → 두 번째도 성공 (idempotent 가정. drift `deleteTable` 이 미존재 테이블에 graceful 인지 확인 필요 — OQ)
- [ ] **integration with onUpgrade**: schemaVersion=2 인 AppDatabase 로 v1 DB 열기 → onUpgrade 자동 호출 → 정상 동작
## 11. 추적성
- 인수조건: #226 AC-4 (8 카테고리 재분류 — DB CHECK 갱신 부분), AC-8 (user 테이블 무변화), AC-9 (마이그레이션 unit test).
- 관련 ADR: **ADR-0004** (본 이슈에서 발행 — Catalog re-categorization + first schema migration policy).

View File

@@ -0,0 +1,284 @@
# 설계서: On-device Gemma tool calling (#260)
> **상태**: Draft
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
> **추적성** — Redmine: #260 · 관련 ADR: ADR-0005 (신규)
> · 구현 파일: `app/lib/ai/tools/`, `app/lib/ui/screens/chat_screen.dart`, `app/lib/data/ai/gemma_llm_service.dart` 일부 확장 · 테스트: `app/test/ai/tools/`, `app/test/ui/chat_screen_test.dart`
## 1. 목적 (Why)
on-device Gemma 4 와의 대화만으로 **카탈로그 검색 → 습관 추가 → 체크 → 스트릭 조회**를 한 흐름에 끝낸다. MCP 와 동일한 capability 추상화를 in-process Dart 함수로 구현해 latency 를 거의 0 으로 만든다.
## 2. 범위 (Scope)
- **포함**:
- 6 개 tool 정의 + 핸들러 (`search_catalog`, `query_protocol`, `add_habit`, `list_active_habits`, `log_tracker_entry`, `get_streak`)
- Multi-tool 대화 루프 (LLM 이 동적으로 tool 선택 → 핸들러 실행 → 결과 회신 → LLM 자연어 응답)
- Destructive tool 의 모달 Confirm UI 게이트
- Tool 응답 사이즈 가드 (≤ 2KB)
- Tool 핸들러에서 R1~R10 enforce (모델 prompt 가 아닌 코드)
- 신규 ChatScreen + AppBar entry
- 6 핸들러 unit + 1 widget E2E 테스트
- **제외 (out of scope)**:
- 음성 인터페이스
- 다국어 (한국어만)
- 대화 history persistence (in-memory only, 앱 종료 시 휘발)
- Streaming animation 의 고급 UI
- 모델 prompt engineering 으로 R 규칙 학습
- 자동 phase 전환 / reward 발급 tool
## 3. 인수조건 (Acceptance Criteria)
Planner #260 의 AC-1~12 그대로:
- [ ] AC-1: `search_catalog(category?, keyword?, limit≤10)` — summary (60자 truncate) 반환
- [ ] AC-2: `query_protocol(id)` — Protocol/Break/Diet 전체 필드
- [ ] AC-3: `add_habit(protocol_id, frame_level, framed_text, anchor?, dose?)` — Confirm UI 거침. 거부 시 `{cancelled: true}` 반환
- [ ] AC-4: `list_active_habits()` — 활성 습관 id/title/type/protocol_id
- [ ] AC-5: `log_tracker_entry(habit_id, value, date?)` — value ∈ {done, blank}
- [ ] AC-6: `get_streak(habit_id)` — missing vs blank 구분 결과
- [ ] AC-7: R1~R10 enforce 는 tool 핸들러 책임. 위반 시 `{error, reason}` 반환
- [ ] AC-8: destructive tool 은 ConfirmDialog 의무. read-only 는 직접 실행
- [ ] AC-9: tool result ≤ 2KB 가드. 초과 시 truncate + hint
- [ ] AC-10: 기존 110 + 신규 (≥ 12 unit + 1 widget E2E) 통과
- [ ] AC-11: tool 정의는 `lib/ai/tools/` 도메인별 분리
- [ ] AC-12: 잘못된 args (스키마 위배) → validation error 반환, crash 금지
## 4. 컨텍스트 & 제약
### 의존성
- **완료**: #218 (Gemma 4 통합 / `GemmaLlmService`), #226 (`CatalogRepository`, `DisplayCategory`)
- **활용 surface**:
- `HabitDao.insertWithVariants(HabitDraft)` — R8 + R9/R10 트랜잭션
- `HabitDao.countActive({userId, type})` — R1/R2
- `HabitDao.activeHabitsForUser(userId)` — list
- `TrackerDao.recordCheckIn(TrackerEntryDraft)` — R5
- `CatalogRepository.all()` / `byId()` — 카탈로그
- `validateFrameLevel()` / `detectAvoidanceKeywords()` — R3/R7
- `judgeActiveHabitQuota()` — R1/R2
- `computeStreak(...)` — streak 계산
- **라이브러리**: `flutter_gemma 0.16.5``Tool`, `ToolChoice.auto`, `FunctionCallResponse`, `ParallelFunctionCallResponse`, `TextResponse`
### 제약
- **단일 사용자**: Phase 1 `kLocalDefaultUserId` 하드코딩. tool 인자에 user_id 없음.
- **메모리**: Gemma 4 E2B 가 RAM 4GB+ 요구. tool args/result 는 추가로 모델 context 채움 → token budget 정책 필수.
- **응답 시간**: in-process 호출이라 핸들러 자체는 < 50ms. 하지만 LLM round trip 2회 (tool call decision + 최종 응답) 가 user-perceived latency 의 95%.
- **모달 race**: Confirm UI 가 떠 있는 동안 사용자가 chat 화면 dismiss 가능 — graceful cancellation 필요.
### 가정
- `flutter_gemma 0.16.5``ToolChoice.auto` 가 multi-tool 동적 선택 지원 (← Developer 가 1차 검증).
- 사용자는 한 turn 에 1 mutation 만 수행한다고 가정 (병렬 mutation tool 호출 시 첫 번째만 confirm, 나머지는 reject).
## 5. 아키텍처 개요
### 모듈 / 파일 구조 (신규)
```
app/lib/ai/tools/
├── tool_registry.dart # 모든 ToolDefinition 모음 + Dispatcher 입구
├── tool_dispatcher.dart # FunctionCallResponse → handler 라우팅 + result envelope
├── tool_envelope.dart # ToolResult sealed (Ok, Err, Cancelled) + JSON 직렬화 + 2KB 가드
├── confirm_gate.dart # destructive 호출 시 모달 표시 → bool 반환
├── catalog_tools.dart # search_catalog, query_protocol 정의 + 핸들러
├── habit_tools.dart # add_habit, list_active_habits 정의 + 핸들러
└── tracker_tools.dart # log_tracker_entry, get_streak 정의 + 핸들러
app/lib/ui/screens/
└── chat_screen.dart # ConsumerStatefulWidget. 메시지 리스트 + 입력 + tool call 표시
app/lib/state/
└── chat_providers.dart # chatSessionProvider (StateNotifier), 등록 도구 리스트 provider
app/lib/data/ai/
└── gemma_llm_service.dart # (확장) sendChatTurn(...) 새 메서드 — multi-tool loop 지원
```
### 데이터 흐름
```
ChatScreen.send("아침 햇빛 프로토콜 알려줘")
ChatSessionController.userTurn(text)
│ 1. 사용자 메시지 append
│ 2. llm.sendChatTurn(history, tools) 호출 → stream
GemmaLlmService.sendChatTurn(...) ← 신규 메서드
│ 1. createChat(tools: registry.all(), toolChoice: auto) [세션 캐싱]
│ 2. chat.add(userMessage)
│ 3. stream = chat.generateResponseAsync()
◇ ModelResponse 분기
├─ TextResponse → controller.appendModelChunk(text)
├─ ThinkingResponse → drop (memory: isThinking:false)
└─ FunctionCallResponse(name, args)
ToolDispatcher.dispatch(name, args, context)
◇ ToolDefinition.isDestructive ?
│ ├─ Y → ConfirmGate.show(context, tool, args) → bool
│ │ └─ false → return ToolResult.cancelled()
│ └─ N → 계속
handler(args, deps) async
│ 1. args schema validate (try/catch)
│ 2. R 규칙 enforce (R3/R5/R7/R8 등 호출)
│ 3. Repository 호출
│ 4. ToolResult.ok(payload) or ToolResult.err(code, reason)
ToolEnvelope.encode(result) → JSON String ≤ 2KB
│ size > 2KB → truncate + hint
chat.addToolResult(name, jsonString)
다시 generateResponseAsync() → TextResponse 로 마무리
```
### I/O ↔ 순수 분리
- **I/O**: `GemmaLlmService.sendChatTurn`, `ToolDispatcher.dispatch` (Repository 호출), `ConfirmGate.show` (UI)
- **순수**:
- `ToolEnvelope.encode/truncate` — JSON 직렬화 + 사이즈 가드
- `ToolArgsValidator.validate(tool, args)` — schema 매칭
- 각 도메인 R 규칙 함수 (기존)
- **테스트 전략**: 핸들러 unit 테스트는 in-memory DB + 직접 호출. ConfirmGate 는 `WidgetTester` 로 모달 검증. Multi-tool loop 는 Mock LLM 으로 시뮬레이션 (모델 호출 안 함).
## 6. 데이터 모델
### ToolDefinition
```dart
class ToolDefinition {
final String name; // 'search_catalog'
final String description; // 모델이 보는 한국어 설명
final Map<String, dynamic> parametersSchema; // JSON Schema (draft-07)
final bool isDestructive; // true → ConfirmGate 거침
final ToolHandler handler; // Future<ToolResult> Function(args, deps)
}
```
### ToolResult (sealed)
```dart
sealed class ToolResult {
Map<String, dynamic> toJson();
}
final class ToolOk extends ToolResult {
final Map<String, dynamic> data;
}
final class ToolErr extends ToolResult {
final String code; // 'validation' | 'r3_quota' | 'r7_avoidance' | 'r8_xor' | ...
final String reason; // 모델이 사용자에게 안내할 한국어
}
final class ToolCancelled extends ToolResult {
// user dismissed confirm modal
}
```
### ChatMessage
```dart
sealed class ChatMessage {
final DateTime ts;
}
final class UserChatMessage extends ChatMessage { final String text; }
final class ModelChatMessage extends ChatMessage { final String text; }
final class ToolCallChatMessage extends ChatMessage {
final String toolName;
final Map<String, dynamic> args;
final ToolResult result;
// UI 표시: "📦 search_catalog 호출 → 3개 결과"
}
```
### 입력 검증 규칙
| Tool | 인자 | 제약 |
|---|---|---|
| search_catalog | category, keyword, limit | category ∈ DisplayCategory.values \| null, keyword ≤ 50자, limit ∈ [1,10] |
| query_protocol | id | non-empty String |
| add_habit | protocol_id, frame_level, framed_text, anchor?, dose? | protocol_id ∈ existing catalog ids, frame_level ∈ {L2, L3}, framed_text ≤ 200자 |
| list_active_habits | (없음) | — |
| log_tracker_entry | habit_id, value, date? | habit_id ∈ existing habits, value ∈ {done, blank}, date YYYY-MM-DD 또는 null(=today) |
| get_streak | habit_id | habit_id ∈ existing habits |
## 7. 함수 명세 (Function Specs)
복잡 함수는 별도 파일:
- `fn-tool_dispatcher.md` — multi-tool 라우팅 + envelope + Confirm gate 통합
- `fn-add_habit_handler.md` — destructive 핸들러 대표. R3/R7/R8 enforce + HabitDraft 빌드
- `fn-confirm_gate.md` — 모달 UI 흐름 + race 조건
- `fn-chat_session_controller.md` — Multi-tool loop 의 상태 머신
단순 함수 (직접 구현):
- `search_catalog_handler` / `query_protocol_handler` / `list_active_habits_handler` / `get_streak_handler` — Repository 호출 후 envelope 만 씌움
- `ToolEnvelope.encode` / `truncate` — JSON encode + 2KB cap + 말미 hint
## 8. 변경 영향 / 기존 코드 수정
| 파일 | 변경 |
|---|---|
| `lib/data/ai/gemma_llm_service.dart` | `sendChatTurn(history, tools)` 신규 메서드. 기존 `generateStructured` 는 유지 (frame suggest 가 사용 중). |
| `lib/data/ai/llm_service.dart` | `LlmService` 인터페이스에 `sendChatTurn(...)` 추가. `MockLlmService` 도 갱신. |
| `lib/state/ai_providers.dart` | 변경 없음. |
| `lib/ui/screens/habit_list_screen.dart` | AppBar 에 chat icon → ChatScreen 진입 (검색 옆). |
| `lib/state/providers.dart` | 변경 없음. |
| `lib/domain/rules/active_habit_quota.dart` | 기존 함수 그대로 활용. 호출 위치만 핸들러로 확장. |
## 9. 비기능 요구
- **Latency**: tool 1 회당 LLM round trip 2 회. 모델 응답 평균 2~5 초 (E2B), 핸들러 자체 < 100ms.
- **메모리**: tool args/result 가 모델 context 에 누적. 8 turn 후 reset 권장 (OQ-2 정책).
- **A11y**: ConfirmDialog 는 Semantics + autofocus 첫 액션 버튼.
- **i18n**: 한국어만. tool description 도 한국어.
- **로깅**: tool call 이벤트 (name, success/err code) 만 — args/result payload 는 로깅 금지 (PII 누출 차단).
## 10. 테스트 전략
### Unit (≥ 12)
- `tool_envelope_test.dart` — encode/decode round-trip, 2KB truncate, error 직렬화 (3)
- `catalog_tools_test.dart` — search_catalog (category 필터/keyword/limit) + query_protocol (정상/미존재) (4)
- `habit_tools_test.dart` — add_habit 성공 + R3 차단 + R7 차단 + R8 차단, list_active_habits (5)
- `tracker_tools_test.dart` — log_tracker_entry 성공 + R5 차단 + date 기본값, get_streak (4)
- `tool_dispatcher_test.dart` — unknown tool, validation fail, ok 경로 (3)
### Widget (≥ 1)
- `chat_screen_test.dart` — 시드 후 "아침 햇빛 추가해줘" → mock LLM 이 add_habit tool call → ConfirmDialog 노출 → 확인 → habit row 1개 증가 검증
### Integration (선택)
- 실 단말 manual : 카탈로그 검색 + 습관 추가 + 체크 흐름 1회 (QA 단계).
## 11. Open Questions (구현 중 답)
### Planner 의 OQ
- **OQ-1 (idempotency)**: 동일 mutation 연속 호출 처리
- **결정**: tool 핸들러는 (구분 키, time window) 기반 dedup 없음. 대신 **응답에 의미 있는 ID 를 항상 포함** → 모델이 후속 turn 에서 "이미 추가됨" 인지. Repository 의 unique constraint 가 최종 안전망 (TrackerEntries (habit_id, date) UNIQUE 가정 — Developer 확인 필요).
- 이유: 시간 기반 dedup 은 사용자가 의도적으로 "다시 시도" 한 경우와 구분 못함. 명시적 confirm UI 가 이미 안전 게이트.
- **OQ-2 (token budget)**:
- **결정**: tool result 직렬화 후 **2048 bytes hard cap**. 초과 시 마지막 1KB 를 `"... (잘림) 더 보려면 query_protocol 사용"` 으로 대체. 카탈로그 list 응답은 항상 summary(60자) + id 만, 상세는 별도 tool 호출.
- 추가: chat history 가 8 turn 초과 시 controller 가 "지난 대화를 정리할까요?" 안내 — 자동 reset 은 안 함 (사용자가 선택).
- **OQ-3 (Confirm UI)**: 모달 확정 (사용자 결정 2026-06-15)
- `ConfirmGate.show(context, ToolDefinition, args)``showDialog<bool>` 으로 AlertDialog 표시. 제목 = "이 작업을 수행할까요?", 본문 = tool description + args 의 사람 친화 요약 (예: "프로토콜 '아침 햇빛'을 L2 프레임으로 새 습관 추가"), 액션 = "취소" / "수행". 모달이 뜨는 동안 chat 입력은 disable.
- **OQ-4 (schema SoT)**:
- **결정**: **Dart 코드** 가 SoT. 각 `ToolDefinition``parametersSchema` 는 Dart 리터럴 Map. 이유:
1. yaml 추가 시 codegen + 버전 동기화 부담
2. 핸들러 시그니처 와 schema 가 같은 파일에 있어야 drift 방지
3. 자동완성 + 리팩터링 도구 활용 (rename, find-usages)
- 추후 schema 가 수십 개 이상이면 ADR 후속에서 재논의.
### 신규 OQ (Developer 가 구현 중 답)
- **OQ-5**: `flutter_gemma 0.16.5``ToolChoice.auto` 가 multi-tool 동적 선택 + tool 호출 안 함 (TextResponse) 모두 지원하는가? — Developer 가 small probe 작성. 미지원 시 fallback = `ToolChoice.required` + meta-tool ("any_action"/"reply_only").
- **OQ-6**: `chat.addToolResult(...)` API 명 + 시그니처 정확히 (`Tool result message`, `addFunctionResult` 등 변형 가능) — flutter_gemma changelog 확인.
- **OQ-7**: `TrackerEntries(habit_id, date)` UNIQUE 제약 존재 여부 — 없으면 마이그레이션 추가 vs 핸들러 levelup 결정.
## 12. ADR 후보
- **ADR-0005**: "In-app tool calling architecture (MCP-equivalent)" — 발행 예정.
- 결정 1: tool runtime 은 in-process Dart (MCP 서버 별도 띄우지 않음)
- 결정 2: R 규칙 enforce 는 tool 핸들러 책임 (모델 prompt 아님)
- 결정 3: schema SoT = Dart 코드
- 결정 4: destructive tool = 모달 Confirm 게이트 의무
## 13. 작업 분할 (Developer 가 참고)
1. ADR-0005 발행 (Architect 발행 — 본 작업과 동시)
2. `ToolDefinition` + `ToolResult` + `ToolEnvelope` 골격
3. `catalog_tools` 2 핸들러 (read-only) + 테스트
4. `tracker_tools` 2 핸들러 + 테스트
5. `habit_tools` 2 핸들러 + R 규칙 enforce + 테스트
6. `ToolDispatcher` + `ConfirmGate` + 테스트
7. `GemmaLlmService.sendChatTurn` + `LlmService` 확장 + `MockLlmService` 갱신
8. `ChatScreen` + `ChatSessionController` + 위젯 테스트
9. `HabitListScreen` AppBar 진입점
10. 회귀 (전체 110 + 신규)

View File

@@ -0,0 +1,141 @@
# 함수 설계서: `addHabitHandler` (#260)
> **부모 설계서**: ./README.md · **상태**: Draft
> **작성**: [AI] Architect · **구현**: `lib/ai/tools/habit_tools.dart:addHabitHandler` · **테스트**: `test/ai/tools/habit_tools_test.dart`
## 1. 시그니처
```dart
Future<ToolResult> addHabitHandler(
Map<String, dynamic> args,
ToolDeps deps,
);
```
대표 destructive 핸들러. 다른 destructive (`log_tracker_entry`) 도 동일 패턴을 따름.
## 2. 책임 (단일 책임, 1줄)
LLM 이 제안한 새 습관 args 를 검증 → R1~R10 enforce → `HabitDao.insertWithVariants` 호출 → 결과 envelope 반환.
## 3. 입력
| 파라미터 | 타입 | 제약 | 설명 |
|---|---|---|---|
| `args.protocol_id` | String | non-empty, catalog 에 존재 | 사용자가 선택한 카탈로그 ID |
| `args.frame_level` | String | ∈ {"L2", "L3"} | R3: L0/L1 reject |
| `args.framed_text` | String | 1~200자 | 사용자가 보는 문장 |
| `args.anchor_when` | String? | ≤ 50자 | 선택 — 예: "기상 후" |
| `args.anchor_after_what` | String? | ≤ 50자 | 선택 — 예: "세수" |
| `args.dose_text` | String? | ≤ 100자 | 선택 — variant 단일 생성 (없으면 시드의 default_dose) |
| `deps` | ToolDeps | non-null | userId / HabitDao / CatalogRepository |
**`type` 인자 없음** — catalog item 의 종류 (Protocol/Break) 에 따라 자동 결정. 핸들러가 catalog lookup 으로 판정.
## 4. 출력
- **반환**: `Future<ToolResult>`.
- `ToolOk(data: {habit_id, title, type, frame_level})` 성공
- `ToolErr(code: 'validation' | 'not_found' | 'r3_quota' | 'r7_avoidance' | 'r8_xor', reason: 한국어)`
- **부수효과**: 성공 시 DB write (Habits + HabitDoseVariants 트랜잭션). 실패 시 무변화.
## 5. 동작 / 알고리즘
```
1. validate args (schema 는 dispatch 가 이미 했지만, 의미 검증 추가):
- protocol_id = args['protocol_id'] (String)
- frameLevel = FrameLevel.fromString(args['frame_level'])
if frameLevel == null || frameLevel in {l0, l1}:
return ToolErr('validation', 'frame_level 은 L2 또는 L3 이어야 합니다.')
- framedText = args['framed_text']
if framedText.trim().isEmpty:
return ToolErr('validation', 'framed_text 가 비어있습니다.')
2. catalog lookup (1차 효과로 type 결정):
item = await deps.catalog.byId(protocol_id)
if item == null:
return ToolErr('not_found', '카탈로그에서 \'$protocol_id\' 를 찾을 수 없습니다.')
if item is ProtocolCatalogItem: habitType = build
else if item is BreakCatalogItem: habitType = breakHabit
else if item is DietCatalogItem: habitType = build // diet 는 build 로 매핑
else: return ToolErr('validation', '지원되지 않는 카탈로그 타입')
3. R7 avoidance keyword 검증:
patterns = await framePatterns() // ref 로 캐시된 것 활용 권장 (deps 에 주입)
hits = detectAvoidanceKeywords(framedText, patterns)
if hits.isNotEmpty:
return ToolErr('r7_avoidance',
'L${frameLevel} 프레임에 회피 키워드 "${hits.first.keyword}" 가 감지됐어요. '
'"${hits.first.suggestion}" 같은 표현으로 다시 시도해주세요.')
4. R3 active habit quota 검증:
count = await deps.habitDao.countActive(userId: deps.userId, type: habitType)
quota = judgeActiveHabitQuota(type: habitType, currentActiveCount: count)
if !quota.allowed:
return ToolErr('r3_quota', quota.reason)
5. HabitDraft 빌드 (R8 XOR enforce 는 HabitDao 내부 assertion):
draft = HabitDraft(
userId: deps.userId,
type: habitType,
title: item.title,
protocolId: habitType == HabitType.build ? protocol_id : null,
breakProtocolId: habitType == HabitType.breakHabit ? protocol_id : null,
frameLevel: frameLevel,
frameFramedText: framedText,
anchorWhen: args['anchor_when'],
anchorAfterWhat: args['anchor_after_what'],
startedAt: today_kst_yyyymmdd(),
variants: dose_text != null
? [VariantDraft(label: '기본', doseText: dose_text, isMinimum: false, sortOrder: 0)]
: [], // 빈 variants 면 HabitDao 가 시드 default 로 채우거나 그대로 빈 채로 저장
)
6. insert:
habitId = await deps.habitDao.insertWithVariants(draft)
// R8 위배 시 assertXorProtocol 가 throw → catch → return ToolErr('r8_xor', ...)
7. return ToolOk(data: {
'habit_id': habitId,
'title': item.title,
'type': habitType.name,
'frame_level': frameLevel.name,
})
```
## 6. 에러 & 실패 모드
| 조건 | 처리 | 반환 |
|---|---|---|
| frame_level L0/L1 | 사용자 안내 | `ToolErr('validation', '...')` |
| protocol_id 미존재 | 사용자 안내 | `ToolErr('not_found', '...')` |
| 회피 키워드 감지 | 사용자 안내 + 대안 제시 | `ToolErr('r7_avoidance', '...')` |
| R3 quota 초과 | 사용자 안내 | `ToolErr('r3_quota', '...')` |
| R8 XOR 위배 (이론상 불가, 안전망) | 로깅 + 안내 | `ToolErr('r8_xor', '...')` |
| HabitDao 예외 | dispatch 가 catch → `ToolErr('handler_error', ...)` | |
**불변식**: 모든 error path 는 한국어 reason 을 포함. 모델이 이를 받아 사용자에게 안내.
## 7. 엣지케이스
- **공백 anchor**: trim 후 empty → null 로 변환.
- **catalog 이 DietCatalogItem 인데 frame_level 이 L3**: 통과. (diet 도 정체성 프레임 가능)
- **frame_level "l2" 소문자**: `FrameLevel.fromString` 이 정규화. 미지원 시 validation error.
- **중복 추가**: 같은 protocol_id + framed_text 로 재호출 — 별도 dedup 없음. R3 quota 가 3 째에서 차단. (OQ-1)
- **anchor_when 만 있고 anchor_after_what 없음**: 모두 허용 (어느 하나만 있어도 됨).
## 8. 복잡도 / 성능
- DB query 2회 (catalog byId + countActive) + 1회 write 트랜잭션.
- `framePatterns` 매번 로드 시 N (≈ 30) 패턴 keyword 매칭 — O(text_len × pattern_count). text ≤ 200자, patterns ≤ 30 → 무시 가능.
- 평균 < 100ms.
## 9. 테스트 케이스 (필수)
| 케이스 | 사전 | 입력 | 기대 |
|---|---|---|---|
| 정상 build | seed + 0 active | `{protocol_id: 'morning_sunlight', frame_level: 'L2', framed_text: '아침에 햇빛 보기'}` | ToolOk, habits row +1 |
| 정상 break | seed + 0 active | `{protocol_id: 'alcohol', ...}` | ToolOk, type=breakHabit |
| L0 reject | seed | `frame_level: 'L0'` | ToolErr('validation') |
| 미존재 protocol | seed | `protocol_id: 'no_such'` | ToolErr('not_found') |
| 회피 키워드 | seed | `framed_text: '술 끊기'` | ToolErr('r7_avoidance') |
| R3 quota (build 3개) | seed + build 3개 | 새 build 추가 | ToolErr('r3_quota') |
## 10. 의존
- `CatalogRepository.byId(id)`
- `HabitDao.countActive(...)`, `HabitDao.insertWithVariants(draft)`
- `judgeActiveHabitQuota(...)`
- `detectAvoidanceKeywords(text, patterns)`
- `framePatterns` (ToolDeps 에 주입)
- `FrameLevel`, `HabitType`, `HabitDraft`, `VariantDraft`

View File

@@ -0,0 +1,146 @@
# 함수 설계서: `ChatSessionController.userTurn` (#260)
> **부모 설계서**: ./README.md · **상태**: Draft
> **작성**: [AI] Architect · **구현**: `lib/state/chat_providers.dart:ChatSessionController.userTurn` · **테스트**: `test/state/chat_session_controller_test.dart`
## 1. 시그니처
```dart
class ChatSessionController extends StateNotifier<ChatSessionState> {
Future<void> userTurn(String text, BuildContext context);
}
class ChatSessionState {
final List<ChatMessage> messages; // append-only in-memory
final bool isStreaming; // 모델 응답 중 → 입력 disabled
final String? streamingText; // 부분 텍스트 누적
final String? error; // 마지막 에러 (null = OK)
}
```
## 2. 책임 (단일 책임, 1줄)
사용자 메시지를 받아 LLM ↔ Tool Dispatcher 의 multi-turn loop 을 돌리고, 메시지 상태를 갱신한다.
## 3. 입력
| 파라미터 | 타입 | 제약 | 설명 |
|---|---|---|---|
| `text` | String | non-empty after trim | 사용자가 입력한 자연어 |
| `context` | BuildContext | mounted | ConfirmGate 가 사용 |
## 4. 출력
- **반환**: `Future<void>`. 결과는 state.messages 와 streamingText 에 반영.
- **부수효과**:
- state 다중 갱신 (StateNotifier.state = ...)
- LLM 호출 (I/O)
- Tool dispatcher 호출 (DB write 가능)
- ConfirmGate 모달 표시 가능
## 5. 동작 / 알고리즘
```
1. 입력 validate:
if text.trim().isEmpty: return
if state.isStreaming: return // 중복 호출 차단
2. user 메시지 append:
state = state.copyWith(
messages: [...state.messages, UserChatMessage(text)],
isStreaming: true,
streamingText: '',
error: null,
)
3. tool registry 와 deps 준비:
tools = ToolRegistry.allDefinitions()
deps = ref.read(toolDepsProvider)
llm = ref.read(llmServiceProvider)
4. multi-turn loop (최대 MAX_TURNS=4 — tool 호출 chain 보호):
for (var turn = 0; turn < MAX_TURNS; turn++) {
stream = llm.sendChatTurn(
userInput: turn == 0 ? text : null, // 0 turn 만 user text, 이후는 tool result
toolResultToSubmit: turn == 0 ? null : pendingToolResult,
tools: tools,
)
toolCallToHandle = null
accumulatedText = ''
await for (event in stream) {
switch event:
case TextResponse(text):
accumulatedText += text
state = state.copyWith(streamingText: accumulatedText)
case FunctionCallResponse(name, args):
toolCallToHandle = (name, args)
break // 스트림 stop, tool 처리로 분기
case ThinkingResponse: skip
}
if toolCallToHandle == null:
// 모델이 자연어 응답으로 마무리
state = state.copyWith(
messages: [...state.messages, ModelChatMessage(accumulatedText)],
streamingText: null,
isStreaming: false,
)
return
// tool 처리
result = await ToolDispatcher.dispatch(
toolName: toolCallToHandle.name,
rawArgs: toolCallToHandle.args,
confirmContext: context,
deps: deps,
)
state = state.copyWith(
messages: [...state.messages,
ToolCallChatMessage(toolCallToHandle.name, toolCallToHandle.args, result)],
streamingText: '',
)
pendingToolResult = (toolCallToHandle.name, result.toJson())
}
// MAX_TURNS 초과 → 안전 종료
state = state.copyWith(
error: '도구 호출 루프가 너무 길어 중단했습니다.',
isStreaming: false,
streamingText: null,
)
```
## 6. 에러 & 실패 모드
| 조건 | 처리 | state |
|---|---|---|
| 빈 입력 | early return | unchanged |
| 동시 호출 (isStreaming) | early return | unchanged |
| LLM stream 예외 | catch | `error: 'LLM 응답 실패: ${e.type}'`, isStreaming:false |
| MAX_TURNS 초과 | safety break | `error: '...너무 길어...'` |
| tool result 직렬화 실패 (이론상 없음) | catch | tool ToolErr 대체 |
## 7. 엣지케이스
- **사용자가 stream 중 chat 화면 dismiss**: StateNotifier 가 dispose 되어도 진행 중인 `await` 는 계속됨. side effect (DB write) 가 이미 시작됐을 수 있으니 graceful 하게 무시 — `mounted` 체크로 state 갱신만 skip.
- **연속 tool 호출**: LLM 이 search_catalog → query_protocol → add_habit 같이 3 turn 돌 수 있음. MAX_TURNS=4 가 안전망. AC 시나리오 대부분 1~2 turn 종료.
- **tool 호출 후 LLM 이 또 같은 tool 호출 (loop)**: MAX_TURNS 가 차단. 추가로 핸들러는 idempotent 결과 반환하지만 R3 quota 등이 2번째 호출에서 차단.
- **chat history 8 turn 초과**: 현재 turn 끝나면 "지난 대화를 정리할까요?" 안내 메시지 (`SystemChatMessage`) 자동 append. clear 는 사용자 액션.
- **모달 confirm 대기 중 사용자가 화면 dismiss**: ConfirmGate 내부 mounted 가드가 false 반환 → ToolCancelled → loop 계속 (또는 LLM 이 마무리).
## 8. 복잡도 / 성능
- per-turn: LLM round trip (~2~5초 E2B) + handler (<100ms).
- 총 latency: 2~3 turn 으로 끝나는 시나리오 평균 5~10 초.
- 메모리: messages 리스트가 메모리 누적. clear 안 하면 무한 — 단 chat history persist X 이므로 앱 종료 시 GC.
## 9. 테스트 케이스 (필수)
Mock LLM 으로 시뮬레이션. 실 모델 호출 안 함.
| 케이스 | LLM mock 시퀀스 | 기대 state |
|---|---|---|
| 자연어 응답만 | `[TextResponse('안녕!')]` | messages = [user, model], isStreaming=false |
| 1 tool call + 응답 | `[FunctionCallResponse('search_catalog', {category:'sleep'})]` → tool result → `[TextResponse('카페인 protocol...')]` | messages = [user, toolCall, model] |
| destructive cancel | `add_habit` call → ConfirmGate mock false | toolCall message 의 result = ToolCancelled |
| MAX_TURNS 초과 | LLM 이 매번 tool call | error 세팅, 안전 종료 |
| 중복 호출 차단 | isStreaming=true 일 때 userTurn 재호출 | early return, state unchanged |
## 10. 의존
- `LlmService.sendChatTurn(...)` (확장 인터페이스)
- `ToolDispatcher.dispatch(...)`
- `ToolRegistry.allDefinitions()`
- `ToolDeps` (toolDepsProvider)
- Flutter `BuildContext.mounted`

View File

@@ -0,0 +1,106 @@
# 함수 설계서: `ConfirmGate.show` (#260)
> **부모 설계서**: ./README.md · **상태**: Draft
> **작성**: [AI] Architect · **구현**: `lib/ai/tools/confirm_gate.dart:show` · **테스트**: `test/ui/confirm_gate_test.dart`
## 1. 시그니처
```dart
class ConfirmGate {
static Future<bool> show(
BuildContext context,
ToolDefinition tool,
Map<String, dynamic> args,
);
}
```
## 2. 책임 (단일 책임, 1줄)
destructive tool 실행 직전 모달 AlertDialog 을 띄워 사용자 confirm 여부를 `Future<bool>` 로 반환한다.
## 3. 입력
| 파라미터 | 타입 | 제약 | 설명 |
|---|---|---|---|
| `context` | BuildContext | `context.mounted == true` | chat screen 의 context |
| `tool` | ToolDefinition | isDestructive=true | 어떤 도구인가 |
| `args` | Map<String, dynamic> | 이미 validate 통과 | 사용자에게 보여줄 인자 |
## 4. 출력
- **반환**: `Future<bool>`
- `true` = 사용자가 "수행" 탭
- `false` = 사용자가 "취소" 또는 outside-tap dismiss 또는 `context.mounted == false`
- **부수효과**: 모달 표시 (UI). DB 변경 없음.
## 5. 동작 / 알고리즘
```
1. if !context.mounted:
return false
2. summary = _summarize(tool.name, args)
// tool 별 사람 친화 요약 함수 (per-tool overridable)
3. result = await showDialog<bool>(
context: context,
barrierDismissible: true, // outside-tap = 취소
builder: (ctx) => AlertDialog(
title: Text('이 작업을 수행할까요?'),
content: Column(mainAxisSize: min, crossAxisAlignment: start, children: [
Text(tool.description, style: bodyMedium),
SizedBox(height: 12),
Container(
padding: EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.surfaceVariant,
borderRadius: BorderRadius.circular(8),
),
child: Text(summary),
),
]),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text('취소')),
FilledButton(
autofocus: true,
onPressed: () => Navigator.pop(ctx, true),
child: Text('수행'),
),
],
),
)
4. return result ?? false // dismiss 시 null → false
```
### `_summarize` 규칙 (tool 별)
- `add_habit` → "프로토콜 '$title'을 ${frame_level} 프레임으로 새 습관으로 추가합니다.\n • 문장: \"$framed_text\"\n • 앵커: ${anchor_when ?? '-'} / ${anchor_after_what ?? '-'}"
- `log_tracker_entry` → "$habit_title 의 ${date ?? '오늘'} 기록을 '${value == 'done' ? '완료' : '공란'}' 으로 저장합니다."
- 기타 → JSON pretty (fallback)
`title` 은 핸들러가 호출 직전 lookup 해서 args 에 채워줄 수 있지만, 단순화를 위해 ConfirmGate 가 직접 catalog/habit 조회는 안 함 — args 에 이미 있는 값만 사용. (안 채워졌으면 protocol_id 그대로 노출 — 트레이드오프 수용.)
## 6. 에러 & 실패 모드
| 조건 | 처리 | 반환 |
|---|---|---|
| context dispose 후 호출 | guard 즉시 | `false` |
| showDialog 자체 예외 (이론상 없음) | rethrow X — catch 후 false 반환 | `false` |
| args 가 _summarize 가 기대 안 한 형태 | toString fallback | 정상 동작 (dialog 노출) |
## 7. 엣지케이스
- **chat 모달 위에 chat 모달**: 동시에 호출되지 않도록 ChatSessionController 가 직렬화 (한 turn = 한 tool call 만). 다중 destructive tool 병렬 호출 시 첫 confirm 만 처리, 나머지는 `ToolCancelled` 자동 반환 (OQ-1 영향).
- **시스템 back press**: Android 뒤로가기 → dialog dismiss → false. 의도된 cancel.
- **autofocus + 키보드 enter**: 수행 버튼 기본 포커스. 의도치 않은 enter 누름 위험 — 사용자 결정으로 수용 (단축키 활용성 ↑).
## 8. 복잡도 / 성능
- O(1). 사용자 대기 시간 = 무한 (사용자 입력 대기).
- 호출 빈도: 사용자 대화 turn 당 0 또는 1.
## 9. 테스트 케이스 (필수)
| 케이스 | 셋업 | 입력 | 기대 |
|---|---|---|---|
| confirm | MaterialApp + 모달 진입 → "수행" tap | add_habit args | true |
| cancel | "취소" tap | 동일 | false |
| outside dismiss | barrier tap | 동일 | false |
| unmounted context | context dispose 후 호출 | — | false 즉시 |
## 10. 의존
- Flutter `showDialog`, `AlertDialog`
- `ToolDefinition` (description 출처)
- per-tool summary 규칙은 별도 함수로 분리 (`_summarize(toolName, args)`) — 단위 테스트 용이.

View File

@@ -0,0 +1,100 @@
# 함수 설계서: `ToolDispatcher.dispatch` (#260)
> **부모 설계서**: ./README.md · **상태**: Draft
> **작성**: [AI] Architect · **구현**: `lib/ai/tools/tool_dispatcher.dart:dispatch` · **테스트**: `test/ai/tools/tool_dispatcher_test.dart`
## 1. 시그니처
```dart
Future<ToolResult> dispatch({
required String toolName,
required Map<String, dynamic> rawArgs,
required BuildContext? confirmContext, // null 이면 destructive tool 자동 cancel
required ToolDeps deps,
});
```
`ToolDeps` = `{ HabitDao habitDao, TrackerDao trackerDao, CatalogRepository catalog, String userId }`.
## 2. 책임 (단일 책임, 1줄)
`toolName` 으로 핸들러를 찾아, args 검증 → Confirm gate → 핸들러 호출 → 결과를 envelope 으로 감싸 반환한다.
## 3. 입력
| 파라미터 | 타입 | 제약/검증 | 설명 |
|---|---|---|---|
| `toolName` | String | non-empty, registry 에 등록된 이름 | LLM 의 `FunctionCallResponse.name` |
| `rawArgs` | Map<String, dynamic> | 어떤 타입이든 — 검증은 내부에서 | LLM 의 `FunctionCallResponse.args` |
| `confirmContext` | BuildContext? | 살아있는 widget context | destructive 가 아니면 무관. null + destructive = 자동 cancel |
| `deps` | ToolDeps | non-null | 핸들러가 호출할 Repository 묶음 |
## 4. 출력
- **반환**: `Future<ToolResult>``ToolOk` / `ToolErr` / `ToolCancelled`. 절대 throw 하지 않음.
- **부수효과**:
- Confirm gate 호출 시 모달 표시 (UI side effect)
- 핸들러 내부에서 DB write 가능 (destructive 인 경우만)
## 5. 동작 / 알고리즘
```
1. tool = ToolRegistry.byName(toolName)
if tool == null:
return ToolErr(code: 'unknown_tool', reason: '알 수 없는 도구: $toolName')
2. validatedArgs = ToolArgsValidator.validate(tool.parametersSchema, rawArgs)
if validatedArgs is ValidationError:
return ToolErr(code: 'validation', reason: '인자 오류: ${err.message}')
3. if tool.isDestructive:
if confirmContext == null:
return ToolCancelled()
ok = await ConfirmGate.show(confirmContext, tool, validatedArgs)
if !ok:
return ToolCancelled()
4. try:
payload = await tool.handler(validatedArgs, deps)
// handler 가 이미 ToolResult 를 반환하는 형태이므로 passthrough
return payload
catch (e, st):
log('tool_error', tool=$toolName, err=$e)
return ToolErr(code: 'handler_error', reason: '도구 실행 실패: ${e.runtimeType}')
```
## 6. 에러 & 실패 모드
| 조건 | 처리 | 반환 |
|---|---|---|
| `toolName` 미등록 | log warn | `ToolErr('unknown_tool', ...)` |
| `rawArgs` schema 위배 | log info | `ToolErr('validation', ...)` |
| destructive + confirmContext null | 조용히 | `ToolCancelled()` |
| 사용자 모달 거부 | 조용히 | `ToolCancelled()` |
| 핸들러 예외 | log error + stacktrace | `ToolErr('handler_error', ...)` — 사용자 메시지엔 타입만 |
| 핸들러가 R 규칙 위배 detect | 핸들러 자체에서 반환 | passthrough `ToolErr('r3_quota', ...)` 등 |
**불변식**: dispatch 는 throw 하지 않는다. 모든 실패 경로는 ToolResult 로 환원.
## 7. 엣지케이스
- **빈 args**: `{}` 가 들어와도 schema 가 required 필드 검증으로 잡음.
- **redundant args** (스키마에 없는 키): 무시 — 모델이 환각해도 통과시키되 로깅.
- **모달 race**: confirmContext 가 dispatch 호출 후 dispose 되는 경우 → `ConfirmGate` 내부에서 `context.mounted` 체크 후 false 반환.
- **dispatch 중 사용자가 chat 화면 dismiss**: 핸들러는 계속 실행됨 (취소 안 함). 결과는 폐기되지만 DB write 는 commit 된 채 남음. ChatSessionController 가 lifecycle 책임짐 (Architect 결정: side effect 보존 = 사용자가 의도적으로 chat 닫았다고 가정).
## 8. 복잡도 / 성능
- O(1) registry lookup (`Map<String, ToolDefinition>`).
- args validate ≤ 50자 keyword 등 small payload — O(n) JSON schema 매칭.
- 호출 빈도: 사용자 대화 turn 당 0~N (보통 0 또는 1). 폴링 루프 아님.
- 메모리: stateless — instance 변수 없음.
## 9. 테스트 케이스 (필수)
| 케이스 | 입력 | 기대 |
|---|---|---|
| unknown tool | `dispatch('foo', {}, ...)` | `ToolErr('unknown_tool', ...)` |
| validation fail | `dispatch('add_habit', {'protocol_id': 123}, ...)` | `ToolErr('validation', ...)` (123 is int not string) |
| destructive + null context | `dispatch('add_habit', validArgs, null, ...)` | `ToolCancelled()` |
| destructive + user accept | mock ConfirmGate → true | handler 결과 그대로 |
| destructive + user reject | mock ConfirmGate → false | `ToolCancelled()` |
| handler throw | mock handler throws | `ToolErr('handler_error', ...)` |
| read-only normal | `dispatch('search_catalog', validArgs, null, ...)` | `ToolOk(data:...)` |
## 10. 의존
- `ToolRegistry` (정적 lookup)
- `ToolArgsValidator` (JSON schema validator — 간단 자체 구현 권장)
- `ConfirmGate.show` (UI)
- `ToolDeps` 내부의 Repository 들 (각 핸들러가 사용)

View File

@@ -0,0 +1,244 @@
# 설계서: ChatScreen LLM warm-up (#311)
> **상태**: Approved (v0.4.1, 2026-06-15)
> **작성**: [AI] Architect · **최종수정**: 2026-06-15 (08-Documenter 마감)
> **추적성** — Redmine: #311 · 관련 ADR: 없음 (Backward-compatible 추가) · Release: v0.4.1 (`121108f`)
> · 구현 파일: `app/lib/state/chat_warmup_provider.dart` (신규) · `app/lib/data/ai/llm_service.dart` (수정) · `app/lib/data/ai/model_lifecycle.dart` (`quickCheck` 추가) · `app/lib/data/ai/gemma_llm_service.dart` (concurrent load guard) · `app/lib/ui/screens/chat_screen.dart` (warmup binding)
> · 테스트: `app/test/state/chat_warmup_test.dart` (신규) · `app/test/data/ai/model_lifecycle_test.dart` (quickCheck 케이스 추가) · `app/test/ui/chat_screen_test.dart` (warmup 라벨/disabled 케이스 추가)
## 1. 목적 (Why)
> Planner 목표 인용: ChatScreen 진입 시 LLM 모델을 백그라운드로 warm-up 하여, 첫 send 의 perceived latency 에서 cold load (수 초) 를 제거한다.
현재 `chat_providers.dart:131``llm.load()` 가 첫 `userTurn` 시점에 lazy 실행된다. Gemma 4 E2B 의 native runtime 초기화 + `installModel.fromFile().install()` + `getActiveModel(maxTokens: 2048)` 가 합쳐 수 초가 걸려, 사용자는 첫 메시지 send 직후 빈 화면을 본다. 본 이슈는 그 비용을 사용자 입력 전(ChatScreen mount 시점) 으로 이동시킨다.
## 2. 범위 (Scope)
- **포함**:
- ChatScreen mount 시 백그라운드 `llm.load()` 트리거.
- 로드 상태(idle/loading/ready/failed/unavailable) 노출 + 입력창 binding.
- `GemmaLlmService.load()` / `MockLlmService.load()` 의 concurrent-call 가드.
- `ModelLifecycle.quickCheck()` — SHA-256 해싱 없이 ready 여부 추정 (warmup gate 전용).
- Widget 테스트 (loading 라벨 노출 → 완료 → 사라짐).
- **제외 (out of scope)**:
- `HabitCreateScreen` 의 AI 제안 (frame suggestion) warm-up — 동일 패턴 필요 시 별도 후속 이슈.
- 다운로드 자체 진행률 UI — 이미 SettingsScreen 에 존재 (#218).
- `#219` idle auto-unload 구현 — 본 이슈는 entry point 만 정의.
- 추론 자체 (KV-cache warm 등) 의 추가 최적화 — `load()` 호출까지만.
## 3. 인수조건 (Acceptance Criteria)
> Planner AC 8개 + UX-Reviewer 신규 AC 4개 (UX-REVIEW.md 흡수). QA 가 이걸로 판정.
- [ ] **AC1** ChatScreen mount → background `llm.load()` 시작. 이미 `isLoaded` 면 no-op.
- [ ] **AC2** `ModelLifecycle.quickCheck() != ready` 일 때 warmup 시도하지 않음. 다운로드 미완 / opt-out / corrupt 상태에서 spurious load 방지.
- [ ] **AC3** 로드 진행 중 입력창은 `enabled: true` 유지 (사용자가 메시지를 미리 작성할 수 있음). send 버튼만 disabled + `CircularProgressIndicator(strokeWidth:2)`. 입력창 `hintText``"AI 준비 중… 첫 시작은 몇 초 걸려요"` 로 교체. ← UX R1+R3 흡수.
- [ ] **AC4** 로드 완료 시 send 버튼 활성, `hintText` 가 평상시 `"습관 추가, 기록, 카탈로그 질문…"` 로 복귀. 첫 send 가 cold load 비포함 수준 latency 로 응답.
- [ ] **AC5** 로드 실패 시 기존 chat_screen error container 재사용. **메시지는 상태만 기술**, 행동은 별도 `OutlinedButton('다시 시도')` 가 담당 (error container 내부, 우측 정렬). ← UX R5+R6 흡수.
- [ ] **AC6** Warmup 진행 중 사용자가 ChatScreen 을 떠나도 race / leak 없음. autoDispose StateNotifier + `_disposed` 가드.
- [ ] **AC7** `ChatSessionController.userTurn` 의 lazy load 와 백그라운드 warmup 이 동시 호출되어도 안전. `GemmaLlmService._loadingFuture` 가드로 중복 native init 차단.
- [ ] **AC8** Widget 테스트:
- delay mock → spinner + hintText 교체 노출 → 완료 후 send 활성 + hintText 복귀.
- error mock → error container + [다시 시도] 버튼 노출 + 클릭 시 retry.
- `quickCheck() = missing` → warmup 시도 안 함 + UI 변화 없음 (첫 send 시 기존 lazy 경로).
- [ ] **AC9** (UX R1+R2) Warmup 중 입력창은 타이핑 가능. send 만 disabled.
- [ ] **AC10** (UX R2) Warmup ready 전이 시점에 입력창에 비어있지 않은 텍스트가 있으면 send 자동 활성화. 자동 send 는 X.
- [ ] **AC11** (UX R4) `isLoaded=true` 인 재진입 시 Loading state 가 1 frame 이라도 노출되지 않음 (위젯 테스트로 verify).
- [ ] **AC12** (UX R5) 실패 메시지 본문에 "다시 시도해주세요" 같은 명령형 문구 금지. 행동은 버튼이 담당.
## 4. 컨텍스트 & 제약
- **의존성**:
- `LlmService` (`load`/`isLoaded`/`startChat`) — 기존 인터페이스 유지.
- `ModelLifecycle` (`checkAvailability` 기존, `quickCheck` 신규) — meta_kv DAO 의존.
- Riverpod (`StateNotifier`, `FutureProvider`, `autoDispose`).
- **제약**:
- `flutter_gemma 0.16.5``FlutterGemma.initialize` 는 isolate 당 1회 (`_initialized` 가드 있음). `installModel` + `getActiveModel` 은 idempotent 가 아니다 — 두 번째 호출 시 동작 미정의. **concurrent load 가드 필수**.
- `ModelLifecycle.checkAvailability()` 가 SHA-256 ~2.4GB 해싱을 포함. ChatScreen mount 마다 호출하면 비용 과대. quickCheck 분리.
- autoDispose StateNotifier 라이프사이클: ChatScreen pop 시 dispose 호출, 진행 중 future 가 unmounted state 변경 시도하면 안 됨.
- **가정**:
- `GemmaLlmService.load()` 비용 ≈ native init + mmap + `getActiveModel`. KV-cache warm 은 첫 inference 시 발생 (별도 트랙). → R3 해소.
- 사용자가 SettingsScreen 에서 모델을 ready 상태로 만든 적이 있다 (meta_kv 의 `ai_model_sha256` 가 채워져 있다). 그렇지 않으면 quickCheck=missing → warmup skip → 기존 lazy 경로 fallback.
## 5. 아키텍처 개요
- **모듈/파일**:
- 신규: `app/lib/state/chat_warmup_provider.dart``ChatWarmupController` (StateNotifier) + `chatWarmupProvider`.
- 수정: `app/lib/data/ai/llm_service.dart``MockLlmService._loadingFuture` 가드 추가.
- 수정: `app/lib/data/ai/gemma_llm_service.dart``_loadingFuture` 가드 추가.
- 수정: `app/lib/data/ai/model_lifecycle.dart``quickCheck()` 메서드 추가.
- 수정: `app/lib/ui/screens/chat_screen.dart``initState` 에서 warmup 트리거, body 에 상태 binding.
- **데이터 흐름**:
```
ChatScreen.initState
└─> ref.read(chatWarmupProvider.notifier).start()
├─> meta_kv 의 ai_opt_in / ai_model_path / ai_model_sha256 조회 (ModelLifecycle.quickCheck)
│ ├─> ready 아님 → state = unavailable, 종료 (기존 lazy 경로 fallback)
│ └─> ready
├─> state = loading
├─> llm.load() ── concurrent 가드 (_loadingFuture 공유) ──┐
│ ↓
│ ChatSessionController.userTurn 의 llm.load() 호출 시 동일 future 반환
├─> 성공 → state = ready
└─> 실패 → state = failed(message)
ChatScreen.build (Consumer)
├─> warmup.state == loading
│ ├─ 입력창: enabled:true, hintText="AI 준비 중… 첫 시작은 몇 초 걸려요"
│ └─ send: disabled + spinner
├─> warmup.state == failed
│ ├─ error container: 상태 메시지만
│ └─ [다시 시도] OutlinedButton (container 내부, 우측 정렬) → controller.retry()
├─> warmup.state == ready
│ ├─ 입력창: hintText="습관 추가, 기록, 카탈로그 질문…" (평상)
│ └─ send: 텍스트 비어있지 않으면 즉시 활성 (AC10)
├─> warmup.state == unavailable → 정상 입력창 (warmup 라벨 X, 첫 send 시 lazy 경로)
└─> warmup.state == idle (lifecycle race) → 정상 입력창 (안전 기본값)
```
- **I/O ↔ 순수 로직 경계**:
- I/O: `ModelLifecycle.quickCheck()` (meta_kv read + file existsSync), `LlmService.load()` (native init).
- 순수: `ChatWarmupController` 의 state 전이는 의존성 주입된 함수만 호출 — 단위 테스트로 모든 분기 검증.
## 6. 데이터 모델
### `ChatWarmupState` (sealed, `chat_warmup_provider.dart`)
```dart
sealed class ChatWarmupState { const ChatWarmupState(); }
final class ChatWarmupIdle extends ChatWarmupState { const ChatWarmupIdle(); }
final class ChatWarmupLoading extends ChatWarmupState { const ChatWarmupLoading(); }
final class ChatWarmupReady extends ChatWarmupState { const ChatWarmupReady(); }
final class ChatWarmupFailed extends ChatWarmupState {
final String message;
const ChatWarmupFailed(this.message);
}
/// quickCheck != ready — warmup 자체를 시도하지 않은 상태.
/// UI 는 정상 입력창 표시 (첫 send 시 기존 lazy 경로).
final class ChatWarmupUnavailable extends ChatWarmupState { const ChatWarmupUnavailable(); }
```
### `ModelLifecycle.quickCheck()` 반환 타입
- 재사용: 기존 `ModelAvailability` enum (`ready` / `missing` / `corrupt` / `downloading`).
- 차이: SHA-256 재해싱을 건너뛴다. 파일 존재 + meta_kv 의 `ai_model_path` / `ai_model_sha256` 가 모두 채워져 있으면 `ready` 로 간주. 손상 감지는 `checkAvailability()` (cold 경로) 에 위임.
### 입력창 binding (UX 흡수)
- `chat_screen.dart` 의 TextField `enabled` = `state.isStreaming == false`.
- **warmupState 와 무관** — 사용자가 warmup 중에도 메시지를 미리 작성 가능 (UX R1).
- TextField `hintText`:
- `warmupState is ChatWarmupLoading``"AI 준비 중… 첫 시작은 몇 초 걸려요"`
- 그 외 → `"습관 추가, 기록, 카탈로그 질문…"`
- send 버튼: `state.isStreaming || warmupState is ChatWarmupLoading || textIsEmpty` 면 disabled. Loading 일 때는 spinner 표시.
- send 자동 활성 (AC10): warmup 가 ready 로 전이될 때 입력창 텍스트가 비어있지 않으면 send 가 자동으로 enabled 로 바뀜 (텍스트 controller listener 가 이미 처리하므로 별도 코드 거의 없음).
### 마이크로카피 사전 (UX-Reviewer 채택본)
| 상태 | 한국어 라벨 | 위치 |
|---|---|---|
| warmup loading | hintText: `AI 준비 중… 첫 시작은 몇 초 걸려요` | 입력창 |
| warmup ready | hintText: `습관 추가, 기록, 카탈로그 질문…` | 입력창 (기존 유지) |
| warmup unavailable | (라벨 변경 없음) | — |
| warmup failed (file missing) | error container 본문: `AI 모델 파일을 찾을 수 없어요.` + [설정으로 가기] | error container |
| warmup failed (other) | error container 본문: `AI 를 시작하지 못했어요.` + [다시 시도] | error container |
## 7. 함수 명세 (Function Specs)
| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? |
|------|-----------|----------------|------|------|-----------|-------|
| `ChatWarmupController.start` | 모델 ready 체크 → load 호출 → state 전이 | `Future<void> start()` | (deps via ctor) | `void` | failed → `ChatWarmupFailed(msg)` | **복잡** (fn-chat_warmup_controller.md) |
| `ChatWarmupController.retry` | failed/idle 에서 start 재호출 | `Future<void> retry()` | — | `void` | (start 동일) | 단순 |
| `ModelLifecycle.quickCheck` | SHA 해싱 없이 meta_kv + file existence 만으로 ready 추정 | `Future<ModelAvailability> quickCheck()` | (this.meta) | `ModelAvailability` | DB 예외 → `corrupt` (보수적) | 단순 |
| `GemmaLlmService.load` (수정) | concurrent 호출 시 같은 Future 반환 | `Future<void> load()` | — | `void` | (기존 동일) | **복잡** (fn-concurrent_load_guard.md) |
| `MockLlmService.load` (수정) | 동일한 concurrent 가드 적용 (테스트 일관성) | `Future<void> load()` | — | `void` | (기존 동일) | 단순 |
| `_ChatScreenState.initState` (수정) | mount 시 `chatWarmupProvider.notifier.start()` 호출 | `void initState()` | — | `void` | (controller 가 흡수) | 단순 |
| `_ChatScreenState._buildInputRow` (신규 추출) | warmup 상태 ↔ TextField/send 버튼 binding | `Widget _buildInputRow(ChatWarmupState, ChatSessionState)` | states | `Widget` | — | 단순 |
> 복잡 함수 2 개 → 개별 `fn-*.md`. 단순 함수는 본 표로 충분.
## 8. 흐름 / 알고리즘
### Happy path (사용자가 SettingsScreen 에서 옵트인 + 다운로드 완료한 상태)
1. 사용자가 `HabitListScreen` AppBar 의 🤖 탭 → ChatScreen push.
2. `initState``chatWarmupProvider.notifier.start()`.
3. `quickCheck()` 반환 `ready` → state = `ChatWarmupLoading`.
4. UI rebuild → 입력창 자리에 "AI 준비 중…" + spinner. send 버튼 영역에는 작은 spinner.
5. `llm.load()` 백그라운드 진행 (수 초). 그동안 사용자는 메시지 입력 불가능.
6. `load()` 성공 → state = `ChatWarmupReady` → UI rebuild → 정상 입력창.
7. 사용자 send → `ChatSessionController.userTurn` 내부 `llm.load()``isLoaded` 체크로 즉시 통과 → 곧바로 inference.
### quickCheck 가 ready 가 아닌 경우 (다운로드 미완 / opt-out / first-run)
1. `quickCheck()` 반환 `missing`/`downloading`/`corrupt` → state = `ChatWarmupUnavailable`.
2. UI 는 정상 입력창 (warmup 라벨 X). 사용자 send 시 기존 lazy `userTurn` 경로 → `llm.load()` 가 호출되면 어차피 `FileSystemException('model file missing')` 등으로 실패 → 기존 chat_screen error container 에 표시.
3. 즉, **warmup 은 "사용자가 이미 옵트인+다운로드 완료한 케이스" 만 최적화**. 다른 케이스는 기존 동작 유지 (변화 없음).
### Concurrent load
1. ChatScreen mount → warmup → `llm.load()` (Future A 진행 중).
2. (race) 사용자가 매우 빠르게 send → `userTurn` 내부 `llm.load()` 호출.
3. `GemmaLlmService.load()` 내부 `_loadingFuture != null` 이면 **그 future 를 반환**. native init 중복 X.
4. Future A 완료 시 두 caller 모두 정상 진행.
### Failure + retry
1. `load()` 가 throw (예: native init 실패, 파일 권한 변경, OOM) → catch.
2. state = `ChatWarmupFailed("AI 모델 준비에 실패했어요. 다시 시도해주세요.")` + 내부 error code 로깅 (사용자 노출 X).
3. UI: error container + `OutlinedButton('다시 시도')`. 탭 → `controller.retry()`.
4. retry = 단순히 state = `ChatWarmupIdle` 로 reset 후 `start()` 재호출.
### Unmount race
1. `start()` 진행 중 사용자가 back 버튼 → ChatScreen.dispose() → autoDispose → controller.dispose().
2. dispose() 에서 `_disposed = true` 플래그.
3. start() 의 `await llm.load()` 완료 후 `if (_disposed) return;` 가드 → state 변경 시도 skip.
## 9. 엣지케이스 & 에러 처리
| 케이스 | 처리 |
|---|---|
| `meta_kv` DB 가 lock / 손상 | `quickCheck` catch → `corrupt` 반환 → state = `ChatWarmupUnavailable` (warmup skip). 첫 send 시 정상 에러 경로. |
| `LlmService.load()` 가 throw (FileSystemException) | state = `ChatWarmupFailed`. 메시지: "AI 모델 파일을 찾을 수 없어요. 설정에서 다시 다운로드해주세요." |
| `load()` 가 throw (Native init 실패 — OOM / 런타임 호환성) | state = `ChatWarmupFailed`. 메시지: "AI 시작 중 오류가 발생했어요. 잠시 후 다시 시도해주세요." |
| concurrent load — 두 caller 동시 호출 | `_loadingFuture` 가드로 단일 future 공유. 두 caller 모두 완료 시점에 unblocked. |
| 사용자가 warmup 중 ChatScreen 떠남 | `_disposed` 가드 → state 변경 skip. 메모리는 native runtime 이 보유 (다음 진입 시 `isLoaded=true` → no-op). |
| AI opt-in 이 false 인 상태에서 ChatScreen 직접 진입 (불가능한 케이스 — 🤖 아이콘 자체가 hidden) | 안전 기본값으로 quickCheck=missing → unavailable. |
| ChatScreen 재진입 (앞서 load 됨) | `isLoaded=true``llm.load()` 즉시 return → state = ready 빠르게 전이 (사용자 인지 어려운 수 ms). 라벨 깜빡임 방지 위해 — **race 처리**: state 초기값을 `ChatWarmupIdle` 로 두고, `start()` 가 quickCheck 직후 isLoaded 체크해서 이미 loaded 면 곧바로 `ChatWarmupReady` (Loading 단계 skip). |
## 10. 테스트 계획
| 테스트 | 케이스 | AC mapping |
|---|---|---|
| `chat_warmup_test.dart``start() happy` | quickCheck=ready + load delay 100ms → state 시퀀스 [Idle → Loading → Ready] | AC1, AC3, AC4 |
| `chat_warmup_test.dart``start() skip when already loaded` | isLoaded=true → state 시퀀스 [Idle → Ready] (Loading 없음) | AC1 |
| `chat_warmup_test.dart``start() unavailable` | quickCheck=missing → state = Unavailable, load 호출 안 됨 | AC2 |
| `chat_warmup_test.dart``start() failure` | load throws → state = Failed(msg) | AC5 |
| `chat_warmup_test.dart``retry after failure` | Failed → retry() → Loading → Ready | AC5 |
| `chat_warmup_test.dart``unmount race` | start() 진행 중 dispose() → state 변경 시도 skip | AC6 |
| `chat_warmup_test.dart``concurrent load shares future` | start() + userTurn 시뮬 동시 → load 1회만 호출 | AC7 |
| `model_lifecycle_test.dart``quickCheck ready` (신규) | meta_kv 채워짐 + 파일 존재 → ready (SHA 안 함) | AC2 |
| `model_lifecycle_test.dart``quickCheck missing` (신규) | 파일 없음 → missing | AC2 |
| `chat_screen_test.dart``warmup loading label` (신규) | delay mock → "AI 준비 중…" 라벨 + spinner 노출 | AC3 |
| `chat_screen_test.dart``warmup ready hides label` (신규) | 완료 후 라벨 사라지고 send 활성 | AC4 |
| `chat_screen_test.dart``warmup failed shows retry` (신규) | error mock → error container + 재시도 버튼 | AC5 |
> 모킹 전략: `MockLlmService` 에 `loadDelay` / `loadThrows` 필드 추가 (테스트 helper). `ModelLifecycle` 은 in-memory `MetaDao` + `MemoryFileSystem` 패턴 (기존 `model_lifecycle_test.dart` 의 fake storage 재사용).
## 11. 리스크 & 대안 검토
| 리스크 | 대안 | 선택 | 근거 |
|---|---|---|---|
| **R1: concurrent load race** | (a) controller-level coordination (b) service-level `_loadingFuture` guard | **(b)** | frame suggestion 등 다른 caller 도 보호. service 가 진실의 원천. |
| **R3: load() 비용 정의** | (a) load = mmap only (b) load = mmap + dummy inference (KV-cache warm) | **(a)** | 코드 확인 결과 현재 `load()` = native init + mmap + getActiveModel. KV-cache warm 은 첫 inference 시 발생. (b) 는 별도 트랙 (#312 이슈와 묶일 수 있음). |
| **R4: SHA-256 재해싱 비용** | (a) quickCheck 메서드 신설 (file existence + meta_kv 만) (b) checkAvailability 결과를 Riverpod 캐시 | **(a)** | (b) 는 무효화 시점 (다운로드 완료/재시작) 관리 부담. (a) 는 명시적 의도 표현 + SHA 검증은 SettingsScreen 의 cold path 에 남김. |
| **R-extra: warmup 비용이 너무 커서 사용자가 chat 안 쓸 때도 GPU/RAM 점유** | (a) ChatScreen 진입 시 warmup (이 설계) (b) HabitListScreen 🤖 hover/long-press 시 (c) opt-in tier (사용자 선택) | **(a)** | (b) 모바일 hover 없음. (c) 옵션 폭증. ChatScreen 진입 = "사용자가 곧 쓸 의도 명시" 의 가장 강한 신호. |
> ADR 분리 안 함: 모든 결정이 backward-compatible 추가. `LlmService` 인터페이스 변경 없음, `ModelLifecycle.quickCheck` 도 추가 메서드. 되돌리기 어렵지 않음.
## 12. 미해결 질문 (Open Questions)
> UX-Reviewer 패스로 모두 해소. 본 섹션은 의도적으로 비어 있음.
- ~~OQ-1 microcopy 결정~~ → UX R3 채택, 마이크로카피 사전 §6 으로 이관.
- ~~OQ-2 재시도 버튼 위치~~ → UX R6 채택, error container 내부 우측 정렬.
- ~~OQ-3 재진입 깜빡임~~ → UX R4 endorse, fn-spec 의 빠른 경로로 Loading skip. min display time 같은 인위 지연은 금지 (안티패턴).
## 13. UX 리뷰 흡수 노트
- 본 설계서는 UX-REVIEW.md 의 Strong 4건 (R1, R2, R4, R5) 모두 채택, Suggest 2건 (R3, R6) 채택.
- 신규 AC4건 (AC9-AC12) 통합.
- 마이크로카피 사전 §6 으로 이관.
- 다음 페르소나 (03-Developer) 는 README 만 보면 충분. UX-REVIEW.md 는 결정 과정의 기록 으로 보존.
## 14. 참조
- Planner 산출물: Redmine #311 `## [AI] Planner` 섹션.
- 관련 follow-up: #219 (idle auto-unload), #220 (purge try/catch — 본 이슈의 concurrent load guard 와 동일 정신).
- 기존 설계: `docs/design/218-gemma-real-integration/`, `docs/design/260-gemma-tool-calling/`.

View File

@@ -0,0 +1,142 @@
# UX 리뷰: ChatScreen LLM warm-up (#311)
> **검토**: [AI] UX-Reviewer · **대상**: `./README.md` v1 + Planner AC 8개 · **날짜**: 2026-06-15
> **위치**: 02-Architect 단계 내 parallel review. Architect 가 흡수 후 03-Developer 인계.
## 요약
설계서가 백엔드 흐름 (state 머신, concurrent guard, lifecycle) 은 견고하다. 그러나 사용자가 실제로 보는 표면 — 라벨 톤, spinner 위치, 빠른 전이 시 깜빡임, 실패 시 다음 행동 — 에는 **결정 안 된 것이 너무 많고 (OQ 3건), 결정된 부분도 마찰을 만들 위험**이 있다. 6가지 권고를 아래에 정리.
---
## [Strong] R1 — "AI 준비 중…" 을 입력창 자리에 박으면 안 됨
**관점**: 마찰 / 흐름 / 정신 모델
**근거**:
- README §5 / §6 은 "입력창 자리에 라벨 + spinner" 를 기본안으로 둠.
- 그러면 **사용자가 메시지를 미리 타이핑해두는 행동 자체가 차단**된다. 사용자는 모델 로드를 기다리는 동안에도 "어떤 질문을 할지" 머릿속에서 정리하면서 손가락은 이미 키보드 위에 있다.
- 입력창이 사라지면 사용자는 "왜 안 보이지?" 하고 한 번 더 추론해야 한다 (마찰 +1).
- 더 큰 문제: ChatScreen 의 ListView 영역이 비어 있는 첫 진입 시점에 입력창까지 사라지면 **화면 전체가 spinner 하나뿐**이 된다 — "이 앱이 멈췄나?" 시그널.
**제안 (강력 권고)**:
- 입력창은 항상 보이게 유지. `enabled: false` 로만 잠그고 `hintText` 만 교체:
- 평상: `"습관 추가, 기록, 카탈로그 질문…"`
- warmup: `"AI 준비 중… 잠시만요"`
- send 버튼 자리에 `CircularProgressIndicator(strokeWidth: 2)` 표시 (현재 isStreaming 처리와 동일 패턴 — 일관성 ↑).
- 별도 상단 라벨/배지 추가 X. 사용자는 send 버튼이 spinner 인 것 + hint 한 줄로 충분히 추론 가능.
**README 영향**: §3 AC3, §5 다이어그램, §6 binding 절 모두 수정.
---
## [Strong] R2 — 사용자가 텍스트 입력하고 send 누르면 어떻게 되나? (현재 설계는 침묵)
**관점**: 정신 모델 / 마찰
**근거**:
- R1 권고를 받아들이면 입력창은 보이지만 `enabled: false`. 사용자가 키보드를 띄우고 타이핑하려 하면 → **반응 없음**. 또 다른 마찰.
- 만약 `enabled: true` 로 두고 send 만 disable 하면, 사용자가 메시지를 친 뒤 send 를 누르려는 순간 "왜 안 가지?" 로 또 다른 마찰.
- 어느 쪽이든 **사용자의 의도 (메시지를 보내고 싶음) 와 시스템의 상태 (아직 못 받음) 사이의 간극** 이 풀리지 않음.
**제안 (강력 권고)**:
- 입력창은 `enabled: true` 로 두어 **타이핑은 허용**한다. 사용자가 미리 메시지를 작성하도록.
- send 버튼은 disabled + spinner. 누를 수는 없음.
- **warmup 완료 시점**에 사용자가 이미 타이핑해둔 메시지가 있으면 → send 버튼 자동 활성화. (자동 send 까지는 X — 사용자 의도 확인 필요)
- AC4 에 한 줄 추가: "warmup ready 시점에 입력창의 텍스트가 비어있지 않으면 send 활성화."
**README 영향**: §3 AC3/AC4 보강.
---
## [Suggest] R3 — 첫 warmup 은 "예상 시간" 한 마디 더
**관점**: 정신 모델 / 인지된 지연
**근거**:
- 사용자에게 "AI 준비 중" 만 보여주면 — 0.5초 후에도, 5초 후에도, 10초 후에도 같은 라벨. 정신 모델은 점점 "이게 멈췄나?" 로 기운다.
- Gemma 4 E2B native init + mmap 은 디바이스에 따라 **2-8초** 범위로 추정 (cold launch). 첫 진입 시 한 번뿐이고 두 번째 진입부터는 거의 즉시 (`isLoaded=true`) — 즉 사용자가 이 라벨을 길게 보는 건 **첫 진입 단 한 번**.
- 그 한 번을 부드럽게 만들 가치가 있다.
**제안**:
- hint 를 `"AI 준비 중… 첫 시작은 몇 초 걸려요"` 로 한 번만 명시.
- 1회성 SnackBar 도 검토할 수 있으나 — 사용자가 곧바로 입력창 영역으로 시선이 가므로 hint 한 줄로 통합하는 게 단순.
**README 영향**: §3 AC3 의 라벨 문안, §12 OQ-1 해소.
---
## [Strong] R4 — 빠른 경로 (이미 loaded) 의 라벨 깜빡임을 명시적으로 차단
**관점**: 정신 모델
**근거**:
- README §9 의 "ChatScreen 재진입" 케이스 + fn-spec 의 "빠른 경로" 분기로 Loading state skip 처리가 들어가 있음 — 좋다.
- 하지만 §12 OQ-3 에 "라벨 깜빡임 가능 — 미해결" 이 남아있어 모순. fn-spec 의 빠른 경로가 Loading 을 스킵하므로 깜빡임은 일어나지 않음.
- 명확히 못 박을 것.
**제안**:
- OQ-3 를 OQ 에서 제거하고 §9 의 "빠른 경로" 분기 + fn-spec 의 step 2 를 명시적으로 인용한 **결정 노트**로 전환.
- min display time (300ms 등) 같은 인위 지연은 도입 **금지** — 사용자에게 거짓 작업을 보여주는 안티패턴.
**README 영향**: §9 endorse 표현, §12 OQ-3 삭제.
---
## [Strong] R5 — 실패 메시지의 다음 행동이 약함
**관점**: 에러 회복 / 마찰
**근거**:
- README §9 의 실패 메시지:
- "AI 모델 파일을 찾을 수 없어요. 설정에서 다시 다운로드해주세요." — 좋음 (다음 행동 명시).
- "AI 시작 중 오류가 발생했어요. 잠시 후 다시 시도해주세요." — 약함. "잠시 후" 가 얼마인지, "다시 시도" 가 어떻게 인지 불명.
- 사용자는 두 가지 의문: ① 이게 일시적 문제인가, 영구적 문제인가 ② 내가 뭘 해야 하나.
**제안**:
- "다시 시도" 버튼이 있으니, 메시지에서 "잠시 후 다시 시도해주세요" 는 빼고 **상태 + 행동 분리**:
- 상태: `"AI 를 시작하지 못했어요."`
- 행동: 별도 [다시 시도] 버튼 (이미 설계됨).
- AC5 에 "한국어 메시지는 상태만 기술, 행동은 버튼이 담당" 명시.
- 3회 연속 실패 시점에는 보조 안내 ("문제가 계속되면 앱을 재시작해보세요") — 후속 polish 로 deferrable.
**README 영향**: §9 메시지 사전 갱신, §3 AC5 보강.
---
## [Suggest] R6 — 재시도 버튼 위치는 error container 안
**관점**: 흐름 / 접근성
**근거**:
- README §12 OQ-2 가 "error container 내부 vs 입력창 옆 icon" 으로 열어둠.
- 입력창 옆 icon 은 평상시에는 없는 자리에 갑자기 나타나 사용자가 학습해야 함. 게다가 send 자리 근처에 또 다른 액션 = 오탭 위험.
- error container 는 이미 실패 메시지 영역이라 컨텍스트 일관 + 사용자가 "여기서 다음 행동" 학습.
**제안**:
- OQ-2 → 결정: **error container 내부 OutlinedButton('다시 시도')**.
- container 좌우 패딩, 메시지와 버튼은 column 으로 분리, 버튼은 우측 정렬.
**README 영향**: §12 OQ-2 해소.
---
## AC 보강 권고 (UX-Reviewer 가 작성한 추가 AC)
UX 관점에서 검증 가능한 새 AC 를 제안 (Architect 가 흡수 시 README §3 에 추가):
- [ ] **AC9 (신규)** Warmup 중 입력창은 `enabled: true` 로 타이핑 가능. send 만 disabled + spinner. → R1+R2.
- [ ] **AC10 (신규)** Warmup ready 시점에 입력창에 비어있지 않은 텍스트가 있으면 send 자동 활성화 (자동 send 는 X). → R2.
- [ ] **AC11 (신규)** `isLoaded=true` 인 재진입 시 Loading state 가 1 frame 이라도 노출되지 않는다 (위젯 테스트로 verify). → R4.
- [ ] **AC12 (신규)** 실패 메시지는 상태 기술만, 행동은 [다시 시도] 버튼이 담당. 메시지 본문에 "다시 시도해주세요" 같은 명령형 X. → R5.
## 마이크로카피 사전 (Architect 가 채택 시 README §6 또는 별도 부록)
| 상태 | 한국어 라벨 | 위치 |
|---|---|---|
| warmup loading | 입력창 `hintText`: `AI 준비 중… 첫 시작은 몇 초 걸려요` | 입력창 |
| warmup ready | `hintText`: `습관 추가, 기록, 카탈로그 질문…` | 입력창 (기존 유지) |
| warmup unavailable | (라벨 없음 — 평상시와 동일) | — |
| warmup failed (file missing) | error container: `AI 모델 파일을 찾을 수 없어요. 설정에서 다시 다운로드해주세요.` + [설정으로 가기] | error container |
| warmup failed (other) | error container: `AI 를 시작하지 못했어요.` + [다시 시도] | error container |
> [설정으로 가기] 는 R5 의 file-missing 케이스에서 "설정에서 다시 다운로드" 문구의 다음 행동을 한 탭으로 짧게 만드는 보조 권고. 채택은 Architect 재량.
## Architect 가 결정해야 할 것 (요약)
- Strong R1, R2, R4, R5 — 채택 또는 명시 거절 (OQ 로 남기지 말 것).
- Suggest R3, R6 + 마이크로카피 사전 + [설정으로 가기] — 재량.
- 새 AC 4건 — 채택 시 README §3 에 통합.

View File

@@ -0,0 +1,103 @@
# 함수 설계서: `ChatWarmupController.start` (#311)
> **부모 설계서**: ./README.md · **상태**: Approved (v0.4.1, 2026-06-15)
> **작성**: [AI] Architect · **구현**: `app/lib/state/chat_warmup_provider.dart:start` · **테스트**: `app/test/state/chat_warmup_test.dart`
## 1. 시그니처
```dart
class ChatWarmupController extends StateNotifier<ChatWarmupState> {
ChatWarmupController({
required this.llm,
required this.lifecycle,
}) : super(const ChatWarmupIdle());
final LlmService llm;
final ModelLifecycle lifecycle;
bool _disposed = false;
Future<void> start();
Future<void> retry();
@override
void dispose() { _disposed = true; super.dispose(); }
}
```
## 2. 책임 (단일 책임)
모델 ready 추정 → background `load()` → state 전이까지를 한 번의 트랜잭션으로 묶고, 모든 실패/취소 분기에서 안전하게 state 만 갱신한다.
## 3. 입력
| 파라미터 | 타입 | 제약/검증 | 설명 |
|----------|------|-----------|------|
| (ctor) `llm` | `LlmService` | non-null | `load`/`isLoaded` 만 사용. |
| (ctor) `lifecycle` | `ModelLifecycle` | non-null | `quickCheck` 만 사용. |
## 4. 출력
- **반환**: `Future<void>` — 완료 시점에 state 가 ready/failed/unavailable 중 하나로 확정.
- **부수효과**: `state =` 설정. 다른 I/O 없음.
## 5. 동작 / 알고리즘
```
1. 현재 state 가 Loading 이면 즉시 return (재진입 가드, retry 외에는 발생 X).
2. state = ChatWarmupLoading() 임시 설정 (단, 아래 빠른 경로 확인 전이라 안전).
→ ChatScreen 재진입 시 깜빡임 방지 위해 isLoaded 빠른 경로를 먼저 확인:
if (llm.isLoaded) {
_safeSet(const ChatWarmupReady());
return;
}
3. quickCheck = await lifecycle.quickCheck();
4. quickCheck != ready:
_safeSet(const ChatWarmupUnavailable());
return;
5. _safeSet(const ChatWarmupLoading()); // 본격 로드 시작
6. try { await llm.load(); }
catch (e) {
_safeSet(ChatWarmupFailed(_messageFor(e)));
return;
}
7. _safeSet(const ChatWarmupReady());
_safeSet(s) = if (_disposed) return; state = s;
```
`retry()` = `state = ChatWarmupIdle();``await start();`.
## 6. 에러 & 실패 모드
| 조건 | 처리 | 반환/예외 |
|------|------|-----------|
| `quickCheck` 가 DB lock 등으로 throw | `lifecycle.quickCheck` 내부 catch → `corrupt` 반환 | state = `Unavailable` (보수적) |
| `llm.load()``FileSystemException('model file missing')` | _messageFor 가 매핑 → "AI 모델 파일을 찾을 수 없어요. 설정에서 다시 다운로드해주세요." | state = `Failed` |
| `llm.load()` 가 기타 throw (native init 실패, OOM) | _messageFor → "AI 시작 중 오류가 발생했어요. 잠시 후 다시 시도해주세요." | state = `Failed` |
| start() 진행 중 dispose() | `_disposed = true``_safeSet` 가 no-op | state 변경 안 함 (마지막 set 유지) |
| concurrent start() 호출 | step 1 의 Loading 가드 — 외부에서는 retry() 만 사용하므로 정상 흐름에서 미발생 | early return |
## 7. 엣지케이스
- **ChatScreen 재진입 (이미 loaded)**: step 2 의 빠른 경로로 Loading 단계 skip → 라벨 깜빡임 없음.
- **start() 진행 중 ChatScreen pop → push (빠른 재진입)**: 첫 인스턴스 dispose, 두 번째 인스턴스의 start() 가 새로 호출. `_disposed` 가 인스턴스별이라 race 없음. `llm._loadingFuture` 가 native init 중복 차단.
- **opt-in 토글 race**: 사용자가 ChatScreen 진입과 동시에 SettingsScreen 에서 opt-out → ChatScreen 의 🤖 entry 가 hidden 되며 즉시 pop. dispose 가드로 안전.
## 8. 복잡도 / 성능
- 시간: O(1) + `lifecycle.quickCheck` O(1) (meta_kv 4 쿼리 + 1 stat) + `llm.load()` (수 초).
- 공간: state object 1개.
- 호출 빈도: ChatScreen mount 당 1회 (+retry 횟수).
## 9. 의존성
- `LlmService` (`load`, `isLoaded`) — 인터페이스 안정.
- `ModelLifecycle.quickCheck` (신규).
- `flutter_riverpod` `StateNotifier`.
## 10. 테스트 케이스
- [ ] 정상 happy: quickCheck=ready, load delay 100ms → 시퀀스 [Idle → Loading → Ready].
- [ ] 빠른 경로: isLoaded=true → 시퀀스 [Idle → Ready] (Loading 없음).
- [ ] unavailable: quickCheck=missing → 시퀀스 [Idle → Unavailable], load 호출 안 됨.
- [ ] failure: load throws FileSystemException → state = Failed + 매핑된 한국어 메시지.
- [ ] failure: load throws StateError → state = Failed + generic 메시지.
- [ ] retry: Failed → retry() → Loading → Ready.
- [ ] unmount race: start() 진행 중 dispose() → state 변경 시도 무시 (마지막 state = Loading 유지).
- [ ] DB 예외: quickCheck 가 throw → Unavailable.
## 11. 추적성
- 인수조건: AC1, AC2, AC3, AC4, AC5, AC6.
- 관련 ADR: 없음.

View File

@@ -0,0 +1,85 @@
# 함수 설계서: `GemmaLlmService.load` concurrent guard (#311)
> **부모 설계서**: ./README.md · **상태**: Approved (v0.4.1, 2026-06-15)
> **작성**: [AI] Architect · **구현**: `app/lib/data/ai/gemma_llm_service.dart:load` (수정) · **테스트**: `app/test/data/ai/gemma_llm_service_test.dart` (concurrent 케이스 추가) / `chat_warmup_test.dart` (시뮬)
## 1. 시그니처
```dart
class GemmaLlmService implements LlmService {
Future<void>? _loadingFuture; // 신규 필드
@override
Future<void> load() {
if (_loaded) return Future.value();
final existing = _loadingFuture;
if (existing != null) return existing;
final future = _doLoad();
_loadingFuture = future;
return future.whenComplete(() {
_loadingFuture = null;
});
}
Future<void> _doLoad() async {
// 기존 load() 본문 (initialize → installModel → getActiveModel).
}
}
```
> `MockLlmService.load()` 도 같은 패턴 적용 (`_loadingFuture` 필드 추가). 테스트의 동시성 검증 일관성.
## 2. 책임 (단일 책임)
`load()` 가 진행 중일 때 다른 caller 가 호출하면 새 작업을 시작하지 않고 같은 Future 를 반환한다. native runtime 의 `FlutterGemma.installModel` + `getActiveModel` 가 두 번 불리지 않도록 보호.
## 3. 입력
- 없음 (메서드).
## 4. 출력
- **반환**: `Future<void>` — 단일 native init 작업의 완료 future. 모든 caller 가 같은 인스턴스 공유.
- **부수효과**: `_loadingFuture`, `_loaded`, `_model` 필드 변경.
## 5. 동작 / 알고리즘
```
1. _loaded == true → 즉시 완료 Future 반환.
2. _loadingFuture != null → 그 future 그대로 반환. (새 작업 시작 X)
3. 그 외:
a. future = _doLoad();
b. _loadingFuture = future;
c. future.whenComplete(() => _loadingFuture = null);
d. return future;
```
`_doLoad()` 내부 = 기존 `load()` 본문 그대로 (initialize → installModel → getActiveModel → _loaded=true).
## 6. 에러 & 실패 모드
| 조건 | 처리 | 반환/예외 |
|------|------|-----------|
| `_doLoad()` 가 throw | `whenComplete``_loadingFuture = null` 처리 후 throw 전파 | 모든 caller 가 같은 exception 받음 |
| caller A 가 await 중에 caller B 도 호출 | 같은 future 반환 (step 2) | 둘 다 동일하게 완료 또는 fail |
| 첫 호출 실패 후 재시도 | `_loadingFuture=null` 로 cleared → 다음 호출 시 새 `_doLoad()` 시작 | 정상 재시도 가능 |
## 7. 엣지케이스
- **load() 와 unload() race**: caller A 가 load → 진행 중 caller B 가 unload() 호출. `_doLoad()` 가 _model 설정 직후 unload 가 _model.close() 호출. 본 이슈 범위 외 — 현재 시점에 unload() 호출 경로 없음 (#219 가 다룰 영역). 본 설계는 load 의 concurrent 만 다룬다.
- **whenComplete 실행 시점**: future 가 동기 완료 (이미 _loaded=true 인 첫 분기) 시에도 `_loadingFuture=null` 보장. 단, step 1 에서 early return 이라 _loadingFuture 는 손대지 않음.
## 8. 복잡도 / 성능
- 시간: 첫 호출 = 기존 _doLoad 비용. 후속 caller = O(1) future 공유.
- 공간: future 1개 + null 가능 필드.
- 호출 빈도: ChatScreen mount + userTurn 첫 호출 + frame suggestion (#215) — 모두 일생에 몇 회.
## 9. 의존성
- `flutter_gemma` `FlutterGemma.initialize` / `installModel` / `getActiveModel` (기존).
- `_loaded` / `_model` 필드 (기존).
## 10. 테스트 케이스
- [ ] 정상: load() 1회 호출 → _doLoad() 1회 실행.
- [ ] concurrent: load() 두 번 await 동시 호출 → _doLoad() 1회만 실행, 두 future 같은 Future 인스턴스.
- [ ] 실패 후 재시도: 첫 _doLoad throws → caller A 에게 propagate → _loadingFuture cleared → 두 번째 load() 새 _doLoad 시작.
- [ ] isLoaded 이미 true: load() → 즉시 완료, _doLoad 미실행.
> Gemma native 는 통합 테스트에서만 검증 가능. 단위 테스트는 MockLlmService 의 동일 가드로 시뮬.
## 11. 추적성
- 인수조건: AC7.
- 관련 follow-up: #220 (purge try/catch — 동일 정신).
- 관련 ADR: 없음.

View File

@@ -0,0 +1,207 @@
# 설계서: Tool call 직전 prefix 토큰 corpus & 조건부 push (#312)
> **상태**: Draft
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
> **추적성** — Redmine: #312 · 관련 ADR: ADR-0006 (조건부, Developer 단계에서 corpus 결과 확정 후 작성)
> · 구현 파일: `app/lib/state/chat_providers.dart:144-153` (수정 후보), `app/lib/ai/diagnostics/corpus_logger.dart` (신규) · 테스트: `app/test/state/chat_session_prefix_test.dart` (신규)
## 1. 목적 (Why)
> Planner 인용: tool call 직전 Gemma 4 E2B 가 뱉는 prefix 자연어를 실측 corpus 로 측정하고, 30% 임계에 따라 partial push 구현 또는 의도적 폐기 결정 기록을 남긴다.
`chat_providers.dart:144-153``for await` 루프는 `LlmFunctionCall` 도착 시 `accumulated` 텍스트를 버리고 `break` 한다. Gemma 4 가 tool call 전에 "수면 카탈로그를 보여드릴게요" 같은 의미있는 한국어 prefix 를 자주 뱉는다면, 그 정보가 사용자 화면에서 사라지는 UX 손실이 발생한다. 본 이슈는 손실량을 실측한 뒤 push 구현 또는 의도적 폐기 둘 중 하나로 확정한다.
## 2. 범위 (Scope)
- **포함**:
- 디버그 빌드 전용 `CorpusLogger` 인터페이스 + `ChatSessionController` 에 optional inject.
- corpus 수집 절차 문서 (`corpus-procedure.md`) 와 결과 표 (`docs/research/312-tool-prefix-corpus.md`).
- 임계 (5건 이상 / 15) 충족 시 `userTurn` 의 break 직전 `accumulated.trim().isNotEmpty``ModelChatMessage` push.
- 임계 미달 시 폐기 주석 + ADR 0006 결정 기록.
- 어느 경로든 `ChatSessionController` 단위 테스트 1+ 건.
- **제외 (out of scope)**:
- `ParallelFunctionCallResponse` first-only 한계 (AC4 의 follow-up 이슈로 발행만).
- Gemma 4 thinking/reasoning tag 처리 (`isThinking:false` 비활성 중).
- 일반 streaming UX 폴리시 (cursor, 토큰 속도, scroll).
- 프로덕션 빌드의 로깅 — `CorpusLogger``kDebugMode` 가드 + null default.
## 3. 인수조건 (Acceptance Criteria)
- [ ] **AC1**: `docs/research/312-tool-prefix-corpus.md` 에 15 케이스 표 (사용자 입력 / tool name / `accumulated` raw / 의미있는 prefix 여부 (Y/N) / 사유). Planner 의 운영 정의를 본 설계서가 §6 에서 확정 (R2 해소).
- [ ] **AC2 (조건부 구현)**: corpus 결과가 ≥5/15 이면 `userTurn` 의 break 직전 push (fn-userTurn_partial_push §5 경로 A). ≤4/15 이면 폐기 주석 + ADR 0006 + 경로 B (no-op 회귀 가드).
- [ ] **AC3 (단위 테스트)**: `ChatSessionController` 단위 테스트 — fake `LlmService``text → text → function_call` 순으로 emit 했을 때 결과 `state.messages` 의 길이와 순서를 검증. 경로 A 면 [User, Model(prefix), ToolCall] 3 개. 경로 B 면 [User, ToolCall] 2 개 + prefix 누락이 의도적임을 주석으로 명시.
- [ ] **AC4**: Parallel call 한계를 `userTurn` 코드 인근 주석 + 본 설계서 §11 에 명시 + Redmine 신규 이슈 발행 (예: "#312-followup ParallelFunctionCallResponse 다중 호출 처리").
## 4. 컨텍스트 & 제약
- **의존성**:
- `app/lib/data/ai/llm_service.dart``LlmChatEvent` sealed class (`LlmTextChunk` / `LlmFunctionCall` / `LlmDone`).
- `app/lib/state/chat_providers.dart``ChatSessionController.userTurn` 의 multi-turn 루프.
- 실 단말 — Gemma 4 E2B `.litertlm` 모델 (#218). corpus 는 실제 inference 결과여야 함 (mock 무효).
- **제약**:
- 사용자가 수동으로 APK 를 실행해 corpus 를 수집 — reproducibility 낮음. 본 설계는 logger 인터페이스로 수집 부담을 최소화하는 데 집중.
- `kDebugMode` 가드로 production 영향 0. 빌드 사이즈 +수 KB 이내.
- `LlmService` 인터페이스 변경 금지 — logger 는 `ChatSessionController` 의 ctor 파라미터로만 주입.
- **가정**:
- Gemma 4 E2B 의 함수 호출 메커니즘은 SDK 가 prompt 를 자동 렌더 (cf. `feedback_flutter_gemma_api_quirks`). 따라서 prefix 텍스트는 SDK 가 자체 emit 하는 자연어이지 사용자가 prompt 로 유도한 것이 아니다.
- 동일 프롬프트라도 sampling 노이즈로 다른 결과가 나올 수 있음. 15 케이스 1 회 측정이 절대 진리는 아니지만 의사 결정에는 충분.
## 5. 아키텍처 개요
- **모듈 / 파일**:
- 신규: `app/lib/ai/diagnostics/corpus_logger.dart``abstract class CorpusLogger` + `DebugCorpusLogger` 구현 + factory.
- 수정: `app/lib/state/chat_providers.dart``ChatSessionController``final CorpusLogger? logger` 필드 추가, ctor 에 optional 인자. `userTurn` 의 event 루프에서 hook 호출. corpus 결과 확정 후 §5 의 break 직전에 push 분기 (경로 A) 또는 폐기 주석 (경로 B).
- 신규: `docs/research/312-tool-prefix-corpus.md` — corpus 표 (Developer 가 수집 후 채움).
- 신규: `docs/design/312-tool-prefix-corpus/corpus-procedure.md` — 절차 매뉴얼.
- **데이터 흐름**:
```
[User input] → ChatSessionController.userTurn
LlmService.startChat → _session.sendUser(text)
↓ (Stream<LlmChatEvent>)
for await event:
LlmTextChunk → accumulated += text
→ logger?.onTextChunk(turn, text) ← 신규 hook
→ state.streamingText = accumulated
LlmFunctionCall → toolCall = event
→ logger?.onFunctionCall(turn, ← 신규 hook
accumulated, event.name, event.args)
→ [경로 A] if accumulated.trim().isNotEmpty:
state.messages.add(ModelChatMessage(accumulated))
→ break
state.messages.add(ToolCallChatMessage(...))
```
- **I/O ↔ 순수 경계**: `CorpusLogger` 의 구현체가 I/O (file or stdout). `userTurn` 의 push 분기 자체는 순수 (state mutation 뿐) — 테스트 용이.
```
ChatSessionController ────── (optional) ──── CorpusLogger
│ │
│ userTurn () │ onTextChunk()
│ │ onFunctionCall()
│ ↓
│ file / stdout
state.messages
ChatScreen (ListView)
```
## 6. 데이터 모델
### 6.1 "의미있는 prefix" 운영 정의 (R2 확정)
- **포함 (의미있음, Y)**:
- 공백 제외 한국어 자연어 ≥10 자.
- 정보 전달 의도 있음 (예: "수면 카탈로그에서 추천 항목을 보여드릴게요").
- **제외 (의미없음, N)**:
- 빈 문자열 또는 공백/줄바꿈만.
- boilerplate: "search_catalog 를 호출합니다", "잠시만요", "조회 중...", "...", 영어 함수명 문구.
- 단순 응대: "네", "알겠습니다", "확인했어요" 단독.
- 사용자 입력 그대로 echo.
### 6.2 corpus 표 schema (`docs/research/312-tool-prefix-corpus.md`)
| 컬럼 | 타입 | 설명 |
|------|------|------|
| `#` | int | 1-15 |
| `category` | enum | catalog / add_habit / log_tracker_entry / streak |
| `user_input` | str | 그대로 |
| `tool_name` | str | Gemma 가 호출한 도구 |
| `accumulated_raw` | str (multiline) | tool call 도착 시점의 누적 텍스트 (no trim) |
| `meaningful` | Y/N | §6.1 기준 |
| `note` | str | Y/N 사유 1줄 |
### 6.3 임계 (R4 확정)
- **5+ / 15 (≥33%) → 경로 A (push 구현)**.
- **4 또는 그 이하 → 경로 B (폐기 + ADR 0006)**.
- borderline (정확히 5) 도 경로 A 채택 (UX 손실 보수적 보호). 정확히 4 면 +5 케이스 추가 수집 후 재판정 (총 20 케이스, 임계 7).
## 7. 함수 명세 (Function Specs)
| 함수 | 책임 (1줄) | 시그니처 (잠정) | 입력 | 출력 | 에러 / 실패 | 복잡? |
|------|-----------|----------------|------|------|-------------|-------|
| `CorpusLogger.onTextChunk` | 텍스트 청크 도착 시점 기록 | `void onTextChunk(int turn, String text)` | turn idx, chunk | void | 구현체 I/O 실패는 swallow (debug only) | **복잡** ([fn](./fn-corpus_logger.md)) |
| `CorpusLogger.onFunctionCall` | tool call 도착 시점의 누적 prefix + tool name 기록 | `void onFunctionCall(int turn, String accumulated, String toolName, Map<String,dynamic> args)` | turn idx, prefix, name, args | void | I/O swallow | **복잡** ([fn](./fn-corpus_logger.md)) |
| `DebugCorpusLogger.maybeCreate` | factory — `kDebugMode` + dart-define 가드 | `static CorpusLogger? maybeCreate()` | none | nullable logger | exception swallow → null | 단순 (factory) |
| `ChatSessionController.userTurn` (수정) | event 루프 + 조건부 push | (기존 시그니처) | (기존) | (기존) | (기존) | **복잡** ([fn](./fn-userTurn_partial_push.md)) |
> 복잡 기준: state mutation 분기 / 외부 I/O (logger) / corpus 결과에 따라 코드 경로 갈라짐.
## 8. 흐름 / 알고리즘
### Phase A — corpus 수집 (Developer 수동 작업)
1. `flutter run --debug --dart-define=ENABLE_CORPUS_LOG=1` 으로 APK 빌드 & 단말 설치.
2. `corpus-procedure.md` 의 15 프롬프트를 차례로 ChatScreen 에 입력.
3. `flutter logs` 또는 logcat `--tag CorpusLogger` 로 raw event dump 수집.
4. dump 를 `docs/research/312-tool-prefix-corpus.md` 표에 정리.
5. §6.3 임계로 경로 A / B 결정.
### Phase B — 조건부 구현
- 경로 A (push):
```
} else if (event is LlmFunctionCall) {
toolCall = event;
// #312 — corpus 측정 결과 X/15 가 의미있는 prefix → push 채택.
if (accumulated.trim().isNotEmpty) {
_appendPrefixMessage(accumulated);
}
break;
}
```
- 경로 B (폐기 명시):
```
} else if (event is LlmFunctionCall) {
toolCall = event;
// #312 — corpus 측정 결과 X/15 만 의미있는 prefix → 의도적 폐기.
// ADR-0006 참조. accumulated 는 버린다.
break;
}
```
### Phase C — 테스트
- fake `LlmService` 가 `[Text("수면 카탈로그를 보여드릴게요 "), FunctionCall(search_catalog, {...})]` 순으로 emit.
- 경로 A: `state.messages == [User, Model("수면 카탈로그를 보여드릴게요"), ToolCall(...)]` (trim 적용).
- 경로 B: `state.messages == [User, ToolCall(...)]` (prefix 누락 명시적 검증).
- 보너스: 빈 prefix 케이스 — `[FunctionCall(...)]` 직접 emit → 경로 A 도 ModelChatMessage 추가 안 함 (trim guard).
## 9. 엣지케이스 & 에러 처리
- **빈 prefix**: `accumulated.trim().isEmpty` → push 안 함 (경로 A 의 가드).
- **whitespace only prefix** (Gemma 가 `"\n\n"` 같은 토큰 뱉음): trim 가드로 push 안 함.
- **prefix 가 사용자 입력 echo**: §6.1 의 운영 정의로 corpus 수집 시 N 판정. 구현 단계에선 trim/length 가드만 — echo 감지는 false positive 위험 (정상 paraphrase 까지 잡힐 수 있음). corpus 결과로 임계 산정에 영향만.
- **logger I/O 실패**: `DebugCorpusLogger` 내부 try/catch swallow. 절대 `userTurn` 흐름을 깨면 안 됨.
- **production 빌드**: `DebugCorpusLogger.maybeCreate()` → null 반환. `logger?.onTextChunk` 의 null-aware 가 0 비용.
- **dart-define 미설정 + debug**: factory 가 null 반환 (opt-in). 평소 debug run 도 영향 없음.
## 10. 테스트 계획
### 단위 테스트 (AC3)
- [ ] `chat_session_prefix_test.dart` — 경로 A
- Given: fake LlmService 가 `[Text("수면 카탈로그를 보여드릴게요 "), FunctionCall("search_catalog", {})]` emit.
- When: `userTurn("수면 습관 추천")`.
- Then: state.messages 의 마지막 3 개 = [User, Model("수면 카탈로그를 보여드릴게요"), ToolCall("search_catalog", {}, _)].
- [ ] `chat_session_prefix_test.dart` — 경로 A trim guard
- Given: fake 가 `[Text("\n\n"), FunctionCall(...)]` emit.
- Then: state.messages 에 ModelChatMessage 추가 안 됨.
- [ ] (경로 B 채택 시) `chat_session_prefix_test.dart` — 폐기 회귀 가드
- Given: fake 가 `[Text("의미있는 prefix"), FunctionCall(...)]` emit.
- Then: state.messages 에 ModelChatMessage("의미있는 prefix") 없음 — corpus 결과로 폐기가 정당화됨을 명시적으로 assert.
### logger 단위 테스트
- [ ] `CorpusLogger.maybeCreate` — `kDebugMode=true && ENABLE_CORPUS_LOG=1` → non-null.
- [ ] `maybeCreate` — release → null.
- [ ] `DebugCorpusLogger.onFunctionCall` — 호출 시 stdout 에 `[CorpusLogger]` 라벨 + JSON 한 줄 emit.
### 통합 — 수동 corpus (AC1)
- 절차서 `corpus-procedure.md` 따라 수행 + 결과 표.
## 11. 리스크 & 대안 검토
- **선택**: optional logger inject + `kDebugMode` 가드. 프로덕션 0 영향, corpus 수집은 dart-define on/off.
- **대안 1 (기각)**: ChatSessionController 에 직접 logging 코드 박기 — 프로덕션 영향, 테스트 mock 어려움.
- **대안 2 (기각)**: Riverpod provider 로 logger 주입 — 단일 진실 (controller ctor) 보다 모호. 본 이슈는 수명 짧음 (corpus 끝나면 logger 제거 검토).
- **트레이드오프**:
- logger 가 ChatSessionController API 표면을 늘림 → 본 이슈 후 제거 가능 (코드 적음).
- dart-define 가드는 IDE 자동완성에서 안 보임 → corpus-procedure.md 에 명시.
- **ADR 0006**: 경로 B (폐기) 채택 시에만 작성. push 채택 시 본 설계서 자체가 결정 기록 — ADR 별도 발행 안 함.
- **ParallelFunctionCallResponse (AC4)**: 본 이슈 OOS. follow-up 이슈 발행 권고 — "#312-followup ParallelFunctionCallResponse 다중 호출 처리: 현재 first call only yield 후 return. 다중 tool 시나리오가 Gemma 4 E2B 에서 발생하는지 corpus 결과로 함께 측정 가능 (보너스 컬럼)."
## 12. 미해결 질문 (Open Questions)
없음. R1-R5 해소 완료:
- R1 → 15 케이스 1 회 측정 (§4 가정).
- R2 → §6.1 운영 정의 확정.
- R3 → optional CorpusLogger (§5, §11).
- R4 → 임계 5/15 확정 (§6.3).
- R5 → state.messages 순서 보존 + trim guard (§9).

View File

@@ -0,0 +1,89 @@
# Corpus 수집 절차 (#312)
> **부모 설계서**: ./README.md · **목적**: Developer 가 corpus 를 재현 가능하게 수집할 수 있도록 절차 명문화.
## 1. 빌드
```bash
cd app
flutter build apk --debug --dart-define=ENABLE_CORPUS_LOG=true
# 또는 단말 연결 후
flutter run --debug --dart-define=ENABLE_CORPUS_LOG=true
```
`ENABLE_CORPUS_LOG=true` 가 빠지면 `DebugCorpusLogger.maybeCreate()` 가 null 반환 → logging 없음.
## 2. 로그 캡처
```bash
# adb 연결된 단말
adb logcat | grep CorpusLogger > /tmp/corpus-raw.log
# 또는 flutter run 의 stdout
flutter run --debug --dart-define=ENABLE_CORPUS_LOG=true 2>&1 \
| grep CorpusLogger > /tmp/corpus-raw.log
```
각 줄은 `[CorpusLogger] {"kind":"text_chunk"|"function_call",...}` 형태의 JSON.
## 3. 프롬프트 시퀀스 (15 케이스)
ChatScreen 진입 후 아래를 순서대로 입력. 각 프롬프트 사이에 ↻ 로 세션 초기화 (turn 격리).
### catalog 카테고리 (5)
1. 수면 관련 습관 추천해줘
2. 아침에 할 수 있는 습관 뭐 있어?
3. 스트레스 관리 프로토콜 알려줘
4. 운동 관련 카탈로그 보여줘
5. 명상 어떤 게 있어?
### add_habit 카테고리 (5)
6. 아침 햇빛 보기 습관 추가해줘
7. 매일 물 2L 마시기 추가해줘
8. 잠들기 전 스트레칭 등록할래
9. 출근 전 명상 5분 추가해줘
10. 점심 후 산책 습관 만들어줘
### log_tracker_entry 카테고리 (3)
11. 오늘 햇빛 봤어 체크해줘
12. 어제 운동한 거 기록해줘
13. 오늘 명상 완료
### streak 카테고리 (2)
14. 내 연속 기록 어때?
15. 스트릭 보여줘
## 4. 결과 표 작성
`/tmp/corpus-raw.log` 의 각 `function_call` 이벤트에서 `accumulated_prefix` 를 추출해 `docs/research/312-tool-prefix-corpus.md` 표에 채운다.
표 schema (README §6.2 참조):
```markdown
| # | category | user_input | tool_name | accumulated_raw | meaningful | note |
|---|----------|-----------|-----------|-----------------|------------|------|
| 1 | catalog | 수면 관련 습관 추천해줘 | search_catalog | "수면 카탈로그를 보여드릴게요" | Y | 정보 전달 의도 + 14자 |
| 2 | catalog | 아침에 할 수 있는 습관 뭐 있어? | search_catalog | "" | N | 빈 prefix |
| ... |
```
판정 기준은 README §6.1 의 운영 정의:
- **Y**: 공백 제외 한국어 자연어 ≥10 자 + 정보 전달 의도.
- **N**: 빈/공백/boilerplate/단순 응대/echo.
## 5. 임계 판정
- Y 카운트 ≥5 → 경로 A (push 구현).
- Y 카운트 =4 → +5 케이스 추가 수집 (총 20, 임계 7).
- Y 카운트 ≤3 → 경로 B (폐기 + ADR-0006).
## 6. 후처리
- corpus 결과를 Redmine #312`## [AI] Developer` 섹션 (또는 별도 댓글) 에 요약: Y/N count + 채택 경로.
- 채택 경로에 따라 `fn-userTurn_partial_push.md` 의 경로 A 또는 B 를 구현.
- 경로 B 채택 시 ADR-0006 작성 (`docs/adr/0006-tool-call-prefix-discard.md` 또는 적합한 제목).
- corpus 수집 종료 후 `--dart-define=ENABLE_CORPUS_LOG=true` 사용 빈도 0 → 향후 CorpusLogger 제거 follow-up 이슈 발행 검토.
## 7. 보너스 — ParallelFunctionCallResponse 측정
같은 raw log 에서 한 turn 안에 `function_call` 이 2 회 이상 emit 되는지 확인. 발생 시 AC4 의 follow-up 이슈에 빈도 데이터 포함.

View File

@@ -0,0 +1,134 @@
# 함수 설계서: `CorpusLogger` (#312)
> **부모 설계서**: ./README.md · **상태**: Draft
> **작성**: [AI] Architect · **구현**: `app/lib/ai/diagnostics/corpus_logger.dart` (신규) · **테스트**: `app/test/ai/diagnostics/corpus_logger_test.dart` (신규)
## 1. 시그니처
```dart
abstract class CorpusLogger {
void onTextChunk(int turn, String text);
void onFunctionCall(
int turn,
String accumulatedPrefix,
String toolName,
Map<String, dynamic> args,
);
}
class DebugCorpusLogger implements CorpusLogger {
/// kDebugMode + --dart-define=ENABLE_CORPUS_LOG=1 일 때만 non-null.
/// production 빌드에서는 항상 null.
static CorpusLogger? maybeCreate();
@override
void onTextChunk(int turn, String text);
@override
void onFunctionCall(...);
}
```
## 2. 책임 (단일 책임, 1줄)
ChatSessionController 의 event 루프에서 발생한 텍스트 청크와 function call 의 raw payload 를 디버그 빌드에서 stdout 으로 dump 한다 — corpus 수집 부담을 줄이기 위한 한시적 진단 도구.
## 3. 입력
### `onTextChunk`
| 파라미터 | 타입 | 제약/검증 | 설명 |
|----------|------|-----------|------|
| `turn` | int | ≥0 | userTurn 내 multi-turn 루프의 turn index (0=첫 LLM 응답). |
| `text` | String | non-null | 도착한 텍스트 청크 (raw, 누적 X). |
### `onFunctionCall`
| 파라미터 | 타입 | 제약/검증 | 설명 |
|----------|------|-----------|------|
| `turn` | int | ≥0 | 동일. |
| `accumulatedPrefix` | String | non-null | tool call 도착 시점까지의 누적 텍스트 (chunks 의 concat). |
| `toolName` | String | non-null, non-empty | Gemma 가 호출한 도구 이름. |
| `args` | `Map<String, dynamic>` | non-null | tool 인자. JSON serializable 가정. |
### `maybeCreate`
- 입력 없음.
## 4. 출력
- `onTextChunk` / `onFunctionCall`: **반환 없음**. 부수효과 = stdout 한 줄 emit (디버그). I/O 실패 시 swallow.
- `maybeCreate`: **반환** `CorpusLogger?``kDebugMode` 가 true 이고 `const bool.fromEnvironment('ENABLE_CORPUS_LOG')` 가 true 일 때 `DebugCorpusLogger()` 인스턴스, 그 외 null.
## 5. 동작 / 알고리즘
### `DebugCorpusLogger.onTextChunk`
```
1. _emit({
'kind': 'text_chunk',
'turn': turn,
'text': text,
});
```
### `DebugCorpusLogger.onFunctionCall`
```
1. _emit({
'kind': 'function_call',
'turn': turn,
'accumulated_prefix': accumulatedPrefix,
'tool_name': toolName,
'args': args,
});
```
### `_emit(Map<String, dynamic> payload)`
```
1. try:
2. final line = '[CorpusLogger] ' + jsonEncode(payload);
3. developer.log(line, name: 'CorpusLogger');
4. catch (_):
5. // swallow — diagnostic 이 user flow 를 깨지 않게.
```
### `DebugCorpusLogger.maybeCreate`
```
1. if (!kDebugMode) return null;
2. const enabled = bool.fromEnvironment('ENABLE_CORPUS_LOG', defaultValue: false);
3. if (!enabled) return null;
4. return DebugCorpusLogger._();
```
## 6. 에러 & 실패 모드
| 조건 | 처리 | 반환/예외 |
|------|------|-----------|
| `jsonEncode` 가 args 의 non-serializable 키로 throw | `_emit` 의 try/catch swallow | void (silent) |
| `developer.log` I/O 실패 | swallow | void |
| `maybeCreate` 가 production 호출 | `kDebugMode=false` 분기에서 null | null (정상) |
| ctor 직접 호출 시도 | private ctor `_()` 로 차단 | 컴파일 에러 |
## 7. 엣지케이스
- **매우 빈번한 호출**: 토큰 단위 stream 이라 `onTextChunk` 가 초당 수십 회. `developer.log` 가 stdout flush 부담 — 단, debug only + 한시적이라 수용.
- **args 에 BigInt / DateTime**: `jsonEncode` 가 throw → swallow. corpus 결과 누락 시 코드 보강 (toString fallback) 가능하지만 본 설계는 swallow 만.
- **multi-turn 루프**: 같은 userTurn 내에서 turn 0, 1, 2 ... 각각의 prefix 가 모두 캡처되어야 비교 가능 — caller (ChatSessionController) 가 정확한 turn idx 를 넘긴다.
- **logger null 이지만 inject 됨**: callsite 가 `logger?.onTextChunk(...)` 패턴이므로 null-safe.
## 8. 복잡도 / 성능
- 시간: 각 호출 O(payload 크기). `jsonEncode` 가 prefix 길이에 선형.
- 공간: emit 마다 임시 string. 영구 보관 없음 (stdout sink).
- 호출 빈도: 토큰당 1회 (text_chunk), tool call 당 1회 (function_call). 한 userTurn 에 수십-수백 호출 가능 — debug only 라 수용.
## 9. 의존성
- `dart:convert``jsonEncode`.
- `dart:developer``log`.
- `flutter/foundation.dart``kDebugMode`.
- 환경 변수: `ENABLE_CORPUS_LOG` (dart-define).
## 10. 테스트 케이스
- [ ] **maybeCreate**: production 시뮬 (kDebugMode false fake) → null 반환.
- [ ] **maybeCreate**: debug + ENABLE_CORPUS_LOG=false → null.
- [ ] **maybeCreate**: debug + ENABLE_CORPUS_LOG=true → non-null DebugCorpusLogger.
- [ ] **onFunctionCall happy**: 인자 정상 → stdout 에 `[CorpusLogger]` 라벨 + JSON 한 줄 emit (capture for verification).
- [ ] **onFunctionCall non-serializable args**: `{'date': DateTime.now()}` → throw 안 함 (swallow), test 가 timeout 없이 종료.
- [ ] **onTextChunk** 빈 텍스트 → swallow 없이 정상 emit (filter 안 함, 무엇이 들어왔는지 그대로 기록하는 게 corpus 의 정직성).
> ENABLE_CORPUS_LOG 의 dart-define 기반 테스트는 `--dart-define=ENABLE_CORPUS_LOG=true` 로 별도 `flutter test` invocation 또는 mockable wrapper 로 분리. Architect 권고 = wrapper (`bool _readEnableFlag()` 를 visibleForTesting 으로 expose) 로 테스트 간소화.
## 11. 추적성
- 인수조건: AC1 (corpus 수집 인프라).
- 관련 ADR: 없음 (한시적 진단 도구).
- 본 이슈 종료 후 제거 여부 검토 — follow-up 이슈로 발행 권장 ("CorpusLogger 정리 — corpus 결과 반영 후 logger 제거 또는 영구화 결정").

View File

@@ -0,0 +1,116 @@
# 함수 설계서: `ChatSessionController.userTurn` partial push 분기 (#312)
> **부모 설계서**: ./README.md · **상태**: Draft
> **작성**: [AI] Architect · **구현**: `app/lib/state/chat_providers.dart:144-153` 수정 · **테스트**: `app/test/state/chat_session_prefix_test.dart` (신규)
## 1. 시그니처
변경 없음 (메서드 시그니처 유지):
```dart
Future<void> userTurn(String text, BuildContext context) async;
```
본 설계서는 메서드 내부 event 루프의 `LlmFunctionCall` 분기만 다룬다.
## 2. 책임 (단일 책임, 1줄)
Event 루프가 `LlmFunctionCall` 을 받았을 때, corpus 결과 (≥5/15) 가 prefix 보존을 정당화한 경우에만 `accumulated``ModelChatMessage` 로 push 한 뒤 tool 처리로 break.
## 3. 입력
| 파라미터 | 타입 | 제약/검증 | 설명 |
|----------|------|-----------|------|
| (loop local) `accumulated` | String | non-null, 빈 가능 | LlmTextChunk 누적 결과. |
| (loop local) `event` | LlmFunctionCall | non-null | Gemma 의 함수 호출 이벤트. |
| (instance) `logger` | `CorpusLogger?` | nullable | optional 진단. corpus 단계에서만 활성. |
| (instance) `state.messages` | `List<ChatMessage>` | non-null | 누적 메시지 history. |
## 4. 출력
- **반환**: 없음 (loop 내부 분기).
- **부수효과**:
- `logger?.onFunctionCall(...)` (corpus 활성 시).
- 경로 A: `state.messages``ModelChatMessage(accumulated)` append (단 trim 후 non-empty).
- 양 경로 공통: `toolCall = event; break;`.
## 5. 동작 / 알고리즘
### 경로 A (corpus 결과 ≥5/15 → push 채택)
```dart
} else if (event is LlmFunctionCall) {
toolCall = event;
// #312 — corpus 결과 X/15 (≥5) 가 의미있는 prefix → push.
logger?.onFunctionCall(turn, accumulated, event.name, event.args);
final trimmed = accumulated.trim();
if (trimmed.isNotEmpty) {
state = state.copyWith(
messages: [
...state.messages,
ModelChatMessage(trimmed),
],
);
}
break;
}
```
### 경로 B (corpus 결과 ≤4/15 → 폐기)
```dart
} else if (event is LlmFunctionCall) {
toolCall = event;
// #312 — corpus 측정 결과 X/15 만 의미있는 prefix → 의도적 폐기.
// ADR-0006 (docs/adr/0006-tool-call-prefix-discard.md) 참조.
// accumulated 는 버린다 — 회귀 가드는
// app/test/state/chat_session_prefix_test.dart 의 "폐기 회귀" 테스트.
logger?.onFunctionCall(turn, accumulated, event.name, event.args);
break;
}
```
`logger?.onTextChunk(turn, event.text)``LlmTextChunk` 분기에 동일하게 추가 (양 경로 공통).
### 양 경로 공통 추가 사항
- 컨트롤러 생성자에 optional `CorpusLogger? logger` 추가.
- Riverpod provider 가 `DebugCorpusLogger.maybeCreate()` 를 호출해 inject (production 에서는 null).
## 6. 에러 & 실패 모드
| 조건 | 처리 | 반환/예외 |
|------|------|-----------|
| `accumulated.trim()` 이 빈 문자열 | 경로 A 의 if 가드 → push 안 함 | 정상 break |
| `state.copyWith` 가 빈 messages 로 호출 | 정상 (no-op equivalent) | 정상 |
| logger 가 throw | logger 구현체 내부에서 swallow (fn-corpus_logger §6) | 정상 |
| `event.args` 가 null | `LlmFunctionCall` 계약상 non-null — 발생 시 LlmService 버그. catch 없음 (fail-fast). | LlmService 단에서 처리 |
## 7. 엣지케이스
- **빈 prefix 후 tool**: accumulated="" → 경로 A 의 trim guard 가 push 차단. ChatScreen 에 빈 버블 노출 안 됨.
- **whitespace only prefix** (`"\n\n "`): trim 후 empty → push 안 함.
- **prefix 가 multi-turn 루프의 turn 1+ 에서 발생**: 첫 turn 에서 tool 호출, 두 번째 turn 에서 LLM 이 또 prefix 후 tool 호출. 이때도 동일 로직 — accumulated 가 turn 별로 reset 되어 있음 (`var accumulated = '';` 가 for 루프 내부) 이므로 OK.
- **마지막 turn 의 prefix + 자연어 종료**: tool call 이 안 들어오고 `toolCall == null` 분기로 빠지면 기존 코드가 `ModelChatMessage(accumulated)` push — 본 설계와 무관.
- **prefix 가 그대로 사용자 입력 echo**: 운영 정의상 corpus 에서 N 으로 판정되나 구현은 echo 감지 안 함 (false positive 위험). 코드는 단순 trim/length 만.
## 8. 복잡도 / 성능
- 시간: O(accumulated.length) for trim. 무시 가능.
- 공간: `ModelChatMessage` 1개 (trim 된 prefix 길이).
- 호출 빈도: tool call 당 1회. userTurn 당 최대 `kChatMaxTurns` (4) 회.
## 9. 의존성
- 본 파일 (`chat_providers.dart`) 내 sealed `ChatMessage` (`UserChatMessage`/`ModelChatMessage`/`ToolCallChatMessage`).
- `LlmChatEvent` sealed class (`LlmTextChunk` / `LlmFunctionCall`).
- `CorpusLogger?` (fn-corpus_logger.md).
## 10. 테스트 케이스
- [ ] **경로 A happy**:
- Given: fake LlmService emit `[Text("수면 카탈로그를 보여드릴게요 "), FunctionCall("search_catalog", {"category":"sleep"})]`.
- When: `userTurn("수면 습관 추천")`.
- Then: `state.messages` 의 마지막 3 = `[UserChatMessage("수면 습관 추천"), ModelChatMessage("수면 카탈로그를 보여드릴게요"), ToolCallChatMessage("search_catalog", {category:"sleep"}, _)]`.
- [ ] **경로 A trim guard**:
- Given: fake emit `[Text("\n\n "), FunctionCall(...)]`.
- Then: `state.messages` 에 ModelChatMessage 추가 안 됨. 마지막 2 = `[User, ToolCall]`.
- [ ] **경로 A 빈 prefix**:
- Given: fake emit `[FunctionCall(...)]` (text chunk 없음).
- Then: state.messages 마지막 2 = `[User, ToolCall]`.
- [ ] **경로 B 폐기 회귀** (경로 B 채택 시):
- Given: fake emit `[Text("의미있는 한국어 prefix"), FunctionCall(...)]`.
- Then: state.messages 에 ModelChatMessage 없음 — corpus 결과로 폐기가 정당화됨을 명시적으로 assert.
> 모든 케이스는 mocked `LlmService` + 실제 ChatSessionController. 실 Gemma 통합은 corpus 수집 (Phase A) 에서 검증.
## 11. 추적성
- 인수조건: AC2 (조건부 구현), AC3 (단위 테스트).
- 관련 ADR: ADR-0006 (조건부 — 경로 B 채택 시).

View File

@@ -0,0 +1,116 @@
# 설계서: v0.4.2 hotfix — ChatScreen SafeArea + LLM 진단 + UX round 1 (#342)
> **상태**: Approved
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
> **추적성** — Redmine: #342 · 관련 ADR: 없음
> · 구현 파일: `app/lib/ui/screens/chat_screen.dart`, `app/lib/state/chat_providers.dart`, `app/lib/ui/labels.dart`, `app/lib/ui/screens/habit_list_screen.dart`, `app/lib/ui/screens/streak_screen.dart`, `app/lib/ui/screens/habit_create_screen.dart`
> · 테스트: 기존 167 회귀 (신규 추가 없음 — string label / SafeArea wrap 라 단위 가치 낮음)
## 1. 목적 (Why)
v0.4.1 실 단말 (Android, 사용자 본인) 첫 테스트에서 발견된 사용성·진단 격차 묶음. 모두 dev 단계 신속 hotfix.
1. **A — ChatScreen 입력창 가림**: edge-to-edge 모드의 시스템 nav bar 가 send 버튼/입력창을 덮어 사용 불가.
2. **B — LLM 실패 원인 불명**: warm-up 은 Ready 까지 가지만 send 실패 시 빨간 배너에 `LLM 응답 실패: <Type>` 만 떠 원인 진단 불가.
3. **C — UX round 1 (raw enum 노출)**: 습관 카드/스트릭/추가 화면이 Drift row 의 `'build'` / `RewardTier.dbValue('T0')` / `'Never miss twice'` 같은 식별자를 그대로 사용자에 노출.
## 2. 범위 (Scope)
- **포함**:
- A. `ChatScreen` Scaffold.body → `SafeArea(top: false, …)`.
- B. `userTurn` catch 가 `e.toString() + stack` 전체를 error state 에 저장. ChatScreen 빨간 배너를 `SingleChildScrollView + SelectableText` (monospace, 12pt, 최대 1/3 높이) 로 교체.
- C. `app/lib/ui/labels.dart` 신규 — `habitTypeLabel(HabitType)`, `habitTypeLabelFromDb(String)`, `rewardTierLabel(RewardTier)`. P0 3건 + P1 2건.
- D. UX round 2 — chat 빈 상태 안내 (예시 prompt 4 tap-to-fill), check_in 한국식 날짜 (`6월 15일 (월)`), habit_create "프레임 레벨" → "표현 방식" + 예시 부연.
- **제외 (out of scope)**:
- LLM `BackendInitException: model may be invalid` 실 원인 (GPU KV cache RESOURCE_EXHAUSTED 후보) — 단말 빌드/설치 비용 때문에 별도 사이클로 분리. follow-up 이슈 발행 예정.
- release 빌드에서 stack 숨김 (사용자 친화 메시지로 좁히기) — #342 종료 후 follow-up.
## 3. 인수조건 (Acceptance Criteria)
- [x] **AC-A1** Pixel/Galaxy gesture nav 단말에서 입력창 + send 버튼이 nav bar 와 겹치지 않음.
- [x] **AC-B1** send 실패 시 빨간 배너에 `LLM 응답 실패: <Type>\n<message>\n--- STACK ---\n<stack>` 표시. SelectableText 라 복사 가능.
- [x] **AC-B2** 빨간 배너 높이는 화면의 1/3 을 넘지 않고, 내부 스크롤로 전체 노출.
- [x] **AC-C1** 습관 카드 부제가 `build · L3 · …``만들기 · …` (frameLevel 식별자 제거).
- [x] **AC-C2** 스트릭 화면 현재 티어가 `T0` / `T1` (raw) → `🌱 새싹` / `🥉 3회 도전` ….
- [x] **AC-C3** 스트릭 화면 강등 경고가 `Never miss twice 발동 — 티어 강등``이틀 연속 빠졌어요. 한 단계 강등됐습니다.` (영문 잠언 제거).
- [x] **AC-C4** 습관 추가 드롭다운이 `만들기 (build)``만들기` (식별자 병기 제거).
- [x] **AC-C5** 스트릭 화면의 현재 스트릭이 `displayLarge` hero + 티어 라벨로 시각 위계 강조.
- [x] **AC-D1** ChatScreen 첫 진입 시 빈 메시지 리스트 대신 안내 (아이콘 + 한 줄 설명 + 예시 prompt 4개). tap → 입력창 자동 채움 (자동 send X — 사용자 수정 여지).
- [x] **AC-D2** CheckIn 화면 날짜 `2026-06-15` raw → `6월 15일 (월)` 한국식. DB 저장은 `_ymd` 유지.
- [x] **AC-D3** HabitCreate 의 `프레임 레벨``표현 방식` (+ helperText `행동 위주 vs 정체성 위주`). 아이템 라벨 `L2 · 조건부 긍정``조건부 행동 (예: 아침에 햇빛 받기)` 식 예시 포함.
- [x] **AC-D** 167 기존 테스트 회귀 없음, `flutter analyze` clean.
## 4. 컨텍스트 & 제약
- **의존성**: flutter_gemma 0.16.5 (B 변경 안 함), Riverpod 2.x, Drift row 의 raw String enum.
- **제약**:
- dev 단계 hotfix — release 노출 가능한 stack 도 허용 (사용자 본인 단말 진단 우선).
- C 의 라벨 매핑은 UI 레이어 단일 지점 (`ui/labels.dart`) — domain enum 에 `koreanLabel` 두지 않음 (관심사 분리).
- **가정**:
- `h.type` 은 Drift row 의 String — `HabitTypeX.dbValue` 와 동일한 wire 값 (`'build'` / `'break'`).
- `RewardTier` 의 사용자 명칭은 메모리상 5-Tier 정의 — 🌱 새싹 / 🥉 3회 / 🥈 7일 / 🥇 30일 / 🏆 6주 완주.
## 5. 아키텍처 개요
순수 string 매핑 + Widget tree 재구성. 신규 모듈 없음.
```
ChatScreen
├─ Scaffold.body — SafeArea(top: false) ← AC-A1
│ └─ Column
│ ├─ _WarmupErrorBanner (변경 없음)
│ ├─ Container(error) ← AC-B1/B2
│ │ constraints: maxHeight: screen/3
│ │ child: SingleChildScrollView(SelectableText, monospace 12pt)
│ └─ ListView (변경 없음)
ChatSessionController.userTurn ← AC-B1
└─ catch (e, st) → state.error = "LLM 응답 실패: ${e.runtimeType}\n$e\n\n--- STACK ---\n$st"
ui/labels.dart ← AC-C1~C4
├─ habitTypeLabel(HabitType) → '만들기' / '없애기'
├─ habitTypeLabelFromDb(String) → ↑ (Drift raw 분기, 기본 fallback = dbValue)
└─ rewardTierLabel(RewardTier) → '🌱 새싹' / '🥉 3회 도전' / … / '🏆 6주 완주'
habit_list_screen / streak_screen / habit_create_screen
└─ raw enum 노출 지점 모두 labels.dart 의 함수로 교체
```
## 6. 데이터 모델
신규 모델 없음. 매핑 도메인은 기존 enum (`HabitType`, `FrameLevel`, `RewardTier`) 의 표현 레이어만 분리.
| Enum | Raw (DB/wire) | UI 라벨 |
|---|---|---|
| `HabitType.build` | `'build'` | `만들기` |
| `HabitType.breakHabit` | `'break'` | `없애기` |
| `RewardTier.t0` | `'T0'` | `🌱 새싹` |
| `RewardTier.t1` | `'T1'` | `🥉 3회 도전` |
| `RewardTier.t2` | `'T2'` | `🥈 7일 형성` |
| `RewardTier.t3` | `'T3'` | `🥇 30일 정착` |
| `RewardTier.t4` | `'T4'` | `🏆 6주 완주` |
`FrameLevel` 은 본 hotfix 에서 UI 노출을 **제거** — 사용자에 의미 모호 (L2/L3 차이가 즉시 보이지 않음). 라벨 매핑 미작성.
## 7. 함수 명세
| 함수 | 책임 | 시그니처 | 복잡? |
|------|------|----------|-------|
| `habitTypeLabel` | enum → 한국어 라벨 | `String habitTypeLabel(HabitType)` | 단순 (switch) |
| `habitTypeLabelFromDb` | Drift raw String → 한국어 (fallback = raw) | `String habitTypeLabelFromDb(String)` | 단순 (switch + default) |
| `rewardTierLabel` | enum → 이모지+한국어 | `String rewardTierLabel(RewardTier)` | 단순 (switch) |
모두 단순 string switch 라 `fn-*.md` 분리 불필요.
## 8. 흐름 / 알고리즘
- A: `Scaffold.body``SafeArea` 로 감싸지면서 system bottom inset 만큼 padding 자동 적용. `top: false` 인 이유는 AppBar 가 이미 top inset 처리 (이중 padding 방지).
- B: `Future.try-catch (e, st)` 에서 stack trace 까지 함께 string concat → state → 빨간 컨테이너의 `SelectableText` 로 노출. 사용자가 텍스트 선택 → 복사해 외부에 공유 가능.
- C: 라벨 매핑은 분기/상태/I/O 없음. switch one-liner.
## 9. 테스트 전략
- 신규 unit 추가 없음 — 라벨 매핑은 상수 매핑이라 unit 가치 낮음.
- SafeArea + 빨간 배너는 widget 레이어 변경이지만 LLM 단말 시도 자체가 차단 상태 (#312 corpus collection blocker) — manual 검증으로 대체.
- 167 기존 테스트 회귀 없음으로 단위/통합/도메인 보호.
## 10. 후속 (v0.4.3 또는 별개 이슈)
- `BackendInitException: model may be invalid` 진단/수정 — `maxTokens=2048` 의 GPU buffer 후보. 단말 빌드 비용 때문에 분리.
- release 빌드에서 stack 숨김 (사용자 친화 메시지로).
## 11. 추적성
- **Redmine**: #342 (07-Release, dev hotfix bundle).
- **선행**: #311 (v0.4.1 warm-up — 빨간 배너 자체는 v0.4.1 에서 도입, 본 hotfix 가 진단성 강화).
- **관련**: #312 (corpus collection — LLM 동작 의존, B 진단 완료까지 블로커).

View File

@@ -0,0 +1,70 @@
# AI 코치와 대화하기 (사용자 가이드)
> 적용 버전: **v0.4.0 이상** (warm-up 은 **v0.4.1+**) · Redmine #260 / #311 · 관련 레퍼런스: [도구 호출](../reference/260-in-app-tool-calling.md) · [Warm-up](../reference/311-llm-warmup.md)
>
> 본 가이드는 v0.3.0 의 [AI 도움 켜기·끄기](ai-help-onboarding.md) 가 끝났다는 전제에서 시작합니다. 모델이 단말에 준비되어 있어야 합니다.
v0.4.0 부터는 자유 문장으로 AI 코치에게 카탈로그를 묻거나, 습관을 추가하거나, 오늘의 체크인을 기록할 수 있습니다. 모든 처리는 단말에서 일어나며 입력 텍스트는 외부로 나가지 않습니다.
## 진입
1. **습관 목록** 화면 진입.
2. AppBar 의 🤖 (smart toy) 아이콘 탭.
- AI 도움이 꺼져 있으면 아이콘이 보이지 않습니다 — [AI 도움 켜기](ai-help-onboarding.md) 먼저 진행해주세요.
## 가능한 대화 예시
- "아침 햇빛 프로토콜이 뭐야?" → AI 가 카탈로그에서 찾아 한국어로 요약.
- "수면 관련 습관 추천해줘" → 카테고리 검색 결과를 보여주고 어떤 것부터 시작할지 제안.
- "아침 햇빛 보기 습관 추가해줘" → **확인 다이얼로그가 떠야** 추가됩니다 (아래 §확인 게이트).
- "오늘 햇빛 체크해줘" → 같은 식의 확인 다이얼로그.
- "내 스트릭 어때?" → 현재 연속일수 + 5-tier 보상 등급 안내.
## 확인 게이트 (destructive actions)
다음 작업은 사용자의 명시적 확인 없이는 절대 수행되지 않습니다:
- **습관 추가** (`add_habit`)
- **체크 기록** (`log_tracker_entry`)
확인 다이얼로그가 뜨면:
- **수행** — 작업 진행
- **취소** — 작업 중단 (대화에는 `🛠 습관 추가 → 취소됨` 라벨이 남습니다)
- 바깥 영역 탭 = 취소
## 안전장치
- 한 대화당 최대 4번의 도구 호출 — 무한 루프 방지.
- 8 turn 도달 시 "대화가 길어졌어요" 안내 — 오른쪽 위 ↻ 로 새 대화 시작 권장 (이전 기록은 비워짐).
- AI 가 잘못된 인자로 도구를 호출하면 검증 오류 메시지를 모델에게 돌려주고, AI 가 다시 시도합니다. 앱이 죽지 않습니다.
- "술 끊기" 같은 회피 표현은 R7 규칙으로 자동 거부 + 긍정 프레임 제안 ("맑은 정신을 즐긴다" 등).
- 활성 build 습관 3개 / break 습관 1개 초과 시 R3 규칙으로 거부 — 기존 습관을 정리해야 추가 가능.
- 같은 날짜의 같은 습관에 두 번 체크하면 중복 안내 — 덮어쓰려면 기존 항목 삭제 후 다시 시도.
## 자주 묻는 질문
**Q. 대화 기록은 어디에 저장되나요?**
A. 메모리만 — 화면을 떠나거나 ↻ 를 누르면 사라집니다. 단말에 저장되지 않습니다.
**Q. AI 가 같은 도구를 계속 호출하면?**
A. 4 회를 넘기면 자동으로 멈추고 "도구 호출 루프가 너무 길어 중단했습니다" 안내가 뜹니다.
**Q. 응답이 너무 느린 것 같아요.**
A. 단말 모델이라 1턴 평균 2~5초가 정상입니다. AI 가 도구를 호출하면 round trip 이 한 번 더 들어가 6~10초가 될 수 있어요.
**Q. ChatScreen 진입 시 입력창에 "AI 준비 중…" 이 떠요.**
A. v0.4.1 부터 모델을 백그라운드로 미리 시동합니다 (warm-up). 첫 진입 시 한 번만 보이고 평균 2~8초 안에 사라집니다. 그동안 메시지를 미리 타이핑해도 되고, send 버튼만 비활성 상태로 기다립니다. 다시 진입하면 즉시 사용 가능합니다.
**Q. "AI 모델 파일을 찾을 수 없어요." / "AI 를 시작하지 못했어요." 가 떠요.**
A. 파일을 못 찾는 경우는 [설정으로 가기] 로 이동해 재다운로드, 일시적 시작 실패는 [다시 시도] 로 회복합니다. 설정에서 다운로드를 완료하고 돌아오면 자동으로 다시 시도합니다.
**Q. 도구 결과가 잘렸어요.**
A. 모델 컨텍스트 보호를 위해 도구 결과는 2KB 로 제한됩니다. 잘림이 발생하면 AI 가 자동으로 단건 조회 (`프로토콜 상세`) 도구를 다시 부르도록 안내됩니다.
## 관련 문서
- [AI 도움 켜기·끄기](ai-help-onboarding.md)
- 설계서: [docs/design/260-gemma-tool-calling/](../design/260-gemma-tool-calling/)
- 결정 기록: [docs/adr/0005-in-app-tool-calling-architecture.md](../adr/0005-in-app-tool-calling-architecture.md)
- API 레퍼런스: [docs/reference/260-in-app-tool-calling.md](../reference/260-in-app-tool-calling.md)
- 변경 이력: [CHANGELOG.md](../../CHANGELOG.md)

View File

@@ -1,6 +1,8 @@
# AI 도움 켜기·끄기 (사용자 가이드)
> 적용 버전: **v0.2.0 이상** · Redmine #215 · 관련 레퍼런스: [docs/reference/215-ai-frame-suggest.md](../reference/215-ai-frame-suggest.md)
> 적용 버전: **v0.3.0 이상** (v0.2.0 은 placeholder URL 로 다운로드 graceful 실패) · Redmine #215 / #218 · 관련 레퍼런스: [docs/reference/215-ai-frame-suggest.md](../reference/215-ai-frame-suggest.md)
>
> v0.3.0 부터 **단말 RAM ≥ 4GB** 가 필수입니다. 4GB 미만 단말은 설정 화면의 토글이 비활성으로 표시됩니다.
life-helper 는 사용자가 입력한 자유 문장(예: "술 끊고 싶어")을 Huberman 프로토콜 기반 L2/L3 프레임 문장으로 변환해주는 **단말 내 AI 보조**를 제공합니다. 모든 처리는 단말에서만 일어나며, 입력 텍스트는 외부로 전송되지 않습니다.

View File

@@ -1,8 +1,8 @@
# Reference: AI 프레임 제안 (#215 + #218, v0.3.0-dev)
# Reference: AI 프레임 제안 (#215 + #218, v0.3.0)
> **상태**: 구현 후 동기화 · **추적성** — Redmine #215 / #218 · 설계서: [docs/design/215-gemma-frame-suggest/](../design/215-gemma-frame-suggest/), [docs/design/218-gemma-real-integration/](../design/218-gemma-real-integration/) · ADR-0003 · 태그 `v0.2.0` (placeholder) → `v0.3.0` (real Gemma 4)
> **상태**: 구현 후 동기화 · **추적성** — Redmine #215 / #218 · 설계서: [docs/design/215-gemma-frame-suggest/](../design/215-gemma-frame-suggest/), [docs/design/218-gemma-real-integration/](../design/218-gemma-real-integration/) · ADR-0003 · 태그 `v0.2.0` (placeholder) → `v0.3.0` (real Gemma 4, commit da60dd1)
>
> 본 문서는 v0.3.0-dev 시점의 **실제 코드 사양**이다. v0.2.0 의 placeholder 상태는 #218 에서 실 Gemma 4 E2B + flutter_gemma 0.16.5 통합으로 대체됨. 설계 의도/대안은 설계서·ADR 을 참조.
> 본 문서는 v0.3.0 의 **실제 코드 사양**이다. v0.2.0 의 placeholder 상태는 #218 에서 실 Gemma 4 E2B + flutter_gemma 0.16.5 통합으로 대체됨. 설계 의도/대안은 설계서·ADR 을 참조.
## 1. 모듈 지도

View File

@@ -0,0 +1,191 @@
# Reference: In-app tool calling (#260, v0.4.0)
> **상태**: 구현 후 동기화 · **추적성** — Redmine #260 · 설계서: [docs/design/260-gemma-tool-calling/](../design/260-gemma-tool-calling/) · ADR-0005 · 태그 `v0.4.0` (commit `7037b9e`)
>
> 본 문서는 v0.4.0 의 **실제 코드 사양**이다. 설계 의도/대안은 설계서·ADR 을 참조. 본 기능은 v0.3.0 의 AI 프레임 제안 ([215-ai-frame-suggest.md](215-ai-frame-suggest.md)) 위에 multi-turn tool calling 을 얹은 것이다.
## 1. 모듈 지도
```
lib/
ai/tools/
tool_definition.dart — ToolDefinition / ToolHandler / ToolDeps
tool_envelope.dart — ToolResult sealed (Ok/Err/Cancelled) + encodeToolResult (2KB cap)
tool_registry.dart — ToolRegistry.defaults() + 6 tool 모음
tool_dispatcher.dart — 라우팅 + JSON-schema 검증 + ConfirmGate 통합
confirm_gate.dart — destructive 호출 시 AlertDialog
catalog_tools.dart — search_catalog, query_protocol (read-only)
habit_tools.dart — add_habit (destructive), list_active_habits
tracker_tools.dart — log_tracker_entry (destructive), get_streak
data/ai/
llm_service.dart — LlmChatSession 추상 + LlmChatEvent sealed + MockLlmChatSession
gemma_llm_service.dart — _GemmaChatSession (flutter_gemma 0.16.5 + ToolChoice.auto)
state/
chat_providers.dart — ChatSessionController + kChatMaxTurns / kChatSoftHistoryLimit
ui/screens/
chat_screen.dart — AI 코치 화면 + _kToolKoreanLabels
```
## 2. 도메인 모델
### `ToolDefinition` (`lib/ai/tools/tool_definition.dart`)
```dart
class ToolDefinition {
final String name; // 'search_catalog'
final String description; // 모델이 보는 한국어 설명
final Map<String, dynamic> parametersSchema; // draft-07 JSON Schema Map 리터럴
final bool isDestructive; // true → ConfirmGate 의무
final ToolHandler handler; // Future<ToolResult> Function(args, deps)
final String Function(Map<String, dynamic>)? summarize; // confirm 모달 본문
}
```
### `ToolResult` (sealed)
```dart
sealed class ToolResult { Map<String, dynamic> toJson(); }
final class ToolOk extends ToolResult { final Map<String, dynamic> data; }
final class ToolErr extends ToolResult { final String code; final String reason; }
final class ToolCancelled extends ToolResult {}
```
`toJson()` 디스크리미네이터:
- ok → `{'status': 'ok', 'data': {...}}`
- error → `{'status': 'error', 'code': '...', 'reason': '...'}`
- cancel → `{'status': 'cancelled', 'reason': 'user did not confirm'}`
### `LlmChatEvent` (sealed)
```dart
sealed class LlmChatEvent {}
final class LlmTextChunk extends LlmChatEvent { final String text; }
final class LlmFunctionCall extends LlmChatEvent {
final String name;
final Map<String, dynamic> args;
}
```
## 3. 등록된 도구 6개
| 이름 | 종류 | parametersSchema (required) | 반환 (ok) |
|---|---|---|---|
| `search_catalog` | read-only | `category?`, `keyword?`, `limit?` (1~10) | `{count, items:[{id,title,category,summary}]}` |
| `query_protocol` | read-only | `id` | kind 별 필드 (`protocol` / `break` / `diet`) |
| `list_active_habits` | read-only | — | `{count, build_count, break_count, build_quota_remaining, break_quota_remaining, items[]}` |
| `get_streak` | read-only | `habit_id` | `{current_streak, longest_streak, done_count_30d, done_count_phase42, tier, never_miss_twice_broken}` |
| `add_habit` | **destructive** | `protocol_id`, `frame_level`, `framed_text`, `anchor_when?`, `anchor_after_what?`, `dose_text?` | `{habit_id, title, type, frame_level}` |
| `log_tracker_entry` | **destructive** | `habit_id`, `value`, `date?`, `note?` | `{entry_id, habit_id, date, value}` |
### R 규칙 enforce (핸들러 책임, ADR-0005 §D-2)
| R 규칙 | 위치 | ToolErr code |
|---|---|---|
| R3 (build ≤3 / break ≤1) | `_addHabitHandler``judgeActiveHabitQuota` | `r3_quota` |
| R5 (체크 (habit,date) 중복) | `_logTrackerEntryHandler` 핸들러 레벨 dedup | `duplicate` |
| R7 (회피 키워드) | `_addHabitHandler``detectAvoidanceKeywords(framePatterns)` | `r7_avoidance` |
| R8 (build XOR break protocol_id) | `HabitDao.insertWithVariants` assert → catch | `r8_xor` |
R1/R2/R4/R6/R9/R10 는 현재 본 surface 의 직접 enforce 범위 아님. R9/R10 (minimum variant) 는 UI 흐름에서 별도 관리 — 후속 이슈.
## 4. 멀티턴 루프
`ChatSessionController.userTurn(text, context)``lib/state/chat_providers.dart`.
```
1. 사용자 메시지 append + isStreaming=true
2. (lazy) llm.load() + _session = llm.startChat(tools)
3. nextStream = sendUser(trimmed)
4. for turn in 0..kChatMaxTurns:
events = nextStream()
if events.last is LlmTextChunk only:
모델 자연어 응답 append → return
if events has LlmFunctionCall(toolCall):
result = dispatcher.dispatch(toolCall.name, toolCall.args, context, deps)
messages append ToolCallChatMessage(name, args, result)
capped = jsonDecode(encodeToolResult(result)) ← AC-9 2KB cap
nextStream = sendToolResult(name, capped)
5. else: error('도구 호출 루프가 너무 길어 중단했습니다.')
```
- `kChatMaxTurns = 4` (안전 cap, ADR-0005 §C).
- `kChatSoftHistoryLimit = 8` — 사용자 turn 누적 8 회 도달 시 SystemChatMessage 한 줄 추가 ("대화가 길어졌어요").
- `LlmChatSession.sendToolResult``result` 는 항상 capped Map (`encodeToolResult` round-trip 결과).
## 5. ToolDispatcher 게이트
`lib/ai/tools/tool_dispatcher.dart``dispatch` 는 절대 throw 하지 않는다.
```
1. registry.byName → null 이면 ToolErr('unknown_tool')
2. _validateArgs(schema, rawArgs) → 실패 시 ToolErr('validation', ...)
3. tool.isDestructive 라면:
confirmContext==null → ToolCancelled
ConfirmGate.show(ctx, tool, rawArgs) == false → ToolCancelled
4. try handler(rawArgs, deps) — 예외 → ToolErr('handler_error', ...)
```
`_validateArgs` 의 지원 타입: `string` / `integer` / `number` / `boolean` / `object` / `array`. 미선언 키는 허용 (모델 환각 허용 — 핸들러가 무시).
## 6. ConfirmGate UI
`lib/ai/tools/confirm_gate.dart``showDialog<bool>` 기반 `AlertDialog`.
- 제목: `이 작업을 수행할까요?`
- 본문: `tool.description` + summary box (`tool.summarize?.call(args)` 또는 JSON fallback)
- 액션: `취소` (TextButton, false) / `수행` (FilledButton autofocus, true)
- `barrierDismissible: true` — 바깥 탭 = 취소
- 좁은 화면 대응: content 를 `SingleChildScrollView` 로 감쌈, summary box `width: double.infinity`
## 7. 2KB result cap (ADR-0005 §OQ-2)
`encodeToolResult(ToolResult, {int maxBytes = 2048})``lib/ai/tools/tool_envelope.dart`.
- `jsonEncode(result.toJson())` 후 길이가 `maxBytes` 이하면 그대로 반환.
- 초과 + `ToolOk` 이면 payload 를 `{'_truncated': true, '_hint': '... query_protocol 같은 단건 조회 도구를 사용하세요.'}` 로 대체.
- 초과 + 그 외 (방어용) → `substring(0, maxBytes)` hard cut.
- 호출 위치: `chat_providers.dart` `userTurn` 안에서 `jsonDecode(encodeToolResult(result))` 로 round-trip → Map 형태로 `sendToolResult` 전달.
> 주의: `.length` 는 UTF-16 code units. 한글 멀티바이트 utf-8 환산 시 더 작은 바이트 → 2048 cap 은 보수적 (안전 방향).
## 8. LlmChatSession 두 구현
| 구현 | 위치 | 비고 |
|---|---|---|
| `_GemmaChatSession` | `gemma_llm_service.dart` | flutter_gemma 0.16.5 `_chat.addQueryChunk` + `generateChatResponseAsync`. ParallelFunctionCallResponse 는 first 만 yield 후 return. ThinkingResponse skip. |
| `MockLlmChatSession` | `llm_service.dart` | 테스트용. `chatScript` (FIFO `List<List<LlmChatEvent>>`) + `userInputs` + `toolResults` 기록. `lastChat` 으로 마지막 세션 접근. |
`Tool` (flutter_gemma) ↔ `ToolDefinition` 매핑은 `startChat` 안에서 수행 (`name`, `description`, `parameters`).
## 9. UI 진입점
- `HabitListScreen` AppBar 의 🤖 IconButton (Icons.smart_toy_outlined) — `aiSettingsProvider == true` 일 때만 노출.
- 탭 → `ChatScreen` push.
- 새 대화: AppBar refresh 아이콘 (tooltip: `새 대화 (이전 기록 비우기)`) → `controller.clear()`.
- ToolCallChatMessage 라벨은 `_kToolKoreanLabels` 맵 기반 (`add_habit → 습관 추가` 등 6종, 미매핑은 raw name fallback).
## 10. 테스트
| 파일 | 케이스 수 |
|---|---|
| `test/ai/tools/tool_envelope_test.dart` | 6 |
| `test/ai/tools/catalog_tools_test.dart` | 7 |
| `test/ai/tools/habit_tools_test.dart` | 8 |
| `test/ai/tools/tracker_tools_test.dart` | 7 |
| `test/ai/tools/tool_dispatcher_test.dart` | 6 |
| `test/state/chat_session_controller_test.dart` | 8 (huge_dump cap 회귀 포함) |
| `test/ui/chat_screen_test.dart` | 2 (E2E 수행/취소) |
전체 v0.4.0 기준 **154 passed (1 skip)**.
## 11. Known limitations / 후속 이슈 권장
- **`ToolDefinition.koreanLabel` 필드 도입** — 현재 `chat_screen.dart``_kToolKoreanLabels` hardcoded const. 후속 tool 추가 시 두 위치 동기화 필요.
- **`log_tracker_entry` blank 시 confirm skip** — 현재 `isDestructive: true` 가 done/blank 무차별. R5 의도 "blank = 의도적 공란" 과 차이.
- **`search_catalog` category case-insensitive** — 현재 `DisplayCategory.name` 정확 일치. 모델 환각 대비.
- **R9/R10 minimum variant chat 노출** — Phase 1 dose variants UI 가 chat 진입점에 노출 안 됨. 핸들러는 `isMinimum:false` 고정.
- **KGP deprecation 경고** (빌드 시) — `device_info_plus`, `flutter_gemma`, `large_file_handler`. Future Flutter 빌드 실패 가능.
## 관련 문서
- 설계서: [docs/design/260-gemma-tool-calling/](../design/260-gemma-tool-calling/)
- 결정 기록: [docs/adr/0005-in-app-tool-calling-architecture.md](../adr/0005-in-app-tool-calling-architecture.md)
- 선행 레퍼런스: [docs/reference/215-ai-frame-suggest.md](215-ai-frame-suggest.md)
- 변경 이력: [CHANGELOG.md](../../CHANGELOG.md)

View File

@@ -0,0 +1,103 @@
# LLM warm-up (#311)
> 적용 버전: **v0.4.1 이상** · Redmine #311 · 설계서: [docs/design/311-llm-warmup/](../design/311-llm-warmup/)
`ChatScreen` 진입 시 LLM 모델을 백그라운드로 warm-up 하여 첫 send 의 cold native-init 비용 (수 초) 을 제거한다.
## 상태 머신
`app/lib/state/chat_warmup_provider.dart``sealed class ChatWarmupState`.
| 상태 | 진입 조건 | UI 동작 |
|------|-----------|---------|
| `Idle` | 초기값 | 평상 |
| `Loading` | `quickCheck = ready` + `llm.isLoaded = false``llm.load()` in-flight | hintText 교체, send 자리 spinner |
| `Ready` | `load()` 성공 또는 `llm.isLoaded = true` fast path | 평상, send 즉시 활성 |
| `Failed(kind)` | `load()` throw | `_WarmupErrorBanner` 표시 |
| `Unavailable` | `quickCheck != ready` (opt-out / downloading / missing) | 평상 (warmup 라벨 X, 기존 lazy 경로 fallback) |
`ChatWarmupFailureKind`:
- `fileMissing``FileSystemException`. 회복 = `SettingsScreen` push (재다운로드).
- `runtime` — 기타. 회복 = `retry()`.
## API
### `ChatWarmupController`
```dart
final chatWarmupProvider =
StateNotifierProvider.autoDispose<ChatWarmupController, ChatWarmupState>(...);
```
- `Future<void> start()` — quickCheck → load → state 전이. 재진입 가드 (Loading 중 호출은 no-op). `ChatScreen.initState``postFrameCallback` 에서 호출.
- `Future<void> retry()` — Idle reset 후 start() 재호출.
- dispose 시 `_disposed = true``_safeSet` 가 후속 state 변경 무시.
### `ModelLifecycle.quickCheck()`
```dart
Future<ModelAvailability> quickCheck();
```
`checkAvailability()` 의 lightweight 버전. SHA-256 재해싱 (~2.4GB) 을 건너뛰고 meta_kv + 파일 존재만으로 ready 추정. tamper 감지는 `checkAvailability()` 의 cold path (SettingsScreen) 에 위임.
| meta_kv 상태 | quickCheck 반환 |
|---|---|
| `ai_opt_in != 'true'` | `missing` |
| `ai_download_state in (downloading, paused)` | `downloading` |
| `ai_model_path` null | `missing` |
| `ai_model_sha256` null | `corrupt` |
| 파일 부재 | `missing` |
| 그 외 | `ready` |
| 내부 throw | `corrupt` (보수적 fallback) |
### Concurrent load guard
`GemmaLlmService.load()` + `MockLlmService.load()``_loadingFuture` 가드 공유:
```dart
Future<void> load() {
if (_loaded) return Future.value();
final existing = _loadingFuture;
if (existing != null) return existing;
final future = _doLoad();
_loadingFuture = future;
return future.whenComplete(() { _loadingFuture = null; });
}
```
ChatScreen warm-up + `userTurn` lazy load 가 race 해도 native init 1회만 실행. `_doLoad` throw 시 `whenComplete``_loadingFuture = null` 처리 → 다음 caller 가 새 시도.
## UI binding (`chat_screen.dart`)
- `TextField.enabled = !state.isStreaming` — warmup state 와 무관 (타이핑 미리 작성 가능, UX R1+R2).
- `hintText` 분기 — warmup loading 시 `AI 준비 중… 첫 시작은 몇 초 걸려요`, 그 외 평상.
- send 영역 — `state.isStreaming || isWarming` 이면 `CircularProgressIndicator(strokeWidth: 2)`, 그 외 `IconButton.filled`.
- `_WarmupErrorBanner` (`Failed` 일 때만 표시):
- 메시지 본문 = 상태 기술만 (명령형 X, AC12).
- 우측 정렬 `OutlinedButton`:
- `fileMissing``[설정으로 가기]` + `Navigator.push(SettingsScreen).then((_) => retry())` (pop 후 자동 retry).
- `runtime``[다시 시도]` + `retry()` (즉시).
## 마이크로카피
| 상황 | 한국어 |
|------|--------|
| warmup 중 hintText | `AI 준비 중… 첫 시작은 몇 초 걸려요` |
| 평상 hintText | `습관 추가, 기록, 카탈로그 질문…` |
| Failed(fileMissing) | `AI 모델 파일을 찾을 수 없어요.` |
| Failed(runtime) | `AI 를 시작하지 못했어요.` |
명령형 ("다시 시도해주세요" 등) 은 본문 금지 — 행동은 버튼이 담당.
## 테스트
- `app/test/state/chat_warmup_test.dart` — 8 unit (AC1-2, 5-7, 11-12 + retry + 재진입 가드).
- `app/test/data/ai/model_lifecycle_test.dart` — quickCheck 4 신규.
- `app/test/ui/chat_screen_test.dart` — widget E2E 보류 (NOTE comment 사유). `CircularProgressIndicator` 무한 ticker ↔ `pumpAndSettle` race 가 framework-level 한계.
## 관련 문서
- 설계서: [docs/design/311-llm-warmup/](../design/311-llm-warmup/)
- 사용자 가이드: [AI 코치와 대화하기](../guides/ai-chat-using.md)
- 변경 이력: [CHANGELOG.md](../../CHANGELOG.md) [0.4.1]