# 설계서: 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 parametersSchema; // JSON Schema (draft-07) final bool isDestructive; // true → ConfirmGate 거침 final ToolHandler handler; // Future Function(args, deps) } ``` ### ToolResult (sealed) ```dart sealed class ToolResult { Map toJson(); } final class ToolOk extends ToolResult { final Map 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 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` 으로 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 + 신규)