feat(rag): multi-turn ask — Turn struct + ask_with_history + token budget (p9-fb-15)
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>
This commit is contained in:
@@ -36,7 +36,7 @@ use anyhow::{Context, Result};
|
||||
use kebab_core::{
|
||||
Answer, AnswerCitation, AnswerRetrievalSummary, Citation, FinishReason,
|
||||
GenerateRequest, LanguageModel, ModelRef, RefusalReason, Retriever, SearchFilters,
|
||||
SearchHit, SearchMode, SearchQuery, TokenChunk, TokenUsage, TraceId,
|
||||
SearchHit, SearchMode, SearchQuery, TokenChunk, TokenUsage, TraceId, Turn,
|
||||
};
|
||||
use kebab_core::versions::PromptTemplateVersion;
|
||||
use kebab_store_sqlite::SqliteStore;
|
||||
@@ -80,6 +80,41 @@ pub struct AskOpts {
|
||||
/// pipeline — `SendError` is silently swallowed and generation
|
||||
/// continues so the `Answer` row still gets persisted.
|
||||
pub stream_sink: Option<std::sync::mpsc::Sender<String>>,
|
||||
/// p9-fb-15: prior turns of the same conversation. Empty for
|
||||
/// single-shot ask. The pipeline prepends a serialized `[이전
|
||||
/// 대화]` block to the user prompt and uses the most-recent
|
||||
/// answer's first 200 chars to expand the retrieval query
|
||||
/// (cheap concat — LLM-based standalone-question rewriting is
|
||||
/// out of scope per spec §3.8). Newest-first prepended; older
|
||||
/// turns drop when the prompt would otherwise exceed
|
||||
/// `cfg.rag.max_context_tokens`.
|
||||
pub history: Vec<Turn>,
|
||||
/// p9-fb-15: same conversation 의 turn 들이 공유. Filled into
|
||||
/// `Answer.conversation_id`. None for single-shot ask.
|
||||
pub conversation_id: Option<String>,
|
||||
/// p9-fb-15: 0-based index within `conversation_id`. Caller
|
||||
/// (TUI / CLI session) computes from `history.len()`. None for
|
||||
/// single-shot ask.
|
||||
pub turn_index: Option<u32>,
|
||||
}
|
||||
|
||||
impl AskOpts {
|
||||
/// Default knobs for a single-shot ask (no history, no
|
||||
/// conversation_id). `k` falls through to the config floor in
|
||||
/// `RagPipeline::ask`.
|
||||
pub fn single_shot(mode: SearchMode) -> Self {
|
||||
Self {
|
||||
k: 0,
|
||||
explain: false,
|
||||
mode,
|
||||
temperature: None,
|
||||
seed: None,
|
||||
stream_sink: None,
|
||||
history: Vec::new(),
|
||||
conversation_id: None,
|
||||
turn_index: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── RagPipeline ─────────────────────────────────────────────────────────────
|
||||
@@ -111,6 +146,29 @@ impl RagPipeline {
|
||||
}
|
||||
}
|
||||
|
||||
/// p9-fb-15: convenience for multi-turn ask. Stuffs `history`,
|
||||
/// `conversation_id`, `turn_index` into a fresh `AskOpts` (built
|
||||
/// from `opts.mode` + carried-through knobs) and forwards to
|
||||
/// [`Self::ask`]. The returned `Answer` carries the same
|
||||
/// `conversation_id` / `turn_index`. CLI / TUI sessions call this
|
||||
/// once per follow-up question.
|
||||
pub fn ask_with_history(
|
||||
&self,
|
||||
query: &str,
|
||||
history: Vec<Turn>,
|
||||
conversation_id: String,
|
||||
turn_index: u32,
|
||||
opts: AskOpts,
|
||||
) -> Result<Answer> {
|
||||
let combined = AskOpts {
|
||||
history,
|
||||
conversation_id: Some(conversation_id),
|
||||
turn_index: Some(turn_index),
|
||||
..opts
|
||||
};
|
||||
self.ask(query, combined)
|
||||
}
|
||||
|
||||
/// Run one query through the full pipeline. Always persists an
|
||||
/// `answers` row (including refusals); the row write is best-effort
|
||||
/// — a persistence error is surfaced via `tracing::warn!` so the
|
||||
@@ -121,8 +179,14 @@ impl RagPipeline {
|
||||
// ── 1. Retrieve ────────────────────────────────────────────────────
|
||||
// floor at config default — see `AskOpts::k` doc for rationale.
|
||||
let k_effective = opts.k.max(self.config.search.default_k);
|
||||
// p9-fb-15: query expansion when history is present.
|
||||
// Concat the most-recent answer's first 200 chars so the
|
||||
// retriever sees the full conversational context. Cheap —
|
||||
// LLM-based standalone-question rewriting is out of scope
|
||||
// (spec §3.8 marks it P+).
|
||||
let expanded_query = expand_query_with_history(query, &opts.history);
|
||||
let search_query = SearchQuery {
|
||||
text: query.to_string(),
|
||||
text: expanded_query,
|
||||
mode: opts.mode,
|
||||
k: k_effective,
|
||||
filters: SearchFilters::default(),
|
||||
@@ -171,7 +235,25 @@ impl RagPipeline {
|
||||
|
||||
// ── 4. Render prompt ───────────────────────────────────────────────
|
||||
let system = SYSTEM_PROMPT_RAG_V1.to_string();
|
||||
let user = format!("[질문]\n{query}\n\n[근거]\n{packed_text}");
|
||||
// p9-fb-15: prepend `[이전 대화]` block when history is
|
||||
// present. `serialize_history` enforces the spec §3.8
|
||||
// priority — system+question stay untouched, retrieved
|
||||
// chunks already fit (`pack_context` honoured the budget),
|
||||
// so the budget remaining for history is what's left over.
|
||||
let history_budget_chars = remaining_history_budget_chars(
|
||||
self.config.rag.max_context_tokens,
|
||||
&system,
|
||||
query,
|
||||
&packed_text,
|
||||
);
|
||||
let history_block = serialize_history(&opts.history, history_budget_chars);
|
||||
let user = if history_block.is_empty() {
|
||||
format!("[질문]\n{query}\n\n[근거]\n{packed_text}")
|
||||
} else {
|
||||
format!(
|
||||
"{history_block}\n\n[질문]\n{query}\n\n[근거]\n{packed_text}"
|
||||
)
|
||||
};
|
||||
|
||||
// ── 5. Generate ────────────────────────────────────────────────────
|
||||
// Completion budget is bounded only by what the LM context window
|
||||
@@ -322,6 +404,8 @@ impl RagPipeline {
|
||||
},
|
||||
usage: usage_final,
|
||||
created_at: OffsetDateTime::now_utc(),
|
||||
conversation_id: opts.conversation_id.clone(),
|
||||
turn_index: opts.turn_index,
|
||||
};
|
||||
|
||||
// Drop the moved `finish_reason` early into a tracing breadcrumb; the
|
||||
@@ -455,6 +539,8 @@ impl RagPipeline {
|
||||
latency_ms: elapsed_ms,
|
||||
},
|
||||
created_at: OffsetDateTime::now_utc(),
|
||||
conversation_id: opts.conversation_id.clone(),
|
||||
turn_index: opts.turn_index,
|
||||
};
|
||||
if let Err(e) = self.docs.put_answer(&answer, query, None) {
|
||||
tracing::warn!(target: "kebab-rag", error = %e, "kb-rag: put_answer (NoChunks) failed");
|
||||
@@ -530,6 +616,8 @@ impl RagPipeline {
|
||||
latency_ms: elapsed_ms,
|
||||
},
|
||||
created_at: OffsetDateTime::now_utc(),
|
||||
conversation_id: opts.conversation_id.clone(),
|
||||
turn_index: opts.turn_index,
|
||||
};
|
||||
if let Err(e) = self.docs.put_answer(&answer, query, None) {
|
||||
tracing::warn!(target: "kebab-rag", error = %e, "kb-rag: put_answer (ScoreGate) failed");
|
||||
@@ -569,6 +657,80 @@ fn est_tokens(s: &str) -> usize {
|
||||
s.chars().count().div_ceil(4)
|
||||
}
|
||||
|
||||
/// p9-fb-15: expand the retrieval query with the most-recent answer's
|
||||
/// first 200 chars when history is non-empty. Cheap concat per spec
|
||||
/// §3.8 — LLM-based standalone-question rewriting is P+. The retriever
|
||||
/// sees `<question> <last answer prefix>` so embedding / FTS hit on
|
||||
/// names from the prior turn ("Y" in "Y vs X 의 차이?") still surfaces
|
||||
/// the right chunks.
|
||||
fn expand_query_with_history(query: &str, history: &[Turn]) -> String {
|
||||
let Some(last) = history.last() else {
|
||||
return query.to_string();
|
||||
};
|
||||
let prefix: String = last.answer.chars().take(200).collect();
|
||||
if prefix.is_empty() {
|
||||
query.to_string()
|
||||
} else {
|
||||
format!("{query} {prefix}")
|
||||
}
|
||||
}
|
||||
|
||||
/// p9-fb-15: how many *chars* of history block we may afford. The
|
||||
/// budget is `cfg.rag.max_context_tokens * BYTES_PER_TOKEN` minus the
|
||||
/// chars already committed to system + question + retrieved chunks.
|
||||
/// Returns 0 (history fully dropped) when budget already exhausted.
|
||||
fn remaining_history_budget_chars(
|
||||
max_context_tokens: usize,
|
||||
system: &str,
|
||||
question: &str,
|
||||
packed_text: &str,
|
||||
) -> usize {
|
||||
let total_chars = max_context_tokens.saturating_mul(4);
|
||||
let used = system.chars().count()
|
||||
+ question.chars().count()
|
||||
+ packed_text.chars().count()
|
||||
// Account for the format-string overhead: `[질문]\n` + `\n\n[근거]\n`
|
||||
// + `\n\n` between history and question. Round up to ~32 chars
|
||||
// to keep the maths simple.
|
||||
+ 32;
|
||||
total_chars.saturating_sub(used)
|
||||
}
|
||||
|
||||
/// p9-fb-15: serialize history into the `[이전 대화]` block. Newest
|
||||
/// turn first per spec §3.8 — the loop walks `history` in reverse and
|
||||
/// stops as soon as appending the next turn would exceed `budget_chars`.
|
||||
/// Empty when history is empty or no turn fits.
|
||||
fn serialize_history(history: &[Turn], budget_chars: usize) -> String {
|
||||
if history.is_empty() || budget_chars == 0 {
|
||||
return String::new();
|
||||
}
|
||||
// Build newest-first, then reverse so the LM reads chronological
|
||||
// order ("Q1/A1\nQ2/A2 → newest at the bottom, just above the
|
||||
// current question").
|
||||
let mut included_rev: Vec<String> = Vec::new();
|
||||
let mut used = 0usize;
|
||||
let header = "[이전 대화]\n";
|
||||
let header_len = header.chars().count();
|
||||
for turn in history.iter().rev() {
|
||||
let block = format!("Q: {}\nA: {}\n", turn.question, turn.answer);
|
||||
let blen = block.chars().count();
|
||||
if used + blen + header_len > budget_chars {
|
||||
break;
|
||||
}
|
||||
used += blen;
|
||||
included_rev.push(block);
|
||||
}
|
||||
if included_rev.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let mut out = String::with_capacity(used + header_len);
|
||||
out.push_str(header);
|
||||
for block in included_rev.iter().rev() {
|
||||
out.push_str(block);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Strict marker regex per design §1 / spec line 107: `[#1]` … `[#999]`.
|
||||
/// Matches without `#`, with whitespace, or with non-digit content are
|
||||
/// intentionally ignored (see test plan rows 5–6).
|
||||
@@ -634,4 +796,104 @@ mod tests {
|
||||
// 8 chars → 2 tokens
|
||||
assert_eq!(est_tokens("abcdefgh"), 2);
|
||||
}
|
||||
|
||||
// ── p9-fb-15: multi-turn helpers ───────────────────────────────────────
|
||||
|
||||
fn fake_turn(question: &str, answer: &str) -> Turn {
|
||||
Turn {
|
||||
question: question.into(),
|
||||
answer: answer.into(),
|
||||
citations: Vec::new(),
|
||||
created_at: OffsetDateTime::now_utc(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_query_with_history_empty_returns_query_unchanged() {
|
||||
assert_eq!(expand_query_with_history("hi", &[]), "hi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_query_with_history_concats_last_answer_prefix() {
|
||||
let h = vec![fake_turn("Q1", "first answer body")];
|
||||
let expanded = expand_query_with_history("follow-up", &h);
|
||||
assert!(expanded.starts_with("follow-up "), "got: {expanded}");
|
||||
assert!(
|
||||
expanded.contains("first answer body"),
|
||||
"got: {expanded}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_query_caps_last_answer_at_200_chars() {
|
||||
let long = "x".repeat(500);
|
||||
let h = vec![fake_turn("Q", &long)];
|
||||
let expanded = expand_query_with_history("q", &h);
|
||||
// query (1 char) + space (1) + 200 of x = 202.
|
||||
assert_eq!(expanded.chars().count(), 1 + 1 + 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_query_uses_last_turn_only() {
|
||||
let h = vec![
|
||||
fake_turn("Q1", "FIRST ANSWER"),
|
||||
fake_turn("Q2", "LATEST ANSWER"),
|
||||
];
|
||||
let expanded = expand_query_with_history("q3", &h);
|
||||
assert!(expanded.contains("LATEST ANSWER"), "got: {expanded}");
|
||||
assert!(!expanded.contains("FIRST ANSWER"), "got: {expanded}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_history_empty_returns_empty_string() {
|
||||
assert_eq!(serialize_history(&[], 1000), "");
|
||||
let h = vec![fake_turn("q", "a")];
|
||||
assert_eq!(serialize_history(&h, 0), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_history_chronological_order_with_header() {
|
||||
let h = vec![
|
||||
fake_turn("Q1", "A1"),
|
||||
fake_turn("Q2", "A2"),
|
||||
fake_turn("Q3", "A3"),
|
||||
];
|
||||
let s = serialize_history(&h, 1000);
|
||||
assert!(s.starts_with("[이전 대화]\n"), "got: {s:?}");
|
||||
let q1_pos = s.find("Q1").unwrap();
|
||||
let q3_pos = s.find("Q3").unwrap();
|
||||
assert!(q1_pos < q3_pos, "chronological: oldest first; got: {s:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_history_drops_oldest_when_budget_tight() {
|
||||
// Budget tight enough that only 1 of 3 turns fits.
|
||||
let h = vec![
|
||||
fake_turn("Q1", "A1"),
|
||||
fake_turn("Q2", "A2"),
|
||||
fake_turn("Q3", "A3"),
|
||||
];
|
||||
// Header is "[이전 대화]\n" (8 chars) + 1 turn ("Q: Q3\nA: A3\n" = 12 chars) ≈ 20.
|
||||
let s = serialize_history(&h, 25);
|
||||
assert!(s.contains("Q3"), "newest must be kept: {s:?}");
|
||||
assert!(!s.contains("Q1"), "oldest dropped: {s:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remaining_history_budget_subtracts_known_pieces() {
|
||||
// total = 100 tokens * 4 chars = 400 chars budget.
|
||||
// system 100 chars + question 50 chars + packed 150 chars + 32 overhead = 332. left = 68.
|
||||
let s = "x".repeat(100);
|
||||
let q = "y".repeat(50);
|
||||
let p = "z".repeat(150);
|
||||
let left = remaining_history_budget_chars(100, &s, &q, &p);
|
||||
assert_eq!(left, 400 - 100 - 50 - 150 - 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remaining_history_budget_clamps_to_zero_when_overrun() {
|
||||
let s = "x".repeat(1000);
|
||||
let left = remaining_history_budget_chars(10, &s, "q", "p");
|
||||
assert_eq!(left, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,9 @@ fn default_opts() -> AskOpts {
|
||||
temperature: Some(0.0),
|
||||
seed: Some(0),
|
||||
stream_sink: None,
|
||||
history: Vec::new(),
|
||||
conversation_id: None,
|
||||
turn_index: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user