feat(rag): multi-turn ask — Turn + ask_with_history + token budget (p9-fb-15) #60

Merged
altair823 merged 2 commits from feat/p9-fb-15-rag-multiturn into main 2026-05-02 23:14:57 +00:00
Owner

Summary

Spec PR #59 의 §3.8 multi-turn behaviour 구현. RAG facade 가 prior turns 받아 prompt 에 prepend, retrieval query 확장, Answerconversation_id / turn_index 채움. p9-fb-16 (TUI conversation UI) + p9-fb-17/18 (V004 storage + CLI session) 가 같은 facade 위에 build. 도그푸딩 항목 13 (꼬리 물기) 의 backend layer.

변경

kebab-core::Answer

pub struct Answer {
    /* 기존 fields */
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub conversation_id: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub turn_index: Option<u32>,
}

pub struct Turn {
    pub question: String, pub answer: String,
    pub citations: Vec<AnswerCitation>,
    pub created_at: OffsetDateTime,
}

pub enum RefusalReason { /* 기존 4 */, LlmStreamAborted }

skip_serializing_if 로 single-shot answer 의 wire output 변경 0 — 기존 외부 wrapper (Claude Code skill / MCP) 영향 없음.

kebab-rag::AskOpts + RagPipeline::ask_with_history

pub struct AskOpts {
    /* 기존 fields */
    pub history: Vec<Turn>,
    pub conversation_id: Option<String>,
    pub turn_index: Option<u32>,
}

pub fn ask_with_history(
    &self, query: &str, history: Vec<Turn>,
    conversation_id: String, turn_index: u32, opts: AskOpts,
) -> Result<Answer>;

prompt 빌드 + retrieval

  • expand_query_with_history: history.last().answer 첫 200 chars 를 query 에 concat — retriever 가 이전 turn 의 named entity ("Y" in "Y vs X" 의 차이?) 도 잡아냄.
  • serialize_history + remaining_history_budget_chars: spec §3.8 priority — system+question 필수, retrieved chunkspack_context budget 으로 fit, 남은 char budget (max_context_tokens * 4) 안에서 newest-first 로 history prepend, oldest drop.
  • [이전 대화] 블록 형식: Q: ...\nA: ...\n 반복, chronological order (oldest at top, newest just above current question).

기존 caller (single-shot 동작 동일)

  • kebab-cli Cmd::Ask: history=Vec::new(), conversation_id=None, turn_index=None 명시. multi-turn CLI 는 p9-fb-18.
  • kebab-tui ask spawn: 동일 (multi-turn UI = p9-fb-16).
  • kebab-eval runner: golden eval 은 single-shot per query.
  • 모든 Answer struct literal (4 sites) + AskOpts struct literal (5 sites) 새 field 명시.

kebab-store-sqlite

  • refusal_reason_labelLlmStreamAborted\"llm_stream_aborted\".

Test plan

  • cargo test -p kebab-rag --lib — 12 PASS (9 신규: expand_query 4 / serialize_history 3 / remaining_budget 2 + 기존 3).
  • cargo test -p kebab-core -p kebab-eval -p kebab-store-sqlite — 회귀 0.
  • cargo clippy --workspace --all-targets -- -D warnings clean.

후속

  • 머지 후 tasks/p9/p9-fb-15-rag-multi-turn-core.md status in_progresscompleted 한 줄 commit.
  • p9-fb-16 (TUI conversation UI) PR 가 RagPipeline::ask_with_history 호출하는 worker thread spawn.
