[Architect] #312 design spec — tool call prefix corpus & 조건부 push
설계서 3 + 절차서 1. - README.md: 기능 설계서 (15 케이스 corpus, 임계 5/15, 경로 A/B) - fn-corpus_logger.md: optional debug logger (kDebugMode + dart-define 가드) - fn-userTurn_partial_push.md: chat_providers.dart 의 break 분기 수정안 (경로 A/B) - corpus-procedure.md: 빌드/캡처/15 프롬프트/임계 판정 절차 R1-R5 모두 해소 (Architect 채택안). ADR-0006 슬롯 = 경로 B 채택 시 작성 (Developer 단계). Refs #312
This commit is contained in:
116
docs/design/312-tool-prefix-corpus/fn-userTurn_partial_push.md
Normal file
116
docs/design/312-tool-prefix-corpus/fn-userTurn_partial_push.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# 함수 설계서: `ChatSessionController.userTurn` partial push 분기 (#312)
|
||||
|
||||
> **부모 설계서**: ./README.md · **상태**: Draft
|
||||
> **작성**: [AI] Architect · **구현**: `app/lib/state/chat_providers.dart:144-153` 수정 · **테스트**: `app/test/state/chat_session_prefix_test.dart` (신규)
|
||||
|
||||
## 1. 시그니처
|
||||
변경 없음 (메서드 시그니처 유지):
|
||||
```dart
|
||||
Future<void> userTurn(String text, BuildContext context) async;
|
||||
```
|
||||
|
||||
본 설계서는 메서드 내부 event 루프의 `LlmFunctionCall` 분기만 다룬다.
|
||||
|
||||
## 2. 책임 (단일 책임, 1줄)
|
||||
Event 루프가 `LlmFunctionCall` 을 받았을 때, corpus 결과 (≥5/15) 가 prefix 보존을 정당화한 경우에만 `accumulated` 를 `ModelChatMessage` 로 push 한 뒤 tool 처리로 break.
|
||||
|
||||
## 3. 입력
|
||||
| 파라미터 | 타입 | 제약/검증 | 설명 |
|
||||
|----------|------|-----------|------|
|
||||
| (loop local) `accumulated` | String | non-null, 빈 가능 | LlmTextChunk 누적 결과. |
|
||||
| (loop local) `event` | LlmFunctionCall | non-null | Gemma 의 함수 호출 이벤트. |
|
||||
| (instance) `logger` | `CorpusLogger?` | nullable | optional 진단. corpus 단계에서만 활성. |
|
||||
| (instance) `state.messages` | `List<ChatMessage>` | non-null | 누적 메시지 history. |
|
||||
|
||||
## 4. 출력
|
||||
- **반환**: 없음 (loop 내부 분기).
|
||||
- **부수효과**:
|
||||
- `logger?.onFunctionCall(...)` (corpus 활성 시).
|
||||
- 경로 A: `state.messages` 에 `ModelChatMessage(accumulated)` append (단 trim 후 non-empty).
|
||||
- 양 경로 공통: `toolCall = event; break;`.
|
||||
|
||||
## 5. 동작 / 알고리즘
|
||||
|
||||
### 경로 A (corpus 결과 ≥5/15 → push 채택)
|
||||
```dart
|
||||
} else if (event is LlmFunctionCall) {
|
||||
toolCall = event;
|
||||
// #312 — corpus 결과 X/15 (≥5) 가 의미있는 prefix → push.
|
||||
logger?.onFunctionCall(turn, accumulated, event.name, event.args);
|
||||
final trimmed = accumulated.trim();
|
||||
if (trimmed.isNotEmpty) {
|
||||
state = state.copyWith(
|
||||
messages: [
|
||||
...state.messages,
|
||||
ModelChatMessage(trimmed),
|
||||
],
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
```
|
||||
|
||||
### 경로 B (corpus 결과 ≤4/15 → 폐기)
|
||||
```dart
|
||||
} else if (event is LlmFunctionCall) {
|
||||
toolCall = event;
|
||||
// #312 — corpus 측정 결과 X/15 만 의미있는 prefix → 의도적 폐기.
|
||||
// ADR-0006 (docs/adr/0006-tool-call-prefix-discard.md) 참조.
|
||||
// accumulated 는 버린다 — 회귀 가드는
|
||||
// app/test/state/chat_session_prefix_test.dart 의 "폐기 회귀" 테스트.
|
||||
logger?.onFunctionCall(turn, accumulated, event.name, event.args);
|
||||
break;
|
||||
}
|
||||
```
|
||||
|
||||
`logger?.onTextChunk(turn, event.text)` 는 `LlmTextChunk` 분기에 동일하게 추가 (양 경로 공통).
|
||||
|
||||
### 양 경로 공통 추가 사항
|
||||
- 컨트롤러 생성자에 optional `CorpusLogger? logger` 추가.
|
||||
- Riverpod provider 가 `DebugCorpusLogger.maybeCreate()` 를 호출해 inject (production 에서는 null).
|
||||
|
||||
## 6. 에러 & 실패 모드
|
||||
| 조건 | 처리 | 반환/예외 |
|
||||
|------|------|-----------|
|
||||
| `accumulated.trim()` 이 빈 문자열 | 경로 A 의 if 가드 → push 안 함 | 정상 break |
|
||||
| `state.copyWith` 가 빈 messages 로 호출 | 정상 (no-op equivalent) | 정상 |
|
||||
| logger 가 throw | logger 구현체 내부에서 swallow (fn-corpus_logger §6) | 정상 |
|
||||
| `event.args` 가 null | `LlmFunctionCall` 계약상 non-null — 발생 시 LlmService 버그. catch 없음 (fail-fast). | LlmService 단에서 처리 |
|
||||
|
||||
## 7. 엣지케이스
|
||||
- **빈 prefix 후 tool**: accumulated="" → 경로 A 의 trim guard 가 push 차단. ChatScreen 에 빈 버블 노출 안 됨.
|
||||
- **whitespace only prefix** (`"\n\n "`): trim 후 empty → push 안 함.
|
||||
- **prefix 가 multi-turn 루프의 turn 1+ 에서 발생**: 첫 turn 에서 tool 호출, 두 번째 turn 에서 LLM 이 또 prefix 후 tool 호출. 이때도 동일 로직 — accumulated 가 turn 별로 reset 되어 있음 (`var accumulated = '';` 가 for 루프 내부) 이므로 OK.
|
||||
- **마지막 turn 의 prefix + 자연어 종료**: tool call 이 안 들어오고 `toolCall == null` 분기로 빠지면 기존 코드가 `ModelChatMessage(accumulated)` push — 본 설계와 무관.
|
||||
- **prefix 가 그대로 사용자 입력 echo**: 운영 정의상 corpus 에서 N 으로 판정되나 구현은 echo 감지 안 함 (false positive 위험). 코드는 단순 trim/length 만.
|
||||
|
||||
## 8. 복잡도 / 성능
|
||||
- 시간: O(accumulated.length) for trim. 무시 가능.
|
||||
- 공간: `ModelChatMessage` 1개 (trim 된 prefix 길이).
|
||||
- 호출 빈도: tool call 당 1회. userTurn 당 최대 `kChatMaxTurns` (4) 회.
|
||||
|
||||
## 9. 의존성
|
||||
- 본 파일 (`chat_providers.dart`) 내 sealed `ChatMessage` (`UserChatMessage`/`ModelChatMessage`/`ToolCallChatMessage`).
|
||||
- `LlmChatEvent` sealed class (`LlmTextChunk` / `LlmFunctionCall`).
|
||||
- `CorpusLogger?` (fn-corpus_logger.md).
|
||||
|
||||
## 10. 테스트 케이스
|
||||
- [ ] **경로 A happy**:
|
||||
- Given: fake LlmService emit `[Text("수면 카탈로그를 보여드릴게요 "), FunctionCall("search_catalog", {"category":"sleep"})]`.
|
||||
- When: `userTurn("수면 습관 추천")`.
|
||||
- Then: `state.messages` 의 마지막 3 = `[UserChatMessage("수면 습관 추천"), ModelChatMessage("수면 카탈로그를 보여드릴게요"), ToolCallChatMessage("search_catalog", {category:"sleep"}, _)]`.
|
||||
- [ ] **경로 A trim guard**:
|
||||
- Given: fake emit `[Text("\n\n "), FunctionCall(...)]`.
|
||||
- Then: `state.messages` 에 ModelChatMessage 추가 안 됨. 마지막 2 = `[User, ToolCall]`.
|
||||
- [ ] **경로 A 빈 prefix**:
|
||||
- Given: fake emit `[FunctionCall(...)]` (text chunk 없음).
|
||||
- Then: state.messages 마지막 2 = `[User, ToolCall]`.
|
||||
- [ ] **경로 B 폐기 회귀** (경로 B 채택 시):
|
||||
- Given: fake emit `[Text("의미있는 한국어 prefix"), FunctionCall(...)]`.
|
||||
- Then: state.messages 에 ModelChatMessage 없음 — corpus 결과로 폐기가 정당화됨을 명시적으로 assert.
|
||||
|
||||
> 모든 케이스는 mocked `LlmService` + 실제 ChatSessionController. 실 Gemma 통합은 corpus 수집 (Phase A) 에서 검증.
|
||||
|
||||
## 11. 추적성
|
||||
- 인수조건: AC2 (조건부 구현), AC3 (단위 테스트).
|
||||
- 관련 ADR: ADR-0006 (조건부 — 경로 B 채택 시).
|
||||
Reference in New Issue
Block a user