From 94a9cd474bbee3d1cbad38460e080c5e9ec3b6c0 Mon Sep 17 00:00:00 2001 From: joungmin Date: Mon, 15 Jun 2026 14:17:47 +0900 Subject: [PATCH] =?UTF-8?q?[Architect]=20#312=20design=20spec=20=E2=80=94?= =?UTF-8?q?=20tool=20call=20prefix=20corpus=20&=20=EC=A1=B0=EA=B1=B4?= =?UTF-8?q?=EB=B6=80=20push?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 설계서 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 --- docs/design/312-tool-prefix-corpus/README.md | 207 ++++++++++++++++++ .../corpus-procedure.md | 89 ++++++++ .../fn-corpus_logger.md | 134 ++++++++++++ .../fn-userTurn_partial_push.md | 116 ++++++++++ 4 files changed, 546 insertions(+) create mode 100644 docs/design/312-tool-prefix-corpus/README.md create mode 100644 docs/design/312-tool-prefix-corpus/corpus-procedure.md create mode 100644 docs/design/312-tool-prefix-corpus/fn-corpus_logger.md create mode 100644 docs/design/312-tool-prefix-corpus/fn-userTurn_partial_push.md diff --git a/docs/design/312-tool-prefix-corpus/README.md b/docs/design/312-tool-prefix-corpus/README.md new file mode 100644 index 0000000..3acc401 --- /dev/null +++ b/docs/design/312-tool-prefix-corpus/README.md @@ -0,0 +1,207 @@ +# 설계서: 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-153` 의 `for 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().isNotEmpty` → `ModelChatMessage` 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). + - 프로덕션 빌드의 로깅 — `CorpusLogger` 는 `kDebugMode` 가드 + 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 `LlmService` 가 `text → 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.dart` — `LlmChatEvent` sealed class (`LlmTextChunk` / `LlmFunctionCall` / `LlmDone`). + - `app/lib/state/chat_providers.dart` — `ChatSessionController.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.dart` — `abstract class CorpusLogger` + `DebugCorpusLogger` 구현 + factory. + - 수정: `app/lib/state/chat_providers.dart` — `ChatSessionController` 에 `final 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) + 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](./fn-corpus_logger.md)) | +| `CorpusLogger.onFunctionCall` | tool call 도착 시점의 누적 prefix + tool name 기록 | `void onFunctionCall(int turn, String accumulated, String toolName, Map args)` | turn idx, prefix, name, args | void | I/O swallow | **복잡** ([fn](./fn-corpus_logger.md)) | +| `DebugCorpusLogger.maybeCreate` | factory — `kDebugMode` + dart-define 가드 | `static CorpusLogger? maybeCreate()` | none | nullable logger | exception swallow → null | 단순 (factory) | +| `ChatSessionController.userTurn` (수정) | event 루프 + 조건부 push | (기존 시그니처) | (기존) | (기존) | (기존) | **복잡** ([fn](./fn-userTurn_partial_push.md)) | + +> 복잡 기준: 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.maybeCreate` — `kDebugMode=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). diff --git a/docs/design/312-tool-prefix-corpus/corpus-procedure.md b/docs/design/312-tool-prefix-corpus/corpus-procedure.md new file mode 100644 index 0000000..cd31b61 --- /dev/null +++ b/docs/design/312-tool-prefix-corpus/corpus-procedure.md @@ -0,0 +1,89 @@ +# Corpus 수집 절차 (#312) + +> **부모 설계서**: ./README.md · **목적**: Developer 가 corpus 를 재현 가능하게 수집할 수 있도록 절차 명문화. + +## 1. 빌드 + +```bash +cd app +flutter build apk --debug --dart-define=ENABLE_CORPUS_LOG=true +# 또는 단말 연결 후 +flutter run --debug --dart-define=ENABLE_CORPUS_LOG=true +``` + +`ENABLE_CORPUS_LOG=true` 가 빠지면 `DebugCorpusLogger.maybeCreate()` 가 null 반환 → logging 없음. + +## 2. 로그 캡처 + +```bash +# adb 연결된 단말 +adb logcat | grep CorpusLogger > /tmp/corpus-raw.log + +# 또는 flutter run 의 stdout +flutter run --debug --dart-define=ENABLE_CORPUS_LOG=true 2>&1 \ + | grep CorpusLogger > /tmp/corpus-raw.log +``` + +각 줄은 `[CorpusLogger] {"kind":"text_chunk"|"function_call",...}` 형태의 JSON. + +## 3. 프롬프트 시퀀스 (15 케이스) + +ChatScreen 진입 후 아래를 순서대로 입력. 각 프롬프트 사이에 ↻ 로 세션 초기화 (turn 격리). + +### catalog 카테고리 (5) +1. 수면 관련 습관 추천해줘 +2. 아침에 할 수 있는 습관 뭐 있어? +3. 스트레스 관리 프로토콜 알려줘 +4. 운동 관련 카탈로그 보여줘 +5. 명상 어떤 게 있어? + +### add_habit 카테고리 (5) +6. 아침 햇빛 보기 습관 추가해줘 +7. 매일 물 2L 마시기 추가해줘 +8. 잠들기 전 스트레칭 등록할래 +9. 출근 전 명상 5분 추가해줘 +10. 점심 후 산책 습관 만들어줘 + +### log_tracker_entry 카테고리 (3) +11. 오늘 햇빛 봤어 체크해줘 +12. 어제 운동한 거 기록해줘 +13. 오늘 명상 완료 + +### streak 카테고리 (2) +14. 내 연속 기록 어때? +15. 스트릭 보여줘 + +## 4. 결과 표 작성 + +`/tmp/corpus-raw.log` 의 각 `function_call` 이벤트에서 `accumulated_prefix` 를 추출해 `docs/research/312-tool-prefix-corpus.md` 표에 채운다. + +표 schema (README §6.2 참조): + +```markdown +| # | category | user_input | tool_name | accumulated_raw | meaningful | note | +|---|----------|-----------|-----------|-----------------|------------|------| +| 1 | catalog | 수면 관련 습관 추천해줘 | search_catalog | "수면 카탈로그를 보여드릴게요" | Y | 정보 전달 의도 + 14자 | +| 2 | catalog | 아침에 할 수 있는 습관 뭐 있어? | search_catalog | "" | N | 빈 prefix | +| ... | +``` + +판정 기준은 README §6.1 의 운영 정의: +- **Y**: 공백 제외 한국어 자연어 ≥10 자 + 정보 전달 의도. +- **N**: 빈/공백/boilerplate/단순 응대/echo. + +## 5. 임계 판정 + +- Y 카운트 ≥5 → 경로 A (push 구현). +- Y 카운트 =4 → +5 케이스 추가 수집 (총 20, 임계 7). +- Y 카운트 ≤3 → 경로 B (폐기 + ADR-0006). + +## 6. 후처리 + +- corpus 결과를 Redmine #312 의 `## [AI] Developer` 섹션 (또는 별도 댓글) 에 요약: Y/N count + 채택 경로. +- 채택 경로에 따라 `fn-userTurn_partial_push.md` 의 경로 A 또는 B 를 구현. +- 경로 B 채택 시 ADR-0006 작성 (`docs/adr/0006-tool-call-prefix-discard.md` 또는 적합한 제목). +- corpus 수집 종료 후 `--dart-define=ENABLE_CORPUS_LOG=true` 사용 빈도 0 → 향후 CorpusLogger 제거 follow-up 이슈 발행 검토. + +## 7. 보너스 — ParallelFunctionCallResponse 측정 + +같은 raw log 에서 한 turn 안에 `function_call` 이 2 회 이상 emit 되는지 확인. 발생 시 AC4 의 follow-up 이슈에 빈도 데이터 포함. diff --git a/docs/design/312-tool-prefix-corpus/fn-corpus_logger.md b/docs/design/312-tool-prefix-corpus/fn-corpus_logger.md new file mode 100644 index 0000000..7a9d6e1 --- /dev/null +++ b/docs/design/312-tool-prefix-corpus/fn-corpus_logger.md @@ -0,0 +1,134 @@ +# 함수 설계서: `CorpusLogger` (#312) + +> **부모 설계서**: ./README.md · **상태**: Draft +> **작성**: [AI] Architect · **구현**: `app/lib/ai/diagnostics/corpus_logger.dart` (신규) · **테스트**: `app/test/ai/diagnostics/corpus_logger_test.dart` (신규) + +## 1. 시그니처 +```dart +abstract class CorpusLogger { + void onTextChunk(int turn, String text); + void onFunctionCall( + int turn, + String accumulatedPrefix, + String toolName, + Map args, + ); +} + +class DebugCorpusLogger implements CorpusLogger { + /// kDebugMode + --dart-define=ENABLE_CORPUS_LOG=1 일 때만 non-null. + /// production 빌드에서는 항상 null. + static CorpusLogger? maybeCreate(); + + @override + void onTextChunk(int turn, String text); + + @override + void onFunctionCall(...); +} +``` + +## 2. 책임 (단일 책임, 1줄) +ChatSessionController 의 event 루프에서 발생한 텍스트 청크와 function call 의 raw payload 를 디버그 빌드에서 stdout 으로 dump 한다 — corpus 수집 부담을 줄이기 위한 한시적 진단 도구. + +## 3. 입력 + +### `onTextChunk` +| 파라미터 | 타입 | 제약/검증 | 설명 | +|----------|------|-----------|------| +| `turn` | int | ≥0 | userTurn 내 multi-turn 루프의 turn index (0=첫 LLM 응답). | +| `text` | String | non-null | 도착한 텍스트 청크 (raw, 누적 X). | + +### `onFunctionCall` +| 파라미터 | 타입 | 제약/검증 | 설명 | +|----------|------|-----------|------| +| `turn` | int | ≥0 | 동일. | +| `accumulatedPrefix` | String | non-null | tool call 도착 시점까지의 누적 텍스트 (chunks 의 concat). | +| `toolName` | String | non-null, non-empty | Gemma 가 호출한 도구 이름. | +| `args` | `Map` | non-null | tool 인자. JSON serializable 가정. | + +### `maybeCreate` +- 입력 없음. + +## 4. 출력 +- `onTextChunk` / `onFunctionCall`: **반환 없음**. 부수효과 = stdout 한 줄 emit (디버그). I/O 실패 시 swallow. +- `maybeCreate`: **반환** `CorpusLogger?` — `kDebugMode` 가 true 이고 `const bool.fromEnvironment('ENABLE_CORPUS_LOG')` 가 true 일 때 `DebugCorpusLogger()` 인스턴스, 그 외 null. + +## 5. 동작 / 알고리즘 + +### `DebugCorpusLogger.onTextChunk` +``` +1. _emit({ + 'kind': 'text_chunk', + 'turn': turn, + 'text': text, + }); +``` + +### `DebugCorpusLogger.onFunctionCall` +``` +1. _emit({ + 'kind': 'function_call', + 'turn': turn, + 'accumulated_prefix': accumulatedPrefix, + 'tool_name': toolName, + 'args': args, + }); +``` + +### `_emit(Map payload)` +``` +1. try: +2. final line = '[CorpusLogger] ' + jsonEncode(payload); +3. developer.log(line, name: 'CorpusLogger'); +4. catch (_): +5. // swallow — diagnostic 이 user flow 를 깨지 않게. +``` + +### `DebugCorpusLogger.maybeCreate` +``` +1. if (!kDebugMode) return null; +2. const enabled = bool.fromEnvironment('ENABLE_CORPUS_LOG', defaultValue: false); +3. if (!enabled) return null; +4. return DebugCorpusLogger._(); +``` + +## 6. 에러 & 실패 모드 +| 조건 | 처리 | 반환/예외 | +|------|------|-----------| +| `jsonEncode` 가 args 의 non-serializable 키로 throw | `_emit` 의 try/catch swallow | void (silent) | +| `developer.log` I/O 실패 | swallow | void | +| `maybeCreate` 가 production 호출 | `kDebugMode=false` 분기에서 null | null (정상) | +| ctor 직접 호출 시도 | private ctor `_()` 로 차단 | 컴파일 에러 | + +## 7. 엣지케이스 +- **매우 빈번한 호출**: 토큰 단위 stream 이라 `onTextChunk` 가 초당 수십 회. `developer.log` 가 stdout flush 부담 — 단, debug only + 한시적이라 수용. +- **args 에 BigInt / DateTime**: `jsonEncode` 가 throw → swallow. corpus 결과 누락 시 코드 보강 (toString fallback) 가능하지만 본 설계는 swallow 만. +- **multi-turn 루프**: 같은 userTurn 내에서 turn 0, 1, 2 ... 각각의 prefix 가 모두 캡처되어야 비교 가능 — caller (ChatSessionController) 가 정확한 turn idx 를 넘긴다. +- **logger null 이지만 inject 됨**: callsite 가 `logger?.onTextChunk(...)` 패턴이므로 null-safe. + +## 8. 복잡도 / 성능 +- 시간: 각 호출 O(payload 크기). `jsonEncode` 가 prefix 길이에 선형. +- 공간: emit 마다 임시 string. 영구 보관 없음 (stdout sink). +- 호출 빈도: 토큰당 1회 (text_chunk), tool call 당 1회 (function_call). 한 userTurn 에 수십-수백 호출 가능 — debug only 라 수용. + +## 9. 의존성 +- `dart:convert` — `jsonEncode`. +- `dart:developer` — `log`. +- `flutter/foundation.dart` — `kDebugMode`. +- 환경 변수: `ENABLE_CORPUS_LOG` (dart-define). + +## 10. 테스트 케이스 +- [ ] **maybeCreate**: production 시뮬 (kDebugMode false fake) → null 반환. +- [ ] **maybeCreate**: debug + ENABLE_CORPUS_LOG=false → null. +- [ ] **maybeCreate**: debug + ENABLE_CORPUS_LOG=true → non-null DebugCorpusLogger. +- [ ] **onFunctionCall happy**: 인자 정상 → stdout 에 `[CorpusLogger]` 라벨 + JSON 한 줄 emit (capture for verification). +- [ ] **onFunctionCall non-serializable args**: `{'date': DateTime.now()}` → throw 안 함 (swallow), test 가 timeout 없이 종료. +- [ ] **onTextChunk** 빈 텍스트 → swallow 없이 정상 emit (filter 안 함, 무엇이 들어왔는지 그대로 기록하는 게 corpus 의 정직성). + +> ENABLE_CORPUS_LOG 의 dart-define 기반 테스트는 `--dart-define=ENABLE_CORPUS_LOG=true` 로 별도 `flutter test` invocation 또는 mockable wrapper 로 분리. Architect 권고 = wrapper (`bool _readEnableFlag()` 를 visibleForTesting 으로 expose) 로 테스트 간소화. + +## 11. 추적성 +- 인수조건: AC1 (corpus 수집 인프라). +- 관련 ADR: 없음 (한시적 진단 도구). +- 본 이슈 종료 후 제거 여부 검토 — follow-up 이슈로 발행 권장 ("CorpusLogger 정리 — corpus 결과 반영 후 logger 제거 또는 영구화 결정"). diff --git a/docs/design/312-tool-prefix-corpus/fn-userTurn_partial_push.md b/docs/design/312-tool-prefix-corpus/fn-userTurn_partial_push.md new file mode 100644 index 0000000..84d0a85 --- /dev/null +++ b/docs/design/312-tool-prefix-corpus/fn-userTurn_partial_push.md @@ -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 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 채택 시).