## Summary Spec PR #59 의 §3.8 multi-turn behaviour 구현. RAG facade 가 prior turns 받아 prompt 에 prepend, retrieval query 확장, `Answer` 에 `conversation_id` / `turn_index` 채움. p9-fb-16 (TUI conversation UI) + p9-fb-17/18 (V004 storage + CLI session) 가 같은 facade 위에 build. 도그푸딩 항목 13 (꼬리 물기) 의 backend layer. ## 변경 ### `kebab-core::Answer` ```rust pub struct Answer { /* 기존 fields */ #[serde(default, skip_serializing_if = "Option::is_none")] pub conversation_id: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub turn_index: Option<u32>, } pub struct Turn { pub question: String, pub answer: String, pub citations: Vec<AnswerCitation>, pub created_at: OffsetDateTime, } pub enum RefusalReason { /* 기존 4 */, LlmStreamAborted } ``` `skip_serializing_if` 로 single-shot answer 의 wire output 변경 0 — 기존 외부 wrapper (Claude Code skill / MCP) 영향 없음. ### `kebab-rag::AskOpts` + `RagPipeline::ask_with_history` ```rust pub struct AskOpts { /* 기존 fields */ pub history: Vec<Turn>, pub conversation_id: Option<String>, pub turn_index: Option<u32>, } pub fn ask_with_history( &self, query: &str, history: Vec<Turn>, conversation_id: String, turn_index: u32, opts: AskOpts, ) -> Result<Answer>; ``` ### prompt 빌드 + retrieval - `expand_query_with_history`: `history.last().answer` 첫 200 chars 를 query 에 concat — retriever 가 이전 turn 의 named entity (\"Y\" in \"Y vs X\" 의 차이?) 도 잡아냄. - `serialize_history` + `remaining_history_budget_chars`: spec §3.8 priority — `system+question` 필수, `retrieved chunks` 가 `pack_context` budget 으로 fit, 남은 char budget (`max_context_tokens * 4`) 안에서 newest-first 로 history prepend, oldest drop. - `[이전 대화]` 블록 형식: `Q: ...\nA: ...\n` 반복, chronological order (oldest at top, newest just above current question). ### 기존 caller (single-shot 동작 동일) - `kebab-cli` Cmd::Ask: `history=Vec::new(), conversation_id=None, turn_index=None` 명시. multi-turn CLI 는 p9-fb-18. - `kebab-tui` ask spawn: 동일 (multi-turn UI = p9-fb-16). - `kebab-eval` runner: golden eval 은 single-shot per query. - 모든 Answer struct literal (4 sites) + AskOpts struct literal (5 sites) 새 field 명시. ### `kebab-store-sqlite` - `refusal_reason_label` 가 `LlmStreamAborted` → `\"llm_stream_aborted\"`. ## Test plan - [x] `cargo test -p kebab-rag --lib` — 12 PASS (9 신규: expand_query 4 / serialize_history 3 / remaining_budget 2 + 기존 3). - [x] `cargo test -p kebab-core -p kebab-eval -p kebab-store-sqlite` — 회귀 0. - [x] `cargo clippy --workspace --all-targets -- -D warnings` clean. ## 후속 - 머지 후 `tasks/p9/p9-fb-15-rag-multi-turn-core.md` status `in_progress` → `completed` 한 줄 commit. - p9-fb-16 (TUI conversation UI) PR 가 `RagPipeline::ask_with_history` 호출하는 worker thread spawn.
altair823 added 1 commit 2026-05-02 23:10:22 +00:00
Spec PR #59 의 §3.8 multi-turn behaviour 구현. RAG facade 가 prior
turns 받아 prompt 에 prepend, retrieval query expansion 적용,
Answer 에 conversation_id / turn_index 채움.

신규 (kebab-core):
- Answer 에 conversation_id (Option<String>) / turn_index (Option<u32>)
  field 추가. serde skip_serializing_if 로 single-shot 의 wire
  output 변경 0 (기존 외부 wrapper 영향 없음).
- Turn struct (question + answer + citations + created_at).
- RefusalReason::LlmStreamAborted variant.

신규 (kebab-rag):
- AskOpts 에 history (Vec<Turn>) / conversation_id / turn_index 3 field.
- AskOpts::single_shot(mode) helper.
- RagPipeline::ask_with_history(query, history, conversation_id,
  turn_index, opts) — combined opts 로 ask 호출.
- expand_query_with_history: history.last() 의 answer 첫 200 자
  concat 해 SearchQuery.text 확장 (spec §3.8 의 \"cheap concat\";
  LLM-based standalone-question rewriting 은 P+).
- serialize_history + remaining_history_budget_chars: spec 의 priority
  enforcement — system+question 필수, retrieved chunks 가 차지한
  뒤 남은 char budget 안에서 newest 우선, oldest drop.
- ask 본문: history 가 비어있지 않으면 [이전 대화] 블록을 user
  prompt 위에 prepend. Answer 생성 site 3 곳 (정상 / NoChunks /
  ScoreGate refuse) 모두 conversation_id / turn_index 채움.

신규 (kebab-store-sqlite):
- refusal_reason_label 가 LlmStreamAborted → 'llm_stream_aborted'.

기존 caller 변경 (single-shot 동작 동일):
- kebab-cli main.rs Cmd::Ask: AskOpts 에 history=Vec::new(),
  conversation_id=None, turn_index=None 명시 (CLI multi-turn 은
  p9-fb-18 의 --session/--repl 가 채움).
- kebab-tui src/ask.rs spawn site 동일 (multi-turn UI 는 p9-fb-16).
- kebab-eval runner.rs golden eval 동일 (single-shot per query).
- kebab-app tests/ask_smoke.rs / kebab-tui tests/ask.rs / kebab-rag
  tests/pipeline.rs / kebab-eval metrics.rs Answer literal 갱신.

Test:
- 9 신규 lib unit (expand_query 4 / serialize_history 3 / remaining_budget 2).
- 기존 12 PASS 회귀 0.

