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>
115 lines
4.7 KiB
Rust
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",
|
|
}
|
|
}
|