[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,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`