Plan 갱신:
- p9-fb-15 status planned → in_progress. 머지 후 한 줄 commit
  으로 completed flip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
claude-reviewer-01 requested changes 2026-05-02 23:12:19 +00:00
Dismissed
claude-reviewer-01 left a comment
Member

회차 1 — yagni nit 1건 + 칭찬 4건.

핵심 actionable:

  1. (yagni) AskOpts::single_shot(mode) helper 가 dead code. 본 PR 안 caller 0 — 모든 사���처 (5 site) 가 struct literal. CLAUDE.md "Don't add abstractions beyond what the task requires" 룰. 권장: helper 제거 (caller 들은 struct literal 그대로).

총평: spec PR #59 의 §3.8 multi-turn behaviour 를 정확히 구현. skip_serializing_if 로 single-shot wire 변경 0 / refuse path 3 곳 모두 conversation_id 채움 / serialize_history 의 newest-first fitness + chronological output 패턴 / char-based budget 보수성 — 4 칭찬. 위 nit 1건만 정리하면 머지.

회차 1 — yagni nit 1건 + 칭찬 4건. 핵심 actionable: 1. **(yagni) `AskOpts::single_shot(mode)` helper 가 dead code.** 본 PR 안 caller 0 — 모든 사���처 (5 site) 가 struct literal. CLAUDE.md \"Don't add abstractions beyond what the task requires\" 룰. 권장: helper 제거 (caller 들은 struct literal 그대로). 총평: spec PR #59 의 §3.8 multi-turn behaviour 를 정확히 구현. `skip_serializing_if` 로 single-shot wire 변경 0 / refuse path 3 곳 모두 conversation_id 채움 / `serialize_history` 의 newest-first fitness + chronological output 패턴 / char-based budget 보수성 — 4 칭찬. 위 nit 1건만 정리하면 머지.

(칭찬) #[serde(default, skip_serializing_if = "Option::is_none")] — single-shot answer 의 wire JSON 에 두 새 field 가 안 나타남. spec PR #59 의 "기존 외부 wrapper 영향 없음" 약속을 코드 레벨에서 강제. multi-turn 모르는 Claude Code skill / MCP 가 그대로 동작 + multi-turn answer 만 새 field 노출 — 두 모드의 wire output 자연 분리.

(칭찬) `#[serde(default, skip_serializing_if = "Option::is_none")]` — single-shot answer 의 wire JSON 에 두 새 field 가 안 나타남. spec PR #59 의 "기존 외부 wrapper 영향 없음" 약속을 코드 레벨에서 강제. multi-turn 모르는 Claude Code skill / MCP 가 그대로 동작 + multi-turn answer 만 새 field 노출 — 두 모드의 wire output 자연 분리.

(yagni / dead helper) AskOpts::single_shot(mode) 가 정의됐지만 본 PR 안에서 호출 site 0 — 모든 caller (CLI / TUI / eval / test / kebab-app smoke) 가 직접 struct literal AskOpts { ..., history: Vec::new(), conversation_id: None, turn_index: None } 사용. 미래 entry slot 의도지만 caller 들이 struct 직접 사용 → helper 가 dead code 가까움.

Why: CLAUDE.md 의 Don't add abstractions beyond what the task requires 룰. helper 가 진짜 가치 있다면 본 PR 의 caller 들도 같이 이걸로 migrate 해야 명확. 그렇지 않으면 미래 reader 가 "struct literal 과 single_shot() 중 어느 것이 canonical?" 의문 생김.

How to apply 두 가지 중:

  1. helper 제거 (yagni). caller 들은 struct literal 그대로 — 패턴 단일.
  2. 본 PR 안에서 caller 들 (5 site) 을 AskOpts::single_shot(mode) 로 migrate + 추가 옵션은 mut + field 갱신. 한 패턴.

권장: 1. 5 site 의 struct literal 가 명시적 + 새 caller 가 IDE 자동완성으로 모든 field 보임 — single_shot helper 의 가치 ↓.

(yagni / dead helper) `AskOpts::single_shot(mode)` 가 정의됐지만 본 PR 안에서 호출 site 0 — 모든 caller (CLI / TUI / eval / test / kebab-app smoke) 가 직접 struct literal `AskOpts { ..., history: Vec::new(), conversation_id: None, turn_index: None }` 사용. 미래 entry slot 의도지만 caller 들이 struct 직접 사용 → helper 가 dead code 가까움. Why: CLAUDE.md 의 `Don't add abstractions beyond what the task requires` 룰. helper 가 진짜 가치 있다면 본 PR 의 caller 들도 같이 이걸로 migrate 해야 명확. 그렇지 않으면 미래 reader 가 "struct literal 과 single_shot() 중 어느 것이 canonical?" 의문 생김. How to apply 두 가지 중: 1. helper 제거 (yagni). caller 들은 struct literal 그대로 — 패턴 단일. 2. 본 PR 안에서 caller 들 (5 site) 을 `AskOpts::single_shot(mode)` 로 migrate + 추가 옵션은 mut + field 갱신. 한 패턴. 권장: 1. 5 site 의 struct literal 가 명시적 + 새 caller 가 IDE 자동완성으로 모든 field 보임 — single_shot helper 의 가치 ↓.

