[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:
2026-06-15 14:17:47 +09:00
parent 41457ab96e
commit 94a9cd474b
4 changed files with 546 additions and 0 deletions

View 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 채택 시).