Files
kebab/crates/kebab-store-sqlite/src/answers.rs
altair823 2c058ab175 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>
2026-05-02 23:09:46 +00:00

115 lines
4.7 KiB
Rust

//! `answers` row writer (P4-3 — design §5.7).
//!
//! `kb-rag` always persists an `answers` row at the end of every
//! `RagPipeline::ask` — including refusal paths (`NoChunks`,
//! `ScoreGate`, `LlmSelfJudge`). The trait `kebab_core::DocumentStore`
//! does not surface this method (answers aren't documents); we add it
//! as an inherent method on `SqliteStore` so kb-rag can call
//! `self.docs.put_answer(...)` directly.
use anyhow::{Context, Result};
use kebab_core::{Answer, RefusalReason, SearchMode};
use rusqlite::params;
use crate::error::StoreError;
use crate::store::SqliteStore;
impl SqliteStore {
/// Insert one row into `answers` (per V001 schema). The `query` is
/// the original user query and is NOT recoverable from `Answer` —
/// it lives only on the wire payload, not on the in-memory struct.
/// `packed_chunks_json` is `Some` only when the caller asked for
/// `--explain` (kb-rag's `AskOpts.explain == true`); otherwise the
/// column stores SQL `NULL` per design §5.7.
///
/// Idempotency: inserts only. The PRIMARY KEY is `trace_id`, which
/// kb-rag mints with a nanosecond suffix so collisions are
/// effectively impossible. If a duplicate trace_id ever does land
/// (e.g., a test harness reuses one), the underlying SQLite
/// `UNIQUE` violation surfaces verbatim through `StoreError`.
pub fn put_answer(
&self,
answer: &Answer,
query: &str,
packed_chunks_json: Option<&str>,
) -> Result<()> {
let created_at = answer
.created_at
.format(&time::format_description::well_known::Rfc3339)
.context("format answer.created_at")?;
let citations_json = serde_json::to_string(&answer.citations)
.context("serialize answer.citations")?;
let refusal_label: Option<&'static str> =
answer.refusal_reason.as_ref().map(refusal_reason_label);
let mode_label = search_mode_label(&answer.retrieval.mode);
let embedding_id: Option<&str> = answer.embedding.as_ref().map(|m| m.id.as_str());
let embedding_dim: Option<i64> =
answer.embedding.as_ref().and_then(|m| m.dimensions.map(|d| d as i64));
let conn = self.lock_conn();
conn.execute(
"INSERT INTO answers (
trace_id, query, answer, grounded, refusal_reason,
model_id, model_provider,
embedding_model_id, embedding_dimensions,
prompt_template_version,
retrieval_mode, retrieval_k, score_gate, top_score,
chunks_returned, chunks_used,
citations_json, packed_chunks_json,
prompt_tokens, completion_tokens, latency_ms,
created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
params![
answer.retrieval.trace_id.0,
query,
answer.answer,
if answer.grounded { 1_i64 } else { 0_i64 },
refusal_label,
answer.model.id,
answer.model.provider,
embedding_id,
embedding_dim,
answer.prompt_template_version.0,
mode_label,
answer.retrieval.k as i64,
answer.retrieval.score_gate as f64,
answer.retrieval.top_score as f64,
answer.retrieval.chunks_returned as i64,
answer.retrieval.chunks_used as i64,
citations_json,
packed_chunks_json,
answer.usage.prompt_tokens as i64,
answer.usage.completion_tokens as i64,
answer.usage.latency_ms as i64,
created_at,
],
)
.map_err(StoreError::from)?;
Ok(())
}
}
/// Stable lower-case label used in the `answers.refusal_reason` column
/// (design §5.7). Mirrors the `serde(rename_all = "snake_case")`
/// representation on `RefusalReason` so wire and DB labels coincide.
fn refusal_reason_label(r: &RefusalReason) -> &'static str {
match r {
RefusalReason::ScoreGate => "score_gate",
RefusalReason::LlmSelfJudge => "llm_self_judge",
RefusalReason::NoIndex => "no_index",
RefusalReason::NoChunks => "no_chunks",
RefusalReason::LlmStreamAborted => "llm_stream_aborted",
}
}
/// Stable label used in the `answers.retrieval_mode` column. Mirrors
/// the `serde(rename_all = "lowercase")` representation on
/// `SearchMode` so wire and DB labels coincide.
fn search_mode_label(m: &SearchMode) -> &'static str {
match m {
SearchMode::Lexical => "lexical",
SearchMode::Vector => "vector",
SearchMode::Hybrid => "hybrid",
}
}