Files
life-helper/docs/design/312-tool-prefix-corpus/fn-userTurn_partial_push.md
joungmin 94a9cd474b [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
2026-06-15 14:17:47 +09:00

6.0 KiB

함수 설계서: 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. 시그니처

변경 없음 (메서드 시그니처 유지):

Future<void> userTurn(String text, BuildContext context) async;

본 설계서는 메서드 내부 event 루프의 LlmFunctionCall 분기만 다룬다.

2. 책임 (단일 책임, 1줄)

Event 루프가 LlmFunctionCall 을 받았을 때, corpus 결과 (≥5/15) 가 prefix 보존을 정당화한 경우에만 accumulatedModelChatMessage 로 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.messagesModelChatMessage(accumulated) append (단 trim 후 non-empty).
    • 양 경로 공통: toolCall = event; break;.

5. 동작 / 알고리즘

경로 A (corpus 결과 ≥5/15 → push 채택)

} 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 → 폐기)

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