[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:
67
docs/adr/0005-in-app-tool-calling-architecture.md
Normal file
67
docs/adr/0005-in-app-tool-calling-architecture.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# ADR-0005: In-app tool calling architecture (MCP-equivalent)
|
||||
|
||||
> **상태**: Accepted
|
||||
> **작성**: [AI] Architect · **일자**: 2026-06-15
|
||||
> **추적성** — Redmine #260, 설계서 `docs/design/260-gemma-tool-calling/README.md`, ADR-0003 (on-device Gemma 채택)
|
||||
|
||||
## 컨텍스트
|
||||
#218 로 on-device Gemma 4 추론이 동작하고 #226 으로 카탈로그 47 항목이 노출됐다. 사용자가 "이거 내 습관으로 추가해줘" 같은 자연어 요청으로 DB mutation 까지 도달하는 경로가 필요하다.
|
||||
|
||||
선택지:
|
||||
1. **별도 MCP 서버 띄우기** — 표준 프로토콜, 외부 process. 모바일 환경에서 IPC + 추가 메모리 부담 + 모델 컨텍스트 비용 동일.
|
||||
2. **In-process Dart 함수 직접 호출** — 같은 프로세스 안에서 tool 정의 + 핸들러. flutter_gemma 0.16.5 의 `tools` 파라미터 위에 얇은 라우터.
|
||||
3. **Prompt engineering 으로 mutation 안내** — 모델이 "다음과 같이 하세요" 텍스트로만 응답, 실제 액션은 사용자가 수동 — UX 후퇴, R 규칙 enforce 불가.
|
||||
|
||||
추가로 결정해야 할 부수 항목:
|
||||
- R1~R10 운영 규칙을 **어디서** enforce 할 것인가
|
||||
- tool schema 의 source-of-truth (Dart 코드 vs yaml/json 파일)
|
||||
- destructive tool 의 사용자 확인 게이트 (모달 vs inline 카드 vs 무게이트)
|
||||
|
||||
## 결정
|
||||
|
||||
### 결정 1: in-process Dart tool runtime
|
||||
- 별도 process 띄우지 않는다. `lib/ai/tools/` 하위에 `ToolDefinition` + `ToolDispatcher` + 핸들러를 Dart 로 작성.
|
||||
- flutter_gemma 의 `createChat(tools: [...], toolChoice: ToolChoice.auto)` 가 모델 ↔ Dart 사이의 protocol layer 역할.
|
||||
- `FunctionCallResponse` 를 받으면 `ToolDispatcher.dispatch(name, args, deps)` 로 라우팅 → `chat.addToolResult(...)` 로 회신.
|
||||
|
||||
### 결정 2: R 규칙 enforce 는 tool 핸들러 책임
|
||||
- 모델 prompt 에 "R3 quota 는 build ≤ 3" 식 안내를 넣지 않는다 (학습 신뢰성 불충분).
|
||||
- 모든 mutation 핸들러는 호출 직전 도메인 함수 (`judgeActiveHabitQuota`, `detectAvoidanceKeywords`, `assertXorProtocol`, `validateTrackerValue` 등) 를 직접 호출.
|
||||
- 위반 시 `ToolErr(code: 'r3_quota' | 'r7_avoidance' | ..., reason: 한국어)` 반환 → 모델이 사용자에게 안내.
|
||||
|
||||
### 결정 3: tool schema source-of-truth = Dart 코드
|
||||
- 각 `ToolDefinition.parametersSchema` 는 Dart `Map<String, dynamic>` 리터럴 (draft-07 JSON Schema 형태).
|
||||
- yaml/json 별도 파일 두지 않는다.
|
||||
- 이유:
|
||||
1. 핸들러 시그니처와 schema 가 같은 파일에 있어 drift 방지
|
||||
2. yaml 추가 시 codegen + 버전 동기화 부담
|
||||
3. IDE 자동완성 / rename / find-usages 활용
|
||||
|
||||
### 결정 4: destructive tool = 모달 Confirm 게이트 의무
|
||||
- `add_habit`, `log_tracker_entry(value=done)` 등 mutation tool 은 `isDestructive=true` 플래그.
|
||||
- Dispatcher 가 호출 전 `ConfirmGate.show(context, tool, args)` 로 AlertDialog 표시 → 사용자 OK 시에만 핸들러 실행.
|
||||
- inline 카드 (chat 메시지 안에) 대신 모달 채택 — 시각적 안전성과 모달 API 단순성을 위해.
|
||||
- 사용자 결정 (2026-06-15).
|
||||
|
||||
## 결과
|
||||
- 새 디렉토리 `lib/ai/tools/` 와 `chat_screen.dart`.
|
||||
- `LlmService` 인터페이스에 `sendChatTurn(...)` 추가 — `MockLlmService` 도 갱신.
|
||||
- 별도 server / process 추가 없음. 의존성 증가 없음.
|
||||
- 향후 외부 서비스 (예: 클라우드 카탈로그 sync) 도입 시 핸들러 내부에서 fetch 하는 것으로 충분 — MCP 도입 부담 없음.
|
||||
|
||||
## 영향 / 후속
|
||||
- (+) tool latency in-process Dart 호출이라 < 100ms. MCP IPC 오버헤드 없음.
|
||||
- (+) R 규칙 단일 SoT — Repository 가 검증하므로 UI/CLI/Chat 모두 동일 동작.
|
||||
- (-) MCP 표준 호환 X — 외부 tool 가 MCP 서버로 연동하려면 별도 어댑터 필요. Phase 1 범위 아님.
|
||||
- (-) Dart schema 가 수십 개 넘으면 가독성 부담 — ≥ 20 tool 시 ADR 후속 (yaml/codegen 도입 재검토).
|
||||
|
||||
## 대안 (기각)
|
||||
- **A. MCP 서버 별도 띄우기**: 모바일에서 native process 띄우려면 platform channel + lifecycle 관리. 메모리 +N MB. 표준 호환 외 이득 없음 — Phase 1 부적합.
|
||||
- **B. Prompt-only 안내**: 모델이 R 규칙을 학습 못 한다는 게 이미 검증됨 (#218 OQ-1 시점). 안전한 mutation 불가.
|
||||
- **C. inline 확인 카드**: chat 메시지 흐름에 자연스럽지만 사용자가 다른 메시지에 묻혀 무심코 진행 위험. 모달이 더 안전.
|
||||
- **D. yaml schema**: codegen 부담. Dart 단일 SoT 가 단순.
|
||||
|
||||
## 참고
|
||||
- 설계서: `docs/design/260-gemma-tool-calling/README.md`
|
||||
- 관련 ADR: ADR-0003 (on-device Gemma 채택)
|
||||
- 관련 Redmine: #260
|
||||
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 + 신규)
|
||||
141
docs/design/260-gemma-tool-calling/fn-add_habit_handler.md
Normal file
141
docs/design/260-gemma-tool-calling/fn-add_habit_handler.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# 함수 설계서: `addHabitHandler` (#260)
|
||||
|
||||
> **부모 설계서**: ./README.md · **상태**: Draft
|
||||
> **작성**: [AI] Architect · **구현**: `lib/ai/tools/habit_tools.dart:addHabitHandler` · **테스트**: `test/ai/tools/habit_tools_test.dart`
|
||||
|
||||
## 1. 시그니처
|
||||
```dart
|
||||
Future<ToolResult> addHabitHandler(
|
||||
Map<String, dynamic> args,
|
||||
ToolDeps deps,
|
||||
);
|
||||
```
|
||||
|
||||
대표 destructive 핸들러. 다른 destructive (`log_tracker_entry`) 도 동일 패턴을 따름.
|
||||
|
||||
## 2. 책임 (단일 책임, 1줄)
|
||||
LLM 이 제안한 새 습관 args 를 검증 → R1~R10 enforce → `HabitDao.insertWithVariants` 호출 → 결과 envelope 반환.
|
||||
|
||||
## 3. 입력
|
||||
| 파라미터 | 타입 | 제약 | 설명 |
|
||||
|---|---|---|---|
|
||||
| `args.protocol_id` | String | non-empty, catalog 에 존재 | 사용자가 선택한 카탈로그 ID |
|
||||
| `args.frame_level` | String | ∈ {"L2", "L3"} | R3: L0/L1 reject |
|
||||
| `args.framed_text` | String | 1~200자 | 사용자가 보는 문장 |
|
||||
| `args.anchor_when` | String? | ≤ 50자 | 선택 — 예: "기상 후" |
|
||||
| `args.anchor_after_what` | String? | ≤ 50자 | 선택 — 예: "세수" |
|
||||
| `args.dose_text` | String? | ≤ 100자 | 선택 — variant 단일 생성 (없으면 시드의 default_dose) |
|
||||
| `deps` | ToolDeps | non-null | userId / HabitDao / CatalogRepository |
|
||||
|
||||
**`type` 인자 없음** — catalog item 의 종류 (Protocol/Break) 에 따라 자동 결정. 핸들러가 catalog lookup 으로 판정.
|
||||
|
||||
## 4. 출력
|
||||
- **반환**: `Future<ToolResult>`.
|
||||
- `ToolOk(data: {habit_id, title, type, frame_level})` 성공
|
||||
- `ToolErr(code: 'validation' | 'not_found' | 'r3_quota' | 'r7_avoidance' | 'r8_xor', reason: 한국어)`
|
||||
- **부수효과**: 성공 시 DB write (Habits + HabitDoseVariants 트랜잭션). 실패 시 무변화.
|
||||
|
||||
## 5. 동작 / 알고리즘
|
||||
```
|
||||
1. validate args (schema 는 dispatch 가 이미 했지만, 의미 검증 추가):
|
||||
- protocol_id = args['protocol_id'] (String)
|
||||
- frameLevel = FrameLevel.fromString(args['frame_level'])
|
||||
if frameLevel == null || frameLevel in {l0, l1}:
|
||||
return ToolErr('validation', 'frame_level 은 L2 또는 L3 이어야 합니다.')
|
||||
- framedText = args['framed_text']
|
||||
if framedText.trim().isEmpty:
|
||||
return ToolErr('validation', 'framed_text 가 비어있습니다.')
|
||||
|
||||
2. catalog lookup (1차 효과로 type 결정):
|
||||
item = await deps.catalog.byId(protocol_id)
|
||||
if item == null:
|
||||
return ToolErr('not_found', '카탈로그에서 \'$protocol_id\' 를 찾을 수 없습니다.')
|
||||
if item is ProtocolCatalogItem: habitType = build
|
||||
else if item is BreakCatalogItem: habitType = breakHabit
|
||||
else if item is DietCatalogItem: habitType = build // diet 는 build 로 매핑
|
||||
else: return ToolErr('validation', '지원되지 않는 카탈로그 타입')
|
||||
|
||||
3. R7 avoidance keyword 검증:
|
||||
patterns = await framePatterns() // ref 로 캐시된 것 활용 권장 (deps 에 주입)
|
||||
hits = detectAvoidanceKeywords(framedText, patterns)
|
||||
if hits.isNotEmpty:
|
||||
return ToolErr('r7_avoidance',
|
||||
'L${frameLevel} 프레임에 회피 키워드 "${hits.first.keyword}" 가 감지됐어요. '
|
||||
'"${hits.first.suggestion}" 같은 표현으로 다시 시도해주세요.')
|
||||
|
||||
4. R3 active habit quota 검증:
|
||||
count = await deps.habitDao.countActive(userId: deps.userId, type: habitType)
|
||||
quota = judgeActiveHabitQuota(type: habitType, currentActiveCount: count)
|
||||
if !quota.allowed:
|
||||
return ToolErr('r3_quota', quota.reason)
|
||||
|
||||
5. HabitDraft 빌드 (R8 XOR enforce 는 HabitDao 내부 assertion):
|
||||
draft = HabitDraft(
|
||||
userId: deps.userId,
|
||||
type: habitType,
|
||||
title: item.title,
|
||||
protocolId: habitType == HabitType.build ? protocol_id : null,
|
||||
breakProtocolId: habitType == HabitType.breakHabit ? protocol_id : null,
|
||||
frameLevel: frameLevel,
|
||||
frameFramedText: framedText,
|
||||
anchorWhen: args['anchor_when'],
|
||||
anchorAfterWhat: args['anchor_after_what'],
|
||||
startedAt: today_kst_yyyymmdd(),
|
||||
variants: dose_text != null
|
||||
? [VariantDraft(label: '기본', doseText: dose_text, isMinimum: false, sortOrder: 0)]
|
||||
: [], // 빈 variants 면 HabitDao 가 시드 default 로 채우거나 그대로 빈 채로 저장
|
||||
)
|
||||
|
||||
6. insert:
|
||||
habitId = await deps.habitDao.insertWithVariants(draft)
|
||||
// R8 위배 시 assertXorProtocol 가 throw → catch → return ToolErr('r8_xor', ...)
|
||||
|
||||
7. return ToolOk(data: {
|
||||
'habit_id': habitId,
|
||||
'title': item.title,
|
||||
'type': habitType.name,
|
||||
'frame_level': frameLevel.name,
|
||||
})
|
||||
```
|
||||
|
||||
## 6. 에러 & 실패 모드
|
||||
| 조건 | 처리 | 반환 |
|
||||
|---|---|---|
|
||||
| frame_level L0/L1 | 사용자 안내 | `ToolErr('validation', '...')` |
|
||||
| protocol_id 미존재 | 사용자 안내 | `ToolErr('not_found', '...')` |
|
||||
| 회피 키워드 감지 | 사용자 안내 + 대안 제시 | `ToolErr('r7_avoidance', '...')` |
|
||||
| R3 quota 초과 | 사용자 안내 | `ToolErr('r3_quota', '...')` |
|
||||
| R8 XOR 위배 (이론상 불가, 안전망) | 로깅 + 안내 | `ToolErr('r8_xor', '...')` |
|
||||
| HabitDao 예외 | dispatch 가 catch → `ToolErr('handler_error', ...)` | |
|
||||
|
||||
**불변식**: 모든 error path 는 한국어 reason 을 포함. 모델이 이를 받아 사용자에게 안내.
|
||||
|
||||
## 7. 엣지케이스
|
||||
- **공백 anchor**: trim 후 empty → null 로 변환.
|
||||
- **catalog 이 DietCatalogItem 인데 frame_level 이 L3**: 통과. (diet 도 정체성 프레임 가능)
|
||||
- **frame_level "l2" 소문자**: `FrameLevel.fromString` 이 정규화. 미지원 시 validation error.
|
||||
- **중복 추가**: 같은 protocol_id + framed_text 로 재호출 — 별도 dedup 없음. R3 quota 가 3 째에서 차단. (OQ-1)
|
||||
- **anchor_when 만 있고 anchor_after_what 없음**: 모두 허용 (어느 하나만 있어도 됨).
|
||||
|
||||
## 8. 복잡도 / 성능
|
||||
- DB query 2회 (catalog byId + countActive) + 1회 write 트랜잭션.
|
||||
- `framePatterns` 매번 로드 시 N (≈ 30) 패턴 keyword 매칭 — O(text_len × pattern_count). text ≤ 200자, patterns ≤ 30 → 무시 가능.
|
||||
- 평균 < 100ms.
|
||||
|
||||
## 9. 테스트 케이스 (필수)
|
||||
| 케이스 | 사전 | 입력 | 기대 |
|
||||
|---|---|---|---|
|
||||
| 정상 build | seed + 0 active | `{protocol_id: 'morning_sunlight', frame_level: 'L2', framed_text: '아침에 햇빛 보기'}` | ToolOk, habits row +1 |
|
||||
| 정상 break | seed + 0 active | `{protocol_id: 'alcohol', ...}` | ToolOk, type=breakHabit |
|
||||
| L0 reject | seed | `frame_level: 'L0'` | ToolErr('validation') |
|
||||
| 미존재 protocol | seed | `protocol_id: 'no_such'` | ToolErr('not_found') |
|
||||
| 회피 키워드 | seed | `framed_text: '술 끊기'` | ToolErr('r7_avoidance') |
|
||||
| R3 quota (build 3개) | seed + build 3개 | 새 build 추가 | ToolErr('r3_quota') |
|
||||
|
||||
## 10. 의존
|
||||
- `CatalogRepository.byId(id)`
|
||||
- `HabitDao.countActive(...)`, `HabitDao.insertWithVariants(draft)`
|
||||
- `judgeActiveHabitQuota(...)`
|
||||
- `detectAvoidanceKeywords(text, patterns)`
|
||||
- `framePatterns` (ToolDeps 에 주입)
|
||||
- `FrameLevel`, `HabitType`, `HabitDraft`, `VariantDraft`
|
||||
146
docs/design/260-gemma-tool-calling/fn-chat_session_controller.md
Normal file
146
docs/design/260-gemma-tool-calling/fn-chat_session_controller.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# 함수 설계서: `ChatSessionController.userTurn` (#260)
|
||||
|
||||
> **부모 설계서**: ./README.md · **상태**: Draft
|
||||
> **작성**: [AI] Architect · **구현**: `lib/state/chat_providers.dart:ChatSessionController.userTurn` · **테스트**: `test/state/chat_session_controller_test.dart`
|
||||
|
||||
## 1. 시그니처
|
||||
```dart
|
||||
class ChatSessionController extends StateNotifier<ChatSessionState> {
|
||||
Future<void> userTurn(String text, BuildContext context);
|
||||
}
|
||||
|
||||
class ChatSessionState {
|
||||
final List<ChatMessage> messages; // append-only in-memory
|
||||
final bool isStreaming; // 모델 응답 중 → 입력 disabled
|
||||
final String? streamingText; // 부분 텍스트 누적
|
||||
final String? error; // 마지막 에러 (null = OK)
|
||||
}
|
||||
```
|
||||
|
||||
## 2. 책임 (단일 책임, 1줄)
|
||||
사용자 메시지를 받아 LLM ↔ Tool Dispatcher 의 multi-turn loop 을 돌리고, 메시지 상태를 갱신한다.
|
||||
|
||||
## 3. 입력
|
||||
| 파라미터 | 타입 | 제약 | 설명 |
|
||||
|---|---|---|---|
|
||||
| `text` | String | non-empty after trim | 사용자가 입력한 자연어 |
|
||||
| `context` | BuildContext | mounted | ConfirmGate 가 사용 |
|
||||
|
||||
## 4. 출력
|
||||
- **반환**: `Future<void>`. 결과는 state.messages 와 streamingText 에 반영.
|
||||
- **부수효과**:
|
||||
- state 다중 갱신 (StateNotifier.state = ...)
|
||||
- LLM 호출 (I/O)
|
||||
- Tool dispatcher 호출 (DB write 가능)
|
||||
- ConfirmGate 모달 표시 가능
|
||||
|
||||
## 5. 동작 / 알고리즘
|
||||
```
|
||||
1. 입력 validate:
|
||||
if text.trim().isEmpty: return
|
||||
if state.isStreaming: return // 중복 호출 차단
|
||||
|
||||
2. user 메시지 append:
|
||||
state = state.copyWith(
|
||||
messages: [...state.messages, UserChatMessage(text)],
|
||||
isStreaming: true,
|
||||
streamingText: '',
|
||||
error: null,
|
||||
)
|
||||
|
||||
3. tool registry 와 deps 준비:
|
||||
tools = ToolRegistry.allDefinitions()
|
||||
deps = ref.read(toolDepsProvider)
|
||||
llm = ref.read(llmServiceProvider)
|
||||
|
||||
4. multi-turn loop (최대 MAX_TURNS=4 — tool 호출 chain 보호):
|
||||
for (var turn = 0; turn < MAX_TURNS; turn++) {
|
||||
stream = llm.sendChatTurn(
|
||||
userInput: turn == 0 ? text : null, // 0 turn 만 user text, 이후는 tool result
|
||||
toolResultToSubmit: turn == 0 ? null : pendingToolResult,
|
||||
tools: tools,
|
||||
)
|
||||
|
||||
toolCallToHandle = null
|
||||
accumulatedText = ''
|
||||
|
||||
await for (event in stream) {
|
||||
switch event:
|
||||
case TextResponse(text):
|
||||
accumulatedText += text
|
||||
state = state.copyWith(streamingText: accumulatedText)
|
||||
case FunctionCallResponse(name, args):
|
||||
toolCallToHandle = (name, args)
|
||||
break // 스트림 stop, tool 처리로 분기
|
||||
case ThinkingResponse: skip
|
||||
}
|
||||
|
||||
if toolCallToHandle == null:
|
||||
// 모델이 자연어 응답으로 마무리
|
||||
state = state.copyWith(
|
||||
messages: [...state.messages, ModelChatMessage(accumulatedText)],
|
||||
streamingText: null,
|
||||
isStreaming: false,
|
||||
)
|
||||
return
|
||||
|
||||
// tool 처리
|
||||
result = await ToolDispatcher.dispatch(
|
||||
toolName: toolCallToHandle.name,
|
||||
rawArgs: toolCallToHandle.args,
|
||||
confirmContext: context,
|
||||
deps: deps,
|
||||
)
|
||||
state = state.copyWith(
|
||||
messages: [...state.messages,
|
||||
ToolCallChatMessage(toolCallToHandle.name, toolCallToHandle.args, result)],
|
||||
streamingText: '',
|
||||
)
|
||||
pendingToolResult = (toolCallToHandle.name, result.toJson())
|
||||
}
|
||||
|
||||
// MAX_TURNS 초과 → 안전 종료
|
||||
state = state.copyWith(
|
||||
error: '도구 호출 루프가 너무 길어 중단했습니다.',
|
||||
isStreaming: false,
|
||||
streamingText: null,
|
||||
)
|
||||
```
|
||||
|
||||
## 6. 에러 & 실패 모드
|
||||
| 조건 | 처리 | state |
|
||||
|---|---|---|
|
||||
| 빈 입력 | early return | unchanged |
|
||||
| 동시 호출 (isStreaming) | early return | unchanged |
|
||||
| LLM stream 예외 | catch | `error: 'LLM 응답 실패: ${e.type}'`, isStreaming:false |
|
||||
| MAX_TURNS 초과 | safety break | `error: '...너무 길어...'` |
|
||||
| tool result 직렬화 실패 (이론상 없음) | catch | tool ToolErr 대체 |
|
||||
|
||||
## 7. 엣지케이스
|
||||
- **사용자가 stream 중 chat 화면 dismiss**: StateNotifier 가 dispose 되어도 진행 중인 `await` 는 계속됨. side effect (DB write) 가 이미 시작됐을 수 있으니 graceful 하게 무시 — `mounted` 체크로 state 갱신만 skip.
|
||||
- **연속 tool 호출**: LLM 이 search_catalog → query_protocol → add_habit 같이 3 turn 돌 수 있음. MAX_TURNS=4 가 안전망. AC 시나리오 대부분 1~2 turn 종료.
|
||||
- **tool 호출 후 LLM 이 또 같은 tool 호출 (loop)**: MAX_TURNS 가 차단. 추가로 핸들러는 idempotent 결과 반환하지만 R3 quota 등이 2번째 호출에서 차단.
|
||||
- **chat history 8 turn 초과**: 현재 turn 끝나면 "지난 대화를 정리할까요?" 안내 메시지 (`SystemChatMessage`) 자동 append. clear 는 사용자 액션.
|
||||
- **모달 confirm 대기 중 사용자가 화면 dismiss**: ConfirmGate 내부 mounted 가드가 false 반환 → ToolCancelled → loop 계속 (또는 LLM 이 마무리).
|
||||
|
||||
## 8. 복잡도 / 성능
|
||||
- per-turn: LLM round trip (~2~5초 E2B) + handler (<100ms).
|
||||
- 총 latency: 2~3 turn 으로 끝나는 시나리오 평균 5~10 초.
|
||||
- 메모리: messages 리스트가 메모리 누적. clear 안 하면 무한 — 단 chat history persist X 이므로 앱 종료 시 GC.
|
||||
|
||||
## 9. 테스트 케이스 (필수)
|
||||
Mock LLM 으로 시뮬레이션. 실 모델 호출 안 함.
|
||||
| 케이스 | LLM mock 시퀀스 | 기대 state |
|
||||
|---|---|---|
|
||||
| 자연어 응답만 | `[TextResponse('안녕!')]` | messages = [user, model], isStreaming=false |
|
||||
| 1 tool call + 응답 | `[FunctionCallResponse('search_catalog', {category:'sleep'})]` → tool result → `[TextResponse('카페인 protocol...')]` | messages = [user, toolCall, model] |
|
||||
| destructive cancel | `add_habit` call → ConfirmGate mock false | toolCall message 의 result = ToolCancelled |
|
||||
| MAX_TURNS 초과 | LLM 이 매번 tool call | error 세팅, 안전 종료 |
|
||||
| 중복 호출 차단 | isStreaming=true 일 때 userTurn 재호출 | early return, state unchanged |
|
||||
|
||||
## 10. 의존
|
||||
- `LlmService.sendChatTurn(...)` (확장 인터페이스)
|
||||
- `ToolDispatcher.dispatch(...)`
|
||||
- `ToolRegistry.allDefinitions()`
|
||||
- `ToolDeps` (toolDepsProvider)
|
||||
- Flutter `BuildContext.mounted`
|
||||
106
docs/design/260-gemma-tool-calling/fn-confirm_gate.md
Normal file
106
docs/design/260-gemma-tool-calling/fn-confirm_gate.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# 함수 설계서: `ConfirmGate.show` (#260)
|
||||
|
||||
> **부모 설계서**: ./README.md · **상태**: Draft
|
||||
> **작성**: [AI] Architect · **구현**: `lib/ai/tools/confirm_gate.dart:show` · **테스트**: `test/ui/confirm_gate_test.dart`
|
||||
|
||||
## 1. 시그니처
|
||||
```dart
|
||||
class ConfirmGate {
|
||||
static Future<bool> show(
|
||||
BuildContext context,
|
||||
ToolDefinition tool,
|
||||
Map<String, dynamic> args,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 2. 책임 (단일 책임, 1줄)
|
||||
destructive tool 실행 직전 모달 AlertDialog 을 띄워 사용자 confirm 여부를 `Future<bool>` 로 반환한다.
|
||||
|
||||
## 3. 입력
|
||||
| 파라미터 | 타입 | 제약 | 설명 |
|
||||
|---|---|---|---|
|
||||
| `context` | BuildContext | `context.mounted == true` | chat screen 의 context |
|
||||
| `tool` | ToolDefinition | isDestructive=true | 어떤 도구인가 |
|
||||
| `args` | Map<String, dynamic> | 이미 validate 통과 | 사용자에게 보여줄 인자 |
|
||||
|
||||
## 4. 출력
|
||||
- **반환**: `Future<bool>`
|
||||
- `true` = 사용자가 "수행" 탭
|
||||
- `false` = 사용자가 "취소" 또는 outside-tap dismiss 또는 `context.mounted == false`
|
||||
- **부수효과**: 모달 표시 (UI). DB 변경 없음.
|
||||
|
||||
## 5. 동작 / 알고리즘
|
||||
```
|
||||
1. if !context.mounted:
|
||||
return false
|
||||
|
||||
2. summary = _summarize(tool.name, args)
|
||||
// tool 별 사람 친화 요약 함수 (per-tool overridable)
|
||||
|
||||
3. result = await showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: true, // outside-tap = 취소
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text('이 작업을 수행할까요?'),
|
||||
content: Column(mainAxisSize: min, crossAxisAlignment: start, children: [
|
||||
Text(tool.description, style: bodyMedium),
|
||||
SizedBox(height: 12),
|
||||
Container(
|
||||
padding: EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(summary),
|
||||
),
|
||||
]),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text('취소')),
|
||||
FilledButton(
|
||||
autofocus: true,
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: Text('수행'),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
4. return result ?? false // dismiss 시 null → false
|
||||
```
|
||||
|
||||
### `_summarize` 규칙 (tool 별)
|
||||
- `add_habit` → "프로토콜 '$title'을 ${frame_level} 프레임으로 새 습관으로 추가합니다.\n • 문장: \"$framed_text\"\n • 앵커: ${anchor_when ?? '-'} / ${anchor_after_what ?? '-'}"
|
||||
- `log_tracker_entry` → "$habit_title 의 ${date ?? '오늘'} 기록을 '${value == 'done' ? '완료' : '공란'}' 으로 저장합니다."
|
||||
- 기타 → JSON pretty (fallback)
|
||||
|
||||
`title` 은 핸들러가 호출 직전 lookup 해서 args 에 채워줄 수 있지만, 단순화를 위해 ConfirmGate 가 직접 catalog/habit 조회는 안 함 — args 에 이미 있는 값만 사용. (안 채워졌으면 protocol_id 그대로 노출 — 트레이드오프 수용.)
|
||||
|
||||
## 6. 에러 & 실패 모드
|
||||
| 조건 | 처리 | 반환 |
|
||||
|---|---|---|
|
||||
| context dispose 후 호출 | guard 즉시 | `false` |
|
||||
| showDialog 자체 예외 (이론상 없음) | rethrow X — catch 후 false 반환 | `false` |
|
||||
| args 가 _summarize 가 기대 안 한 형태 | toString fallback | 정상 동작 (dialog 노출) |
|
||||
|
||||
## 7. 엣지케이스
|
||||
- **chat 모달 위에 chat 모달**: 동시에 호출되지 않도록 ChatSessionController 가 직렬화 (한 turn = 한 tool call 만). 다중 destructive tool 병렬 호출 시 첫 confirm 만 처리, 나머지는 `ToolCancelled` 자동 반환 (OQ-1 영향).
|
||||
- **시스템 back press**: Android 뒤로가기 → dialog dismiss → false. 의도된 cancel.
|
||||
- **autofocus + 키보드 enter**: 수행 버튼 기본 포커스. 의도치 않은 enter 누름 위험 — 사용자 결정으로 수용 (단축키 활용성 ↑).
|
||||
|
||||
## 8. 복잡도 / 성능
|
||||
- O(1). 사용자 대기 시간 = 무한 (사용자 입력 대기).
|
||||
- 호출 빈도: 사용자 대화 turn 당 0 또는 1.
|
||||
|
||||
## 9. 테스트 케이스 (필수)
|
||||
| 케이스 | 셋업 | 입력 | 기대 |
|
||||
|---|---|---|---|
|
||||
| confirm | MaterialApp + 모달 진입 → "수행" tap | add_habit args | true |
|
||||
| cancel | "취소" tap | 동일 | false |
|
||||
| outside dismiss | barrier tap | 동일 | false |
|
||||
| unmounted context | context dispose 후 호출 | — | false 즉시 |
|
||||
|
||||
## 10. 의존
|
||||
- Flutter `showDialog`, `AlertDialog`
|
||||
- `ToolDefinition` (description 출처)
|
||||
- per-tool summary 규칙은 별도 함수로 분리 (`_summarize(toolName, args)`) — 단위 테스트 용이.
|
||||
100
docs/design/260-gemma-tool-calling/fn-tool_dispatcher.md
Normal file
100
docs/design/260-gemma-tool-calling/fn-tool_dispatcher.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# 함수 설계서: `ToolDispatcher.dispatch` (#260)
|
||||
|
||||
> **부모 설계서**: ./README.md · **상태**: Draft
|
||||
> **작성**: [AI] Architect · **구현**: `lib/ai/tools/tool_dispatcher.dart:dispatch` · **테스트**: `test/ai/tools/tool_dispatcher_test.dart`
|
||||
|
||||
## 1. 시그니처
|
||||
```dart
|
||||
Future<ToolResult> dispatch({
|
||||
required String toolName,
|
||||
required Map<String, dynamic> rawArgs,
|
||||
required BuildContext? confirmContext, // null 이면 destructive tool 자동 cancel
|
||||
required ToolDeps deps,
|
||||
});
|
||||
```
|
||||
|
||||
`ToolDeps` = `{ HabitDao habitDao, TrackerDao trackerDao, CatalogRepository catalog, String userId }`.
|
||||
|
||||
## 2. 책임 (단일 책임, 1줄)
|
||||
`toolName` 으로 핸들러를 찾아, args 검증 → Confirm gate → 핸들러 호출 → 결과를 envelope 으로 감싸 반환한다.
|
||||
|
||||
## 3. 입력
|
||||
| 파라미터 | 타입 | 제약/검증 | 설명 |
|
||||
|---|---|---|---|
|
||||
| `toolName` | String | non-empty, registry 에 등록된 이름 | LLM 의 `FunctionCallResponse.name` |
|
||||
| `rawArgs` | Map<String, dynamic> | 어떤 타입이든 — 검증은 내부에서 | LLM 의 `FunctionCallResponse.args` |
|
||||
| `confirmContext` | BuildContext? | 살아있는 widget context | destructive 가 아니면 무관. null + destructive = 자동 cancel |
|
||||
| `deps` | ToolDeps | non-null | 핸들러가 호출할 Repository 묶음 |
|
||||
|
||||
## 4. 출력
|
||||
- **반환**: `Future<ToolResult>` — `ToolOk` / `ToolErr` / `ToolCancelled`. 절대 throw 하지 않음.
|
||||
- **부수효과**:
|
||||
- Confirm gate 호출 시 모달 표시 (UI side effect)
|
||||
- 핸들러 내부에서 DB write 가능 (destructive 인 경우만)
|
||||
|
||||
## 5. 동작 / 알고리즘
|
||||
```
|
||||
1. tool = ToolRegistry.byName(toolName)
|
||||
if tool == null:
|
||||
return ToolErr(code: 'unknown_tool', reason: '알 수 없는 도구: $toolName')
|
||||
|
||||
2. validatedArgs = ToolArgsValidator.validate(tool.parametersSchema, rawArgs)
|
||||
if validatedArgs is ValidationError:
|
||||
return ToolErr(code: 'validation', reason: '인자 오류: ${err.message}')
|
||||
|
||||
3. if tool.isDestructive:
|
||||
if confirmContext == null:
|
||||
return ToolCancelled()
|
||||
ok = await ConfirmGate.show(confirmContext, tool, validatedArgs)
|
||||
if !ok:
|
||||
return ToolCancelled()
|
||||
|
||||
4. try:
|
||||
payload = await tool.handler(validatedArgs, deps)
|
||||
// handler 가 이미 ToolResult 를 반환하는 형태이므로 passthrough
|
||||
return payload
|
||||
catch (e, st):
|
||||
log('tool_error', tool=$toolName, err=$e)
|
||||
return ToolErr(code: 'handler_error', reason: '도구 실행 실패: ${e.runtimeType}')
|
||||
```
|
||||
|
||||
## 6. 에러 & 실패 모드
|
||||
| 조건 | 처리 | 반환 |
|
||||
|---|---|---|
|
||||
| `toolName` 미등록 | log warn | `ToolErr('unknown_tool', ...)` |
|
||||
| `rawArgs` schema 위배 | log info | `ToolErr('validation', ...)` |
|
||||
| destructive + confirmContext null | 조용히 | `ToolCancelled()` |
|
||||
| 사용자 모달 거부 | 조용히 | `ToolCancelled()` |
|
||||
| 핸들러 예외 | log error + stacktrace | `ToolErr('handler_error', ...)` — 사용자 메시지엔 타입만 |
|
||||
| 핸들러가 R 규칙 위배 detect | 핸들러 자체에서 반환 | passthrough `ToolErr('r3_quota', ...)` 등 |
|
||||
|
||||
**불변식**: dispatch 는 throw 하지 않는다. 모든 실패 경로는 ToolResult 로 환원.
|
||||
|
||||
## 7. 엣지케이스
|
||||
- **빈 args**: `{}` 가 들어와도 schema 가 required 필드 검증으로 잡음.
|
||||
- **redundant args** (스키마에 없는 키): 무시 — 모델이 환각해도 통과시키되 로깅.
|
||||
- **모달 race**: confirmContext 가 dispatch 호출 후 dispose 되는 경우 → `ConfirmGate` 내부에서 `context.mounted` 체크 후 false 반환.
|
||||
- **dispatch 중 사용자가 chat 화면 dismiss**: 핸들러는 계속 실행됨 (취소 안 함). 결과는 폐기되지만 DB write 는 commit 된 채 남음. ChatSessionController 가 lifecycle 책임짐 (Architect 결정: side effect 보존 = 사용자가 의도적으로 chat 닫았다고 가정).
|
||||
|
||||
## 8. 복잡도 / 성능
|
||||
- O(1) registry lookup (`Map<String, ToolDefinition>`).
|
||||
- args validate ≤ 50자 keyword 등 small payload — O(n) JSON schema 매칭.
|
||||
- 호출 빈도: 사용자 대화 turn 당 0~N (보통 0 또는 1). 폴링 루프 아님.
|
||||
- 메모리: stateless — instance 변수 없음.
|
||||
|
||||
## 9. 테스트 케이스 (필수)
|
||||
| 케이스 | 입력 | 기대 |
|
||||
|---|---|---|
|
||||
| unknown tool | `dispatch('foo', {}, ...)` | `ToolErr('unknown_tool', ...)` |
|
||||
| validation fail | `dispatch('add_habit', {'protocol_id': 123}, ...)` | `ToolErr('validation', ...)` (123 is int not string) |
|
||||
| destructive + null context | `dispatch('add_habit', validArgs, null, ...)` | `ToolCancelled()` |
|
||||
| destructive + user accept | mock ConfirmGate → true | handler 결과 그대로 |
|
||||
| destructive + user reject | mock ConfirmGate → false | `ToolCancelled()` |
|
||||
| handler throw | mock handler throws | `ToolErr('handler_error', ...)` |
|
||||
| read-only normal | `dispatch('search_catalog', validArgs, null, ...)` | `ToolOk(data:...)` |
|
||||
|
||||
## 10. 의존
|
||||
- `ToolRegistry` (정적 lookup)
|
||||
- `ToolArgsValidator` (JSON schema validator — 간단 자체 구현 권장)
|
||||
- `ConfirmGate.show` (UI)
|
||||
- `ToolDeps` 내부의 Repository 들 (각 핸들러가 사용)
|
||||
Reference in New Issue
Block a user