Files
life-helper/docs/design/312-tool-prefix-corpus
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
..

설계서: Tool call 직전 prefix 토큰 corpus & 조건부 push (#312)

상태: Draft 작성: [AI] Architect · 최종수정: 2026-06-15 추적성 — Redmine: #312 · 관련 ADR: ADR-0006 (조건부, Developer 단계에서 corpus 결과 확정 후 작성) · 구현 파일: app/lib/state/chat_providers.dart:144-153 (수정 후보), app/lib/ai/diagnostics/corpus_logger.dart (신규) · 테스트: app/test/state/chat_session_prefix_test.dart (신규)

1. 목적 (Why)

Planner 인용: tool call 직전 Gemma 4 E2B 가 뱉는 prefix 자연어를 실측 corpus 로 측정하고, 30% 임계에 따라 partial push 구현 또는 의도적 폐기 결정 기록을 남긴다.

chat_providers.dart:144-153for await 루프는 LlmFunctionCall 도착 시 accumulated 텍스트를 버리고 break 한다. Gemma 4 가 tool call 전에 "수면 카탈로그를 보여드릴게요" 같은 의미있는 한국어 prefix 를 자주 뱉는다면, 그 정보가 사용자 화면에서 사라지는 UX 손실이 발생한다. 본 이슈는 손실량을 실측한 뒤 push 구현 또는 의도적 폐기 둘 중 하나로 확정한다.

2. 범위 (Scope)

  • 포함:
    • 디버그 빌드 전용 CorpusLogger 인터페이스 + ChatSessionController 에 optional inject.
    • corpus 수집 절차 문서 (corpus-procedure.md) 와 결과 표 (docs/research/312-tool-prefix-corpus.md).
    • 임계 (5건 이상 / 15) 충족 시 userTurn 의 break 직전 accumulated.trim().isNotEmptyModelChatMessage push.
    • 임계 미달 시 폐기 주석 + ADR 0006 결정 기록.
    • 어느 경로든 ChatSessionController 단위 테스트 1+ 건.
  • 제외 (out of scope):
    • ParallelFunctionCallResponse first-only 한계 (AC4 의 follow-up 이슈로 발행만).
    • Gemma 4 thinking/reasoning tag 처리 (isThinking:false 비활성 중).
    • 일반 streaming UX 폴리시 (cursor, 토큰 속도, scroll).
    • 프로덕션 빌드의 로깅 — CorpusLoggerkDebugMode 가드 + null default.

3. 인수조건 (Acceptance Criteria)

  • AC1: docs/research/312-tool-prefix-corpus.md 에 15 케이스 표 (사용자 입력 / tool name / accumulated raw / 의미있는 prefix 여부 (Y/N) / 사유). Planner 의 운영 정의를 본 설계서가 §6 에서 확정 (R2 해소).
  • AC2 (조건부 구현): corpus 결과가 ≥5/15 이면 userTurn 의 break 직전 push (fn-userTurn_partial_push §5 경로 A). ≤4/15 이면 폐기 주석 + ADR 0006 + 경로 B (no-op 회귀 가드).
  • AC3 (단위 테스트): ChatSessionController 단위 테스트 — fake LlmServicetext → text → function_call 순으로 emit 했을 때 결과 state.messages 의 길이와 순서를 검증. 경로 A 면 [User, Model(prefix), ToolCall] 3 개. 경로 B 면 [User, ToolCall] 2 개 + prefix 누락이 의도적임을 주석으로 명시.
  • AC4: Parallel call 한계를 userTurn 코드 인근 주석 + 본 설계서 §11 에 명시 + Redmine 신규 이슈 발행 (예: "#312-followup ParallelFunctionCallResponse 다중 호출 처리").

4. 컨텍스트 & 제약

  • 의존성:
    • app/lib/data/ai/llm_service.dartLlmChatEvent sealed class (LlmTextChunk / LlmFunctionCall / LlmDone).
    • app/lib/state/chat_providers.dartChatSessionController.userTurn 의 multi-turn 루프.
    • 실 단말 — Gemma 4 E2B .litertlm 모델 (#218). corpus 는 실제 inference 결과여야 함 (mock 무효).
  • 제약:
    • 사용자가 수동으로 APK 를 실행해 corpus 를 수집 — reproducibility 낮음. 본 설계는 logger 인터페이스로 수집 부담을 최소화하는 데 집중.
    • kDebugMode 가드로 production 영향 0. 빌드 사이즈 +수 KB 이내.
    • LlmService 인터페이스 변경 금지 — logger 는 ChatSessionController 의 ctor 파라미터로만 주입.
  • 가정:
    • Gemma 4 E2B 의 함수 호출 메커니즘은 SDK 가 prompt 를 자동 렌더 (cf. feedback_flutter_gemma_api_quirks). 따라서 prefix 텍스트는 SDK 가 자체 emit 하는 자연어이지 사용자가 prompt 로 유도한 것이 아니다.
    • 동일 프롬프트라도 sampling 노이즈로 다른 결과가 나올 수 있음. 15 케이스 1 회 측정이 절대 진리는 아니지만 의사 결정에는 충분.

5. 아키텍처 개요

  • 모듈 / 파일:
    • 신규: app/lib/ai/diagnostics/corpus_logger.dartabstract class CorpusLogger + DebugCorpusLogger 구현 + factory.
    • 수정: app/lib/state/chat_providers.dartChatSessionControllerfinal CorpusLogger? logger 필드 추가, ctor 에 optional 인자. userTurn 의 event 루프에서 hook 호출. corpus 결과 확정 후 §5 의 break 직전에 push 분기 (경로 A) 또는 폐기 주석 (경로 B).
    • 신규: docs/research/312-tool-prefix-corpus.md — corpus 표 (Developer 가 수집 후 채움).
    • 신규: docs/design/312-tool-prefix-corpus/corpus-procedure.md — 절차 매뉴얼.
  • 데이터 흐름:
[User input] → ChatSessionController.userTurn
   ↓
   LlmService.startChat → _session.sendUser(text)
   ↓ (Stream<LlmChatEvent>)
   for await event:
      LlmTextChunk → accumulated += text
                  → logger?.onTextChunk(turn, text)         ← 신규 hook
                  → state.streamingText = accumulated
      LlmFunctionCall → toolCall = event
                     → logger?.onFunctionCall(turn,         ← 신규 hook
                          accumulated, event.name, event.args)
                     → [경로 A] if accumulated.trim().isNotEmpty:
                          state.messages.add(ModelChatMessage(accumulated))
                     → break
   ↓
   state.messages.add(ToolCallChatMessage(...))
  • I/O ↔ 순수 경계: CorpusLogger 의 구현체가 I/O (file or stdout). userTurn 의 push 분기 자체는 순수 (state mutation 뿐) — 테스트 용이.
ChatSessionController ────── (optional) ──── CorpusLogger
       │                                          │
       │ userTurn ()                              │ onTextChunk()
       │                                          │ onFunctionCall()
       │                                          ↓
       │                                       file / stdout
       ↓
state.messages
       ↓
ChatScreen (ListView)

6. 데이터 모델

6.1 "의미있는 prefix" 운영 정의 (R2 확정)

  • 포함 (의미있음, Y):
    • 공백 제외 한국어 자연어 ≥10 자.
    • 정보 전달 의도 있음 (예: "수면 카탈로그에서 추천 항목을 보여드릴게요").
  • 제외 (의미없음, N):
    • 빈 문자열 또는 공백/줄바꿈만.
    • boilerplate: "search_catalog 를 호출합니다", "잠시만요", "조회 중...", "...", 영어 함수명 문구.
    • 단순 응대: "네", "알겠습니다", "확인했어요" 단독.
    • 사용자 입력 그대로 echo.

6.2 corpus 표 schema (docs/research/312-tool-prefix-corpus.md)

컬럼 타입 설명
# int 1-15
category enum catalog / add_habit / log_tracker_entry / streak
user_input str 그대로
tool_name str Gemma 가 호출한 도구
accumulated_raw str (multiline) tool call 도착 시점의 누적 텍스트 (no trim)
meaningful Y/N §6.1 기준
note str Y/N 사유 1줄

6.3 임계 (R4 확정)

  • 5+ / 15 (≥33%) → 경로 A (push 구현).
  • 4 또는 그 이하 → 경로 B (폐기 + ADR 0006).
  • borderline (정확히 5) 도 경로 A 채택 (UX 손실 보수적 보호). 정확히 4 면 +5 케이스 추가 수집 후 재판정 (총 20 케이스, 임계 7).

7. 함수 명세 (Function Specs)

함수 책임 (1줄) 시그니처 (잠정) 입력 출력 에러 / 실패 복잡?
CorpusLogger.onTextChunk 텍스트 청크 도착 시점 기록 void onTextChunk(int turn, String text) turn idx, chunk void 구현체 I/O 실패는 swallow (debug only) 복잡 (fn)
CorpusLogger.onFunctionCall tool call 도착 시점의 누적 prefix + tool name 기록 void onFunctionCall(int turn, String accumulated, String toolName, Map<String,dynamic> args) turn idx, prefix, name, args void I/O swallow 복잡 (fn)
DebugCorpusLogger.maybeCreate factory — kDebugMode + dart-define 가드 static CorpusLogger? maybeCreate() none nullable logger exception swallow → null 단순 (factory)
ChatSessionController.userTurn (수정) event 루프 + 조건부 push (기존 시그니처) (기존) (기존) (기존) 복잡 (fn)

복잡 기준: state mutation 분기 / 외부 I/O (logger) / corpus 결과에 따라 코드 경로 갈라짐.

8. 흐름 / 알고리즘

Phase A — corpus 수집 (Developer 수동 작업)

  1. flutter run --debug --dart-define=ENABLE_CORPUS_LOG=1 으로 APK 빌드 & 단말 설치.
  2. corpus-procedure.md 의 15 프롬프트를 차례로 ChatScreen 에 입력.
  3. flutter logs 또는 logcat --tag CorpusLogger 로 raw event dump 수집.
  4. dump 를 docs/research/312-tool-prefix-corpus.md 표에 정리.
  5. §6.3 임계로 경로 A / B 결정.

Phase B — 조건부 구현

  • 경로 A (push):
    } else if (event is LlmFunctionCall) {
      toolCall = event;
      // #312 — corpus 측정 결과 X/15 가 의미있는 prefix → push 채택.
      if (accumulated.trim().isNotEmpty) {
        _appendPrefixMessage(accumulated);
      }
      break;
    }
    
  • 경로 B (폐기 명시):
    } else if (event is LlmFunctionCall) {
      toolCall = event;
      // #312 — corpus 측정 결과 X/15 만 의미있는 prefix → 의도적 폐기.
      // ADR-0006 참조. accumulated 는 버린다.
      break;
    }
    

