Files
life-helper/docs/reference/260-in-app-tool-calling.md
joungmin 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

10 KiB

Reference: In-app tool calling (#260, v0.4.0)

상태: 구현 후 동기화 · 추적성 — Redmine #260 · 설계서: docs/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) 위에 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)

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)

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)

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) _addHabitHandlerjudgeActiveHabitQuota r3_quota
R5 (체크 (habit,date) 중복) _logTrackerEntryHandler 핸들러 레벨 dedup duplicate
R7 (회피 키워드) _addHabitHandlerdetectAvoidanceKeywords(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.sendToolResultresult 는 항상 capped Map (encodeToolResult round-trip 결과).

5. ToolDispatcher 게이트

lib/ai/tools/tool_dispatcher.dartdispatch 는 절대 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.dartshowDialog<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 빌드 실패 가능.

관련 문서