(칭찬) Refuse path 3 곳 (정상 / NoChunks / ScoreGate) 모두 conversation_id: opts.conversation_id.clone() 채움 — refuse 시도 multi-turn 의 한 turn 으로 SQLite answers 에 기록. 다음 turn 의 history 에 "이전에 거절했음" 컨텍스트로 들어감 (LLM 이 같은 거절 반복 회피). single-shot 처럼 None 으로 비우면 conversation 의 "실패한 turn" 흔적이 사라져 reconstruct 어려움.

(칭찬) Refuse path 3 곳 (정상 / NoChunks / ScoreGate) 모두 `conversation_id: opts.conversation_id.clone()` 채움 — refuse 시도 multi-turn 의 한 turn 으로 SQLite `answers` 에 기록. 다음 turn 의 history 에 "이전에 거절했음" 컨텍스트로 들어감 (LLM 이 같은 거절 반복 회피). single-shot 처럼 None 으로 비우면 conversation 의 "실패한 turn" 흔적이 사라져 reconstruct 어려움.

(칭찬) char-based budget (max_context_tokens * 4) — Ollama 의 실제 token count 보다 보수적 (한국어 평균 1 char = 1 token, 영어 평균 4 chars = 1 token, 평균 2~3 chars/token). 4 chars/token 은 영어 기준 conservative — 한국어 쓰면 더 작은 budget 사용 → history 더 많이 drop. 보수적이라 LM context overflow 위험 ↓. token-precise budget 은 tiktoken-rs 같은 dep 필요 — out of scope.

(칭찬) char-based budget (`max_context_tokens * 4`) — Ollama 의 실제 token count 보다 보수적 (한국어 평균 1 char = 1 token, 영어 평균 4 chars = 1 token, 평균 2~3 chars/token). 4 chars/token 은 영어 기준 conservative — 한국어 쓰면 더 작은 budget 사용 → history 더 많이 drop. 보수적이라 LM context overflow 위험 ↓. token-precise budget 은 `tiktoken-rs` 같은 dep 필요 — out of scope.

(칭찬) serialize_history 의 reverse iter + 최종 reverse 패턴이 newest-first fitness check + chronological output 둘 다 한 함수에. 알고리즘:

  1. history.iter().rev() — newest 부터 budget 검사.
  2. fit 안 되면 break (oldest 자동 drop).
  3. included_rev.iter().rev() — chronological order 로 출력.

결과: budget 안에서 최대한 많은 newest turn 을 포함, LM 은 oldest→newest 자연 순서로 읽음. spec §3.8 "newest 우선 보존, oldest drop" priority 정확 매핑.

(칭찬) `serialize_history` 의 reverse iter + 최종 reverse 패턴이 newest-first fitness check + chronological output 둘 다 한 함수에. 알고리즘: 1. `history.iter().rev()` — newest 부터 budget 검사. 2. fit 안 되면 break (oldest 자동 drop). 3. `included_rev.iter().rev()` — chronological order 로 출력. 결과: budget 안에서 최대한 많은 newest turn 을 포함, LM 은 oldest→newest 자연 순서로 읽음. spec §3.8 "newest 우선 보존, oldest drop" priority 정확 매핑.
altair823 added 1 commit 2026-05-02 23:14:20 +00:00
회차 1 nit 반영. helper 가 본 PR 안 caller 0 — 모든 사용처가
struct literal 패턴. CLAUDE.md "Don't add abstractions beyond
what the task requires" 룰. 미래 caller 가 필요 시 본인이 추가.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
claude-reviewer-01 approved these changes 2026-05-02 23:14:43 +00:00
claude-reviewer-01 left a comment
Member

회차 2 — yagni helper (AskOpts::single_shot) 제거. CLAUDE.md '추상화 최소' 룰 정합. APPROVE.

회차 2 — yagni helper (AskOpts::single_shot) 제거. CLAUDE.md '추상화 최소' 룰 정합. APPROVE.
altair823 merged commit 76fbb44e83 into main 2026-05-02 23:14:57 +00:00
altair823 deleted branch feat/p9-fb-15-rag-multiturn 2026-05-02 23:14:58 +00:00
Sign in to join this conversation.
No Reviewers
No Label
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: altair823-org/kebab#60