[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
This commit is contained in:
64
docs/guides/ai-chat-using.md
Normal file
64
docs/guides/ai-chat-using.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# AI 코치와 대화하기 (사용자 가이드)
|
||||
|
||||
> 적용 버전: **v0.4.0 이상** · Redmine #260 · 관련 레퍼런스: [docs/reference/260-in-app-tool-calling.md](../reference/260-in-app-tool-calling.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. 도구 결과가 잘렸어요.**
|
||||
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)
|
||||
191
docs/reference/260-in-app-tool-calling.md
Normal file
191
docs/reference/260-in-app-tool-calling.md
Normal 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)
|
||||
Reference in New Issue
Block a user