[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:
2026-06-15 10:15:44 +09:00
parent 321d3af53b
commit eca097aa2c
6 changed files with 844 additions and 0 deletions

View 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

View 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 + 신규)

View 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`

View 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`

View 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)`) — 단위 테스트 용이.

View 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 들 (각 핸들러가 사용)