Files
life-helper/docs/design/260-gemma-tool-calling
joungmin 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
..

설계서: 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.5Tool, 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.5ToolChoice.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 은 안 함 (사용자가 선택).
  • 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. 각 ToolDefinitionparametersSchema 는 Dart 리터럴 Map. 이유:
      1. yaml 추가 시 codegen + 버전 동기화 부담
      2. 핸들러 시그니처 와 schema 가 같은 파일에 있어야 drift 방지
      3. 자동완성 + 리팩터링 도구 활용 (rename, find-usages)
    • 추후 schema 가 수십 개 이상이면 ADR 후속에서 재논의.

신규 OQ (Developer 가 구현 중 답)

  • OQ-5: flutter_gemma 0.16.5ToolChoice.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 + 신규)