# 함수 설계서: `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 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` | 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 채택 시).