- 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
15 KiB
15 KiB
설계서: 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 테스트
- 6 개 tool 정의 + 핸들러 (
- 제외 (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/R2HabitDao.activeHabitsForUser(userId)— listTrackerDao.recordCheckIn(TrackerEntryDraft)— R5CatalogRepository.all()/byId()— 카탈로그validateFrameLevel()/detectAvoidanceKeywords()— R3/R7judgeActiveHabitQuota()— R1/R2computeStreak(...)— 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
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)
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
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 은 안 함 (사용자가 선택).
- 결정: tool result 직렬화 후 2048 bytes hard cap. 초과 시 마지막 1KB 를
-
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. 이유:- yaml 추가 시 codegen + 버전 동기화 부담
- 핸들러 시그니처 와 schema 가 같은 파일에 있어야 drift 방지
- 자동완성 + 리팩터링 도구 활용 (rename, find-usages)
- 추후 schema 가 수십 개 이상이면 ADR 후속에서 재논의.
- 결정: Dart 코드 가 SoT. 각
신규 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 가 참고)
- ADR-0005 발행 (Architect 발행 — 본 작업과 동시)
ToolDefinition+ToolResult+ToolEnvelope골격catalog_tools2 핸들러 (read-only) + 테스트tracker_tools2 핸들러 + 테스트habit_tools2 핸들러 + R 규칙 enforce + 테스트ToolDispatcher+ConfirmGate+ 테스트GemmaLlmService.sendChatTurn+LlmService확장 +MockLlmService갱신ChatScreen+ChatSessionController+ 위젯 테스트HabitListScreenAppBar 진입점- 회귀 (전체 110 + 신규)