[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
This commit is contained in:
284
docs/design/260-gemma-tool-calling/README.md
Normal file
284
docs/design/260-gemma-tool-calling/README.md
Normal file
@@ -0,0 +1,284 @@
|
||||
# 설계서: 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<String, dynamic> parametersSchema; // JSON Schema (draft-07)
|
||||
final bool isDestructive; // true → ConfirmGate 거침
|
||||
final ToolHandler handler; // Future<ToolResult> Function(args, deps)
|
||||
}
|
||||
```
|
||||
|
||||
### 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; // '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<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. 각 `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 + 신규)
|
||||
Reference in New Issue
Block a user