Phase C — 테스트

  • fake LlmService[Text("수면 카탈로그를 보여드릴게요 "), FunctionCall(search_catalog, {...})] 순으로 emit.
  • 경로 A: state.messages == [User, Model("수면 카탈로그를 보여드릴게요"), ToolCall(...)] (trim 적용).
  • 경로 B: state.messages == [User, ToolCall(...)] (prefix 누락 명시적 검증).
  • 보너스: 빈 prefix 케이스 — [FunctionCall(...)] 직접 emit → 경로 A 도 ModelChatMessage 추가 안 함 (trim guard).

9. 엣지케이스 & 에러 처리

  • 빈 prefix: accumulated.trim().isEmpty → push 안 함 (경로 A 의 가드).
  • whitespace only prefix (Gemma 가 "\n\n" 같은 토큰 뱉음): trim 가드로 push 안 함.
  • prefix 가 사용자 입력 echo: §6.1 의 운영 정의로 corpus 수집 시 N 판정. 구현 단계에선 trim/length 가드만 — echo 감지는 false positive 위험 (정상 paraphrase 까지 잡힐 수 있음). corpus 결과로 임계 산정에 영향만.
  • logger I/O 실패: DebugCorpusLogger 내부 try/catch swallow. 절대 userTurn 흐름을 깨면 안 됨.
  • production 빌드: DebugCorpusLogger.maybeCreate() → null 반환. logger?.onTextChunk 의 null-aware 가 0 비용.
  • dart-define 미설정 + debug: factory 가 null 반환 (opt-in). 평소 debug run 도 영향 없음.

10. 테스트 계획

단위 테스트 (AC3)

  • chat_session_prefix_test.dart — 경로 A
    • Given: fake LlmService 가 [Text("수면 카탈로그를 보여드릴게요 "), FunctionCall("search_catalog", {})] emit.
    • When: userTurn("수면 습관 추천").
    • Then: state.messages 의 마지막 3 개 = [User, Model("수면 카탈로그를 보여드릴게요"), ToolCall("search_catalog", {}, _)].
  • chat_session_prefix_test.dart — 경로 A trim guard
    • Given: fake 가 [Text("\n\n"), FunctionCall(...)] emit.
    • Then: state.messages 에 ModelChatMessage 추가 안 됨.
  • (경로 B 채택 시) chat_session_prefix_test.dart — 폐기 회귀 가드
    • Given: fake 가 [Text("의미있는 prefix"), FunctionCall(...)] emit.
    • Then: state.messages 에 ModelChatMessage("의미있는 prefix") 없음 — corpus 결과로 폐기가 정당화됨을 명시적으로 assert.

logger 단위 테스트

  • CorpusLogger.maybeCreatekDebugMode=true && ENABLE_CORPUS_LOG=1 → non-null.
  • maybeCreate — release → null.
  • DebugCorpusLogger.onFunctionCall — 호출 시 stdout 에 [CorpusLogger] 라벨 + JSON 한 줄 emit.

통합 — 수동 corpus (AC1)

  • 절차서 corpus-procedure.md 따라 수행 + 결과 표.

11. 리스크 & 대안 검토

  • 선택: optional logger inject + kDebugMode 가드. 프로덕션 0 영향, corpus 수집은 dart-define on/off.
  • 대안 1 (기각): ChatSessionController 에 직접 logging 코드 박기 — 프로덕션 영향, 테스트 mock 어려움.
  • 대안 2 (기각): Riverpod provider 로 logger 주입 — 단일 진실 (controller ctor) 보다 모호. 본 이슈는 수명 짧음 (corpus 끝나면 logger 제거 검토).
  • 트레이드오프:
    • logger 가 ChatSessionController API 표면을 늘림 → 본 이슈 후 제거 가능 (코드 적음).
    • dart-define 가드는 IDE 자동완성에서 안 보임 → corpus-procedure.md 에 명시.
  • ADR 0006: 경로 B (폐기) 채택 시에만 작성. push 채택 시 본 설계서 자체가 결정 기록 — ADR 별도 발행 안 함.
  • ParallelFunctionCallResponse (AC4): 본 이슈 OOS. follow-up 이슈 발행 권고 — "#312-followup ParallelFunctionCallResponse 다중 호출 처리: 현재 first call only yield 후 return. 다중 tool 시나리오가 Gemma 4 E2B 에서 발생하는지 corpus 결과로 함께 측정 가능 (보너스 컬럼)."

12. 미해결 질문 (Open Questions)

없음. R1-R5 해소 완료:

  • R1 → 15 케이스 1 회 측정 (§4 가정).
  • R2 → §6.1 운영 정의 확정.
  • R3 → optional CorpusLogger (§5, §11).
  • R4 → 임계 5/15 확정 (§6.3).
  • R5 → state.messages 순서 보존 + trim guard